feat: web UI — dashboard, indexed browser, publisher manager, publish queue

This commit is contained in:
2026-05-17 15:05:50 -07:00
parent f98e6f8dfa
commit e6fb4f48c6
8 changed files with 990 additions and 1 deletions
+198
View File
@@ -717,6 +717,204 @@ pub async fn get_identity(pool: &SqlitePool) -> anyhow::Result<Option<IdentityRo
Ok(row.map(|(pubkey, nsec, bunker_url)| IdentityRow { pubkey, nsec, bunker_url }))
}
// ─── UI helpers ───────────────────────────────────────────────────────────────
pub struct UiStats {
pub torrent_count: i64,
pub publisher_count: i64,
pub queued_count: i64,
pub published_count: i64,
pub last_event_at: Option<i64>,
}
pub async fn ui_stats(pool: &SqlitePool) -> anyhow::Result<UiStats> {
let (torrent_count, last_event_at): (i64, Option<i64>) =
sqlx::query_as("SELECT COUNT(*), MAX(ingested_at) FROM torrents")
.fetch_one(pool)
.await?;
let (publisher_count,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM publishers")
.fetch_one(pool)
.await?;
let (queued_count,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM publish_queue WHERE published_at IS NULL AND error IS NULL")
.fetch_one(pool)
.await
.unwrap_or((0,));
let (published_count,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM publish_queue WHERE published_at IS NOT NULL")
.fetch_one(pool)
.await
.unwrap_or((0,));
Ok(UiStats { torrent_count, publisher_count, queued_count, published_count, last_event_at })
}
pub struct UiRelayRow {
pub url: String,
pub last_event: Option<i64>,
}
pub async fn list_relays_ui(pool: &SqlitePool) -> anyhow::Result<Vec<UiRelayRow>> {
let rows: Vec<(String, Option<i64>)> =
sqlx::query_as("SELECT url, last_event FROM relays WHERE enabled = 1 ORDER BY url")
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|(url, last_event)| UiRelayRow { url, last_event }).collect())
}
pub struct RecentTorrentRow {
pub title: String,
pub category: String,
pub pubkey: String,
pub ingested_at: i64,
}
pub async fn recent_torrents(pool: &SqlitePool, limit: i64) -> anyhow::Result<Vec<RecentTorrentRow>> {
let rows: Vec<(String, String, String, i64)> = sqlx::query_as(
"SELECT title, COALESCE(category,''), pubkey, ingested_at
FROM torrents ORDER BY ingested_at DESC LIMIT ?",
)
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|(title, category, pubkey, ingested_at)| RecentTorrentRow {
title, category, pubkey, ingested_at,
}).collect())
}
pub struct BrowseTorrentRow {
pub event_id: String,
pub info_hash: String,
pub title: String,
pub category: String,
pub size_bytes: Option<i64>,
pub pubkey: String,
pub ingested_at: i64,
}
impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for BrowseTorrentRow {
fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result<Self, sqlx::Error> {
use sqlx::Row;
Ok(BrowseTorrentRow {
event_id: row.try_get("event_id")?,
info_hash: row.try_get("info_hash")?,
title: row.try_get("title")?,
category: row.try_get("category").unwrap_or_default(),
size_bytes: row.try_get("size_bytes")?,
pubkey: row.try_get("pubkey")?,
ingested_at: row.try_get("ingested_at")?,
})
}
}
pub async fn browse_torrents(
pool: &SqlitePool,
q: &str,
limit: i64,
offset: i64,
) -> anyhow::Result<Vec<BrowseTorrentRow>> {
if q.is_empty() {
sqlx::query_as(
"SELECT event_id, info_hash, title, COALESCE(category,'') AS category,
size_bytes, pubkey, ingested_at
FROM torrents ORDER BY ingested_at DESC LIMIT ? OFFSET ?",
)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await
.context("browse_torrents")
} else {
sqlx::query_as(
"SELECT t.event_id, t.info_hash, t.title, COALESCE(t.category,'') AS category,
t.size_bytes, t.pubkey, t.ingested_at
FROM torrents t
WHERE t.rowid IN (SELECT rowid FROM torrents_fts WHERE torrents_fts MATCH ?)
ORDER BY t.ingested_at DESC LIMIT ? OFFSET ?",
)
.bind(q)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await
.context("browse_torrents fts")
}
}
pub async fn count_browse(pool: &SqlitePool, q: &str) -> anyhow::Result<i64> {
if q.is_empty() {
let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM torrents")
.fetch_one(pool)
.await?;
Ok(n)
} else {
let (n,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM torrents_fts WHERE torrents_fts MATCH ?")
.bind(q)
.fetch_one(pool)
.await?;
Ok(n)
}
}
pub struct QueueRow {
pub id: i64,
pub info_hash: String,
pub title: String,
pub category: String,
pub queued_at: i64,
pub scheduled_at: i64,
pub published_at: Option<i64>,
pub event_id: Option<String>,
pub error: Option<String>,
}
impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for QueueRow {
fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result<Self, sqlx::Error> {
use sqlx::Row;
Ok(QueueRow {
id: row.try_get("id")?,
info_hash: row.try_get("info_hash")?,
title: row.try_get("title")?,
category: row.try_get("category").unwrap_or_default(),
queued_at: row.try_get("queued_at")?,
scheduled_at: row.try_get("scheduled_at")?,
published_at: row.try_get("published_at")?,
event_id: row.try_get("event_id")?,
error: row.try_get("error")?,
})
}
}
pub async fn list_queue_items(
pool: &SqlitePool,
done: Option<bool>,
limit: i64,
offset: i64,
) -> anyhow::Result<Vec<QueueRow>> {
let sql = match done {
None => "SELECT id,info_hash,title,COALESCE(category,'') AS category,queued_at,scheduled_at,published_at,event_id,error FROM publish_queue ORDER BY queued_at DESC LIMIT ? OFFSET ?",
Some(true) => "SELECT id,info_hash,title,COALESCE(category,'') AS category,queued_at,scheduled_at,published_at,event_id,error FROM publish_queue WHERE published_at IS NOT NULL ORDER BY published_at DESC LIMIT ? OFFSET ?",
Some(false) => "SELECT id,info_hash,title,COALESCE(category,'') AS category,queued_at,scheduled_at,published_at,event_id,error FROM publish_queue WHERE published_at IS NULL ORDER BY scheduled_at ASC LIMIT ? OFFSET ?",
};
Ok(sqlx::query_as(sql)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await
.unwrap_or_default())
}
pub async fn count_queue_items(pool: &SqlitePool, done: Option<bool>) -> anyhow::Result<i64> {
let sql = match done {
None => "SELECT COUNT(*) FROM publish_queue",
Some(true) => "SELECT COUNT(*) FROM publish_queue WHERE published_at IS NOT NULL",
Some(false) => "SELECT COUNT(*) FROM publish_queue WHERE published_at IS NULL",
};
let (n,): (i64,) = sqlx::query_as(sql).fetch_one(pool).await.unwrap_or((0,));
Ok(n)
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
fn null_str(s: &str) -> Option<&str> {
+1
View File
@@ -4,6 +4,7 @@ pub mod enrich;
pub mod nostr;
pub mod publisher;
pub mod torznab;
pub mod ui;
pub mod wot;
use std::{
+2 -1
View File
@@ -1,7 +1,7 @@
use anyhow::Context;
use axum::{extract::State, routing::get, Json, Router};
use clap::Parser;
use kindexr::{config, db, enrich::tmdb::Enricher, nostr, publisher, torznab, wot, AppState};
use kindexr::{config, db, enrich::tmdb::Enricher, nostr, publisher, torznab, ui, wot, AppState};
use serde_json::{json, Value};
use std::{
sync::{atomic::AtomicI32, Arc},
@@ -85,6 +85,7 @@ async fn main() -> anyhow::Result<()> {
let app = Router::new()
.route("/health", get(health_handler))
.merge(torznab::server::router(state.clone()))
.merge(ui::router())
.with_state(state);
let listener = tokio::net::TcpListener::bind(&cfg.server.listen)
+105
View File
@@ -0,0 +1,105 @@
use axum::extract::State;
use axum::response::Html;
use crate::{db, AppState};
use super::{page, err_page, fmt_ts_ago, short_pubkey, esc};
pub async fn handler(State(state): State<AppState>) -> Html<String> {
match render(&state).await {
Ok(h) => h,
Err(e) => err_page(&e),
}
}
async fn render(state: &AppState) -> anyhow::Result<Html<String>> {
let stats = db::ui_stats(&state.pool).await?;
let relays = db::list_relays_ui(&state.pool).await.unwrap_or_default();
let recent = db::recent_torrents(&state.pool, 12).await.unwrap_or_default();
// Stats row
let last_seen = stats
.last_event_at
.map(|ts| fmt_ts_ago(ts))
.unwrap_or_else(|| "never".into());
let stats_html = format!(
r#"<div class="stats">
<div class="stat"><div class="val">{}</div><div class="lbl">Indexed</div></div>
<div class="stat"><div class="val">{}</div><div class="lbl">Publishers</div></div>
<div class="stat"><div class="val">{}</div><div class="lbl">Queued</div></div>
<div class="stat"><div class="val">{}</div><div class="lbl">Published</div></div>
<div class="stat"><div class="val" style="font-size:1rem;padding-top:.2rem">{last_seen}</div><div class="lbl">Last ingest</div></div>
</div>"#,
stats.torrent_count,
stats.publisher_count,
stats.queued_count,
stats.published_count,
);
// Relay status
let relay_rows: String = if relays.is_empty() {
"<div class='empty'>No relays configured</div>".into()
} else {
relays
.iter()
.map(|r| {
let (dot, ts_str) = match r.last_event {
Some(ts) => ("dot-ok", format!("last event {}", fmt_ts_ago(ts))),
None => ("dot-dim", "no events yet".into()),
};
format!(
r#"<div class="relay-row">
<span><span class="dot {dot}"></span><span class="relay-url">{url}</span></span>
<span class="relay-ts">{ts_str}</span>
</div>"#,
url = esc(&r.url),
)
})
.collect()
};
let relays_html = format!(
r#"<div class="section">
<div class="section-title">Relays ({} configured)</div>
<div class="relay-list">{relay_rows}</div>
</div>"#,
relays.len(),
);
// Recent ingest table
let recent_rows: String = if recent.is_empty() {
"<tr><td colspan='4' class='empty'>Nothing indexed yet</td></tr>".into()
} else {
recent
.iter()
.map(|r| {
format!(
r#"<tr>
<td>{title}</td>
<td><span class="badge badge-dim">{cat}</span></td>
<td class="mono">{pk}</td>
<td class="mono">{ts}</td>
</tr>"#,
title = esc(&r.title),
cat = esc(&r.category),
pk = short_pubkey(&r.pubkey),
ts = fmt_ts_ago(r.ingested_at),
)
})
.collect()
};
let recent_html = format!(
r#"<div class="section">
<div class="section-title">Recent ingest</div>
<div class="table-wrap">
<table>
<thead><tr><th>Title</th><th>Category</th><th>Publisher</th><th>When</th></tr></thead>
<tbody>{recent_rows}</tbody>
</table>
</div>
</div>"#
);
let body = format!("<h1>Dashboard</h1>{stats_html}{relays_html}{recent_html}");
Ok(page("dashboard", "Dashboard", &body))
}
+98
View File
@@ -0,0 +1,98 @@
use axum::extract::{Query, State};
use axum::response::Html;
use serde::Deserialize;
use crate::{db, AppState};
use super::{page, err_page, fmt_ts, fmt_size, short_pubkey, esc, pagination};
const PAGE_SIZE: i64 = 50;
#[derive(Deserialize, Default)]
pub struct Params {
pub q: Option<String>,
pub page: Option<i64>,
}
pub async fn handler(
State(state): State<AppState>,
Query(params): Query<Params>,
) -> Html<String> {
match render(&state, &params).await {
Ok(h) => h,
Err(e) => err_page(&e),
}
}
async fn render(state: &AppState, params: &Params) -> anyhow::Result<Html<String>> {
let q = params.q.as_deref().unwrap_or("").trim().to_owned();
let page_num = params.page.unwrap_or(1).max(1);
let offset = (page_num - 1) * PAGE_SIZE;
let total = db::count_browse(&state.pool, &q).await?;
let rows = db::browse_torrents(&state.pool, &q, PAGE_SIZE, offset).await?;
let total_pages = (total + PAGE_SIZE - 1) / PAGE_SIZE;
// Search bar
let q_esc = esc(&q);
let search = format!(
r#"<form class="search-row" method="GET" action="/ui/indexed">
<input type="text" name="q" value="{q_esc}" placeholder="Search title…">
<button type="submit">Search</button>
{clear}
</form>"#,
clear = if !q.is_empty() {
r#"<a href="/ui/indexed" class="btn">Clear</a>"#
} else {
""
},
);
let table_rows: String = if rows.is_empty() {
"<tr><td colspan='6' class='empty'>Nothing found</td></tr>".into()
} else {
rows.iter()
.map(|r| {
let size = r
.size_bytes
.map(fmt_size)
.unwrap_or_else(|| "".into());
format!(
r#"<tr>
<td>{title}</td>
<td><span class="badge badge-dim">{cat}</span></td>
<td class="mono">{size}</td>
<td class="mono" title="{pk_full}">{pk}</td>
<td class="mono" title="{hash}">{hash_short}</td>
<td class="mono">{ts}</td>
</tr>"#,
title = esc(&r.title),
cat = esc(&r.category),
pk_full = esc(&r.pubkey),
pk = short_pubkey(&r.pubkey),
hash = esc(&r.info_hash),
hash_short = &r.info_hash[..12],
ts = fmt_ts(r.ingested_at),
)
})
.collect()
};
let base_url = format!("/ui/indexed?q={q_esc}");
let pager = pagination(&base_url, page_num, total_pages);
let body = format!(
r#"<h1>Indexed <span style="font-weight:400;color:var(--muted);font-size:.9rem">({total} total)</span></h1>
{search}
<div class="table-wrap">
<table>
<thead><tr>
<th>Title</th><th>Category</th><th>Size</th>
<th>Publisher</th><th>Info Hash</th><th>Indexed</th>
</tr></thead>
<tbody>{table_rows}</tbody>
</table>
</div>
{pager}"#
);
Ok(page("indexed", "Indexed", &body))
}
+257
View File
@@ -0,0 +1,257 @@
use axum::{
routing::{get, post},
Router,
};
use crate::AppState;
mod dashboard;
mod indexed;
mod publishers;
mod published;
pub fn router() -> Router<AppState> {
Router::new()
.route("/ui", get(dashboard::handler))
.route("/ui/indexed", get(indexed::handler))
.route("/ui/publishers", get(publishers::list_handler))
.route("/ui/publishers/vouch", post(publishers::vouch_handler))
.route("/ui/publishers/:pubkey/block", post(publishers::block_handler))
.route("/ui/publishers/:pubkey/unblock", post(publishers::unblock_handler))
.route("/ui/publishers/:pubkey/mute", post(publishers::mute_handler))
.route("/ui/published", get(published::handler))
}
// ─── Shared helpers ───────────────────────────────────────────────────────────
pub fn page(active: &str, title: &str, body: &str) -> axum::response::Html<String> {
let nav = [
("dashboard", "/ui", "Dashboard"),
("indexed", "/ui/indexed", "Indexed"),
("publishers", "/ui/publishers", "Publishers"),
("published", "/ui/published", "Published"),
]
.iter()
.map(|(id, href, label)| {
if *id == active {
format!(r#"<a href="{href}" class="active">{label}</a>"#)
} else {
format!(r#"<a href="{href}">{label}</a>"#)
}
})
.collect::<Vec<_>>()
.join("\n ");
axum::response::Html(format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title} — kindexr</title>
<style>{css}</style>
</head>
<body>
<nav>
<span class="brand">kindexr</span>
{nav}
</nav>
<main>
{body}
</main>
</body>
</html>"#,
css = CSS,
))
}
pub fn err_page(e: &anyhow::Error) -> axum::response::Html<String> {
page("", "Error", &format!("<div class='err-box'><strong>Error:</strong> {e}</div>"))
}
pub fn esc(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
pub fn fmt_ts(ts: i64) -> String {
use std::time::{Duration, UNIX_EPOCH};
let d = UNIX_EPOCH + Duration::from_secs(ts.max(0) as u64);
// Simple formatting without chrono dependency in this module
// Use chrono which is already in Cargo.toml
let dt = chrono::DateTime::<chrono::Utc>::from(d);
dt.format("%Y-%m-%d %H:%M").to_string()
}
pub fn fmt_ts_ago(ts: i64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let diff = (now - ts).max(0);
if diff < 60 {
format!("{diff}s ago")
} else if diff < 3600 {
format!("{}m ago", diff / 60)
} else if diff < 86400 {
format!("{}h ago", diff / 3600)
} else {
format!("{}d ago", diff / 86400)
}
}
pub fn fmt_size(bytes: i64) -> String {
const GIB: i64 = 1 << 30;
const MIB: i64 = 1 << 20;
const KIB: i64 = 1 << 10;
if bytes >= GIB {
format!("{:.1} GiB", bytes as f64 / GIB as f64)
} else if bytes >= MIB {
format!("{:.0} MiB", bytes as f64 / MIB as f64)
} else if bytes >= KIB {
format!("{:.0} KiB", bytes as f64 / KIB as f64)
} else {
format!("{bytes} B")
}
}
pub fn short_pubkey(pk: &str) -> String {
if pk.len() >= 12 {
format!("{}{}", &pk[..6], &pk[pk.len() - 6..])
} else {
pk.to_owned()
}
}
pub fn pagination(base: &str, page: i64, total_pages: i64) -> String {
if total_pages <= 1 {
return String::new();
}
let mut parts = vec![r#"<div class="pager">"#.to_string()];
if page > 1 {
parts.push(format!(r#"<a href="{base}&page={}">&larr;</a>"#, page - 1));
}
let start = (page - 2).max(1);
let end = (page + 2).min(total_pages);
for p in start..=end {
if p == page {
parts.push(format!(r#"<a class="cur" href="{base}&page={p}">{p}</a>"#));
} else {
parts.push(format!(r#"<a href="{base}&page={p}">{p}</a>"#));
}
}
if page < total_pages {
parts.push(format!(r#"<a href="{base}&page={}">&rarr;</a>"#, page + 1));
}
parts.push(format!(
r#"<span>{page} / {total_pages}</span>"#
));
parts.push("</div>".to_string());
parts.join("\n")
}
/// Try to normalise a pubkey string (npub bech32 or 64-char hex) to lowercase hex.
pub fn parse_pubkey(s: &str) -> Option<String> {
let s = s.trim();
if s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit()) {
return Some(s.to_lowercase());
}
// try as npub bech32
use nostr::FromBech32;
nostr::PublicKey::from_bech32(s)
.ok()
.map(|pk| pk.to_hex())
}
const CSS: &str = r#"
:root {
--bg: #0f1117; --bg2: #161b27; --bg3: #1e2438;
--border: #2a3050; --text: #dde1f0; --muted: #6b7599;
--accent: #818cf8; --success: #34d399; --danger: #f87171; --warn: #fbbf24;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font: 14px/1.6 system-ui, sans-serif; min-height: 100vh; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
nav { display: flex; align-items: center; gap: 1.5rem; padding: .6rem 1.5rem;
background: var(--bg2); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
.brand { font-weight: 700; color: var(--accent); margin-right: auto; letter-spacing: -.02em; }
nav a { color: var(--muted); font-size: 13px; }
nav a:hover, nav a.active { color: var(--text); text-decoration: none; }
nav a.active { font-weight: 600; }
main { padding: 1.5rem; max-width: 1200px; margin: 0 auto; }
h1 { font-size: 1.1rem; font-weight: 600; margin-bottom: 1.25rem; }
.stats { display: flex; gap: .75rem; flex-wrap: wrap; margin-bottom: 1.75rem; }
.stat { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
padding: .75rem 1.25rem; min-width: 140px; }
.stat .val { font-size: 1.75rem; font-weight: 700; color: var(--accent); line-height: 1; margin-bottom: .15rem; }
.stat .lbl { font-size: .68rem; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); }
.section { margin-bottom: 2rem; }
.section-title { font-size: .7rem; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); margin-bottom: .75rem; font-weight: 600; }
.table-wrap { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
table { width: 100%; border-collapse: collapse; }
th { background: var(--bg2); padding: .45rem .75rem; text-align: left; font-size: .68rem;
text-transform: uppercase; letter-spacing: .07em; color: var(--muted);
border-bottom: 1px solid var(--border); font-weight: 600; }
td { padding: .45rem .75rem; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--bg3); }
.mono { font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; font-size: 11px; color: var(--muted); }
.badge { display: inline-flex; align-items: center; padding: .12rem .45rem; border-radius: 4px;
font-size: 11px; font-weight: 600; }
.badge-ok { background: #052e1c; color: var(--success); }
.badge-err { background: #2d0c0c; color: var(--danger); }
.badge-warn { background: #2a1a02; color: var(--warn); }
.badge-dim { background: var(--bg3); color: var(--muted); }
.badge-accent { background: #1e2060; color: var(--accent); }
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; }
.dot-ok { background: var(--success); }
.dot-dim { background: var(--muted); }
.dot-err { background: var(--danger); }
button, .btn { padding: .28rem .6rem; border-radius: 5px; border: 1px solid var(--border);
cursor: pointer; font-size: 12px; font-weight: 600; background: var(--bg3); color: var(--text); }
.btn-danger { background: #2d0c0c; color: var(--danger); border-color: #5a1a1a; }
.btn-ok { background: #052e1c; color: var(--success); border-color: #0a4a2b; }
.btn-warn { background: #2a1a02; color: var(--warn); border-color: #5a3600; }
.btn-accent { background: var(--accent); color: #0f1117; border-color: var(--accent); }
.card { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
padding: 1rem 1.25rem; margin-bottom: 1.5rem; }
.card-title { font-size: .7rem; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); margin-bottom: .75rem; font-weight: 600; }
.form-row { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
input[type=text], input[type=number], select {
padding: .38rem .7rem; background: var(--bg); border: 1px solid var(--border);
border-radius: 5px; color: var(--text); font-size: 13px; outline: none; }
input[type=text]:focus, select:focus { border-color: var(--accent); }
input.mono-in { font-family: monospace; }
input.wide { flex: 1; min-width: 260px; }
.search-row { display: flex; gap: .6rem; margin-bottom: 1rem; }
.search-row input { flex: 1; max-width: 360px; }
.search-row button { padding: .38rem .9rem; background: var(--accent); color: #0f1117;
border: none; border-radius: 5px; font-weight: 600; cursor: pointer; }
.pager { display: flex; gap: .4rem; margin-top: 1rem; align-items: center; }
.pager a { padding: .22rem .55rem; border-radius: 4px; background: var(--bg2);
border: 1px solid var(--border); color: var(--muted); font-size: 12px; }
.pager a:hover, .pager a.cur { background: var(--accent); color: #0f1117;
border-color: var(--accent); text-decoration: none; }
.pager span { color: var(--muted); font-size: 12px; }
.empty { color: var(--muted); font-style: italic; padding: 1.5rem .75rem; text-align: center; }
.err-box { background: #2d0c0c; border: 1px solid #5a1a1a; color: var(--danger);
border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
.msg-ok { background: #052e1c; border: 1px solid #0a4a2b; color: var(--success);
border-radius: 8px; padding: .6rem 1rem; margin-bottom: 1rem; font-size: 13px; }
.msg-err { background: #2d0c0c; border: 1px solid #5a1a1a; color: var(--danger);
border-radius: 8px; padding: .6rem 1rem; margin-bottom: 1rem; font-size: 13px; }
form.ifrm { display: inline; }
.tabs { display: flex; gap: 0; margin-bottom: 1rem; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; width: fit-content; }
.tabs a { padding: .35rem .9rem; font-size: 12px; font-weight: 600; color: var(--muted); background: var(--bg2); }
.tabs a:hover { color: var(--text); text-decoration: none; }
.tabs a.cur { background: var(--accent); color: #0f1117; }
.relay-list { display: flex; flex-direction: column; gap: .4rem; }
.relay-row { display: flex; align-items: center; justify-content: space-between;
background: var(--bg3); border-radius: 5px; padding: .4rem .75rem; font-size: 13px; }
.relay-url { font-family: monospace; font-size: 12px; color: var(--text); }
.relay-ts { font-size: 11px; color: var(--muted); }
"#;
+113
View File
@@ -0,0 +1,113 @@
use axum::extract::{Query, State};
use axum::response::Html;
use serde::Deserialize;
use crate::{db, AppState};
use super::{page, err_page, fmt_ts, fmt_ts_ago, esc, pagination};
const PAGE_SIZE: i64 = 50;
#[derive(Deserialize, Default)]
pub struct Params {
pub tab: Option<String>,
pub page: Option<i64>,
}
pub async fn handler(
State(state): State<AppState>,
Query(params): Query<Params>,
) -> Html<String> {
match render(&state, &params).await {
Ok(h) => h,
Err(e) => err_page(&e),
}
}
async fn render(state: &AppState, params: &Params) -> anyhow::Result<Html<String>> {
let tab = params.tab.as_deref().unwrap_or("done");
let page_num = params.page.unwrap_or(1).max(1);
let offset = (page_num - 1) * PAGE_SIZE;
let done_filter = match tab {
"pending" => Some(false),
_ => Some(true),
};
let rows = db::list_queue_items(&state.pool, done_filter, PAGE_SIZE, offset).await?;
let total = db::count_queue_items(&state.pool, done_filter).await?;
let total_pages = (total + PAGE_SIZE - 1) / PAGE_SIZE;
let tabs = format!(
r#"<div class="tabs">
<a href="/ui/published?tab=done" class="{c_done}">Published</a>
<a href="/ui/published?tab=pending" class="{c_pend}">Pending queue</a>
</div>"#,
c_done = if tab == "done" { "cur" } else { "" },
c_pend = if tab == "pending" { "cur" } else { "" },
);
let table_rows: String = if rows.is_empty() {
"<tr><td colspan='6' class='empty'>Nothing here yet</td></tr>".into()
} else {
rows.iter().map(|r| {
let status: &str = if r.error.is_some() {
r#"<span class="badge badge-err">error</span>"#
} else if r.published_at.is_some() {
r#"<span class="badge badge-ok">published</span>"#
} else {
r#"<span class="badge badge-warn">pending</span>"#
};
let event_cell = match &r.event_id {
Some(id) => format!(r#"<span class="mono" title="{full}">{short}…</span>"#,
full = esc(id), short = &id[..12]),
None => match &r.error {
Some(e) => format!(r#"<span class="mono" style="color:var(--danger)" title="{}">[error]</span>"#, esc(e)),
None => {
let sched = fmt_ts_ago(r.scheduled_at);
format!(r#"<span class="mono" style="color:var(--muted)">in {sched}</span>"#)
}
},
};
let pub_ts = r.published_at
.map(|ts| fmt_ts(ts))
.unwrap_or_else(|| fmt_ts(r.scheduled_at));
format!(
r#"<tr>
<td>{title}</td>
<td><span class="badge badge-dim">{cat}</span></td>
<td class="mono" title="{hash}">{hash_short}</td>
<td>{status}</td>
<td>{event_cell}</td>
<td class="mono">{ts}</td>
</tr>"#,
title = esc(&r.title),
cat = esc(&r.category),
hash = esc(&r.info_hash),
hash_short = &r.info_hash[..12],
ts = pub_ts,
)
}).collect()
};
let base_url = format!("/ui/published?tab={tab}");
let pager = pagination(&base_url, page_num, total_pages);
let body = format!(
r#"<h1>Published <span style="font-weight:400;color:var(--muted);font-size:.9rem">({total} items)</span></h1>
{tabs}
<div class="table-wrap">
<table>
<thead><tr>
<th>Title</th><th>Category</th><th>Info Hash</th>
<th>Status</th><th>Event ID</th><th>Time</th>
</tr></thead>
<tbody>{table_rows}</tbody>
</table>
</div>
{pager}"#
);
Ok(page("published", "Published", &body))
}
+216
View File
@@ -0,0 +1,216 @@
use axum::{
extract::{Path, Query, State},
response::{Html, Redirect},
Form,
};
use serde::Deserialize;
use crate::{db, AppState};
use super::{page, err_page, esc, fmt_ts_ago, short_pubkey, parse_pubkey, pagination};
const PAGE_SIZE: i64 = 50;
#[derive(Deserialize, Default)]
pub struct ListParams {
pub page: Option<i64>,
pub msg: Option<String>,
pub err: Option<String>,
}
pub async fn list_handler(
State(state): State<AppState>,
Query(params): Query<ListParams>,
) -> Html<String> {
match render(&state, &params).await {
Ok(h) => h,
Err(e) => err_page(&e),
}
}
async fn render(state: &AppState, params: &ListParams) -> anyhow::Result<Html<String>> {
let page_num = params.page.unwrap_or(1).max(1);
let offset = (page_num - 1) * PAGE_SIZE;
let rows = db::list_publishers(&state.pool, PAGE_SIZE, offset).await?;
// Rough total — just count what we have for pagination hint
let total = db::ui_stats(&state.pool).await?.publisher_count;
let total_pages = (total + PAGE_SIZE - 1) / PAGE_SIZE;
// Flash message
let flash = if let Some(msg) = &params.msg {
format!(r#"<div class="msg-ok">{}</div>"#, esc(msg))
} else if let Some(err) = &params.err {
format!(r#"<div class="msg-err">{}</div>"#, esc(err))
} else {
String::new()
};
// Vouch form
let vouch_form = r#"<div class="card">
<div class="card-title">Vouch for a publisher</div>
<form method="POST" action="/ui/publishers/vouch">
<div class="form-row">
<input class="wide mono-in" type="text" name="pubkey"
placeholder="npub1… or 64-char hex pubkey" required>
<select name="action">
<option value="trusted">Trusted (score 1.0)</option>
<option value="neutral">Neutral (score 0.5)</option>
<option value="untrusted">Untrusted (score 0.0)</option>
<option value="block">Block</option>
<option value="mute">Mute</option>
</select>
<button type="submit" class="btn-accent">Apply</button>
</div>
<div style="margin-top:.5rem;font-size:11px;color:var(--muted)">
Vouching sets the trust score for this publisher independently of the WoT graph.
Blocked publishers' events are silently dropped at ingest.
</div>
</form>
</div>"#;
// Publishers table
let table_rows: String = if rows.is_empty() {
"<tr><td colspan='7' class='empty'>No publishers seen yet</td></tr>".into()
} else {
rows.iter().map(|r| {
let trust_badge = if r.blocked {
r#"<span class="badge badge-err">blocked</span>"#
} else if r.muted {
r#"<span class="badge badge-warn">muted</span>"#
} else if r.trust >= 0.8 {
r#"<span class="badge badge-ok">trusted</span>"#
} else if r.trust <= 0.0 {
r#"<span class="badge badge-dim">untrusted</span>"#
} else {
r#"<span class="badge badge-accent">neutral</span>"#
};
let wot = r.wot_level
.map(|l| format!("{l}"))
.unwrap_or_else(|| "".into());
let last = r.last_seen
.map(fmt_ts_ago)
.unwrap_or_else(|| "".into());
let pk = &r.pubkey;
// Action buttons — wrapped in inline forms
let block_btn = if r.blocked {
format!(
r#"<form class="ifrm" method="POST" action="/ui/publishers/{pk}/unblock">
<button class="btn-ok" type="submit">Unblock</button></form>"#
)
} else {
format!(
r#"<form class="ifrm" method="POST" action="/ui/publishers/{pk}/block">
<button class="btn-danger" type="submit">Block</button></form>"#
)
};
let mute_btn = if !r.muted {
format!(
r#"<form class="ifrm" method="POST" action="/ui/publishers/{pk}/mute">
<button class="btn-warn" type="submit">Mute</button></form>"#
)
} else {
String::new()
};
format!(
r#"<tr>
<td class="mono" title="{pk}">{short}</td>
<td>{trust_badge} <span style="font-size:11px;color:var(--muted)">{score:.2}</span></td>
<td class="mono">{wot}</td>
<td class="mono">{events}</td>
<td class="mono">{reports}</td>
<td class="mono">{last}</td>
<td style="white-space:nowrap">{block_btn} {mute_btn}</td>
</tr>"#,
pk = esc(pk),
short = short_pubkey(pk),
score = r.trust,
events = r.torrents_n,
reports = r.report_count,
)
}).collect()
};
let pager = pagination("/ui/publishers?", page_num, total_pages);
let body = format!(
r#"<h1>Publishers</h1>
{flash}
{vouch_form}
<div class="section">
<div class="section-title">{total} known publishers</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>Pubkey</th><th>Trust</th><th>WoT level</th>
<th>Events</th><th>Reports</th><th>Last seen</th><th>Actions</th>
</tr></thead>
<tbody>{table_rows}</tbody>
</table>
</div>
{pager}
</div>"#
);
Ok(page("publishers", "Publishers", &body))
}
// ─── POST handlers ────────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct VouchForm {
pub pubkey: String,
pub action: String,
}
pub async fn vouch_handler(
State(state): State<AppState>,
Form(form): Form<VouchForm>,
) -> Redirect {
let Some(hex) = parse_pubkey(&form.pubkey) else {
return Redirect::to("/ui/publishers?err=Invalid+pubkey+%28use+npub+or+64-char+hex%29");
};
let result = match form.action.as_str() {
"trusted" => db::set_publisher_trust(&state.pool, &hex, 1.0).await,
"neutral" => db::set_publisher_trust(&state.pool, &hex, 0.5).await,
"untrusted" => db::set_publisher_trust(&state.pool, &hex, 0.0).await,
"block" => db::block_publisher(&state.pool, &hex).await,
"mute" => db::mute_publisher(&state.pool, &hex).await,
_ => return Redirect::to("/ui/publishers?err=Unknown+action"),
};
match result {
Ok(_) => Redirect::to(&format!("/ui/publishers?msg=Applied+to+{}", &hex[..12])),
Err(_) => Redirect::to("/ui/publishers?err=DB+error+applying+action"),
}
}
pub async fn block_handler(
State(state): State<AppState>,
Path(pubkey): Path<String>,
) -> Redirect {
let _ = db::block_publisher(&state.pool, &pubkey).await;
Redirect::to("/ui/publishers?msg=Blocked")
}
pub async fn unblock_handler(
State(state): State<AppState>,
Path(pubkey): Path<String>,
) -> Redirect {
let _ = db::unblock_publisher(&state.pool, &pubkey).await;
Redirect::to("/ui/publishers?msg=Unblocked")
}
pub async fn mute_handler(
State(state): State<AppState>,
Path(pubkey): Path<String>,
) -> Redirect {
let _ = db::mute_publisher(&state.pool, &pubkey).await;
Redirect::to("/ui/publishers?msg=Muted")
}