From e6fb4f48c66f3431b75ec717f4be0a38b6e92d1c Mon Sep 17 00:00:00 2001 From: enki Date: Sun, 17 May 2026 15:05:50 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20web=20UI=20=E2=80=94=20dashboard,=20ind?= =?UTF-8?q?exed=20browser,=20publisher=20manager,=20publish=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/db/queries.rs | 198 +++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 3 +- src/ui/dashboard.rs | 105 ++++++++++++++++++ src/ui/indexed.rs | 98 +++++++++++++++++ src/ui/mod.rs | 257 +++++++++++++++++++++++++++++++++++++++++++ src/ui/published.rs | 113 +++++++++++++++++++ src/ui/publishers.rs | 216 ++++++++++++++++++++++++++++++++++++ 8 files changed, 990 insertions(+), 1 deletion(-) create mode 100644 src/ui/dashboard.rs create mode 100644 src/ui/indexed.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/published.rs create mode 100644 src/ui/publishers.rs diff --git a/src/db/queries.rs b/src/db/queries.rs index 44f8fa5..010608a 100644 --- a/src/db/queries.rs +++ b/src/db/queries.rs @@ -717,6 +717,204 @@ pub async fn get_identity(pool: &SqlitePool) -> anyhow::Result, +} + +pub async fn ui_stats(pool: &SqlitePool) -> anyhow::Result { + let (torrent_count, last_event_at): (i64, Option) = + 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, +} + +pub async fn list_relays_ui(pool: &SqlitePool) -> anyhow::Result> { + let rows: Vec<(String, Option)> = + 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> { + 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, + pub pubkey: String, + pub ingested_at: i64, +} + +impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for BrowseTorrentRow { + fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result { + 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> { + 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 { + 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, + pub event_id: Option, + pub error: Option, +} + +impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for QueueRow { + fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result { + 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, + limit: i64, + offset: i64, +) -> anyhow::Result> { + 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) -> anyhow::Result { + 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> { diff --git a/src/lib.rs b/src/lib.rs index 81a3815..7b5c095 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod enrich; pub mod nostr; pub mod publisher; pub mod torznab; +pub mod ui; pub mod wot; use std::{ diff --git a/src/main.rs b/src/main.rs index af5f435..d0fb96b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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) diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs new file mode 100644 index 0000000..def8fd7 --- /dev/null +++ b/src/ui/dashboard.rs @@ -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) -> Html { + match render(&state).await { + Ok(h) => h, + Err(e) => err_page(&e), + } +} + +async fn render(state: &AppState) -> anyhow::Result> { + 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#"
+
{}
Indexed
+
{}
Publishers
+
{}
Queued
+
{}
Published
+
{last_seen}
Last ingest
+
"#, + stats.torrent_count, + stats.publisher_count, + stats.queued_count, + stats.published_count, + ); + + // Relay status + let relay_rows: String = if relays.is_empty() { + "
No relays configured
".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#"
+ {url} + {ts_str} +
"#, + url = esc(&r.url), + ) + }) + .collect() + }; + + let relays_html = format!( + r#"
+
Relays ({} configured)
+
{relay_rows}
+
"#, + relays.len(), + ); + + // Recent ingest table + let recent_rows: String = if recent.is_empty() { + "Nothing indexed yet".into() + } else { + recent + .iter() + .map(|r| { + format!( + r#" + {title} + {cat} + {pk} + {ts} +"#, + 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#"
+
Recent ingest
+
+ + + {recent_rows} +
TitleCategoryPublisherWhen
+
+
"# + ); + + let body = format!("

Dashboard

{stats_html}{relays_html}{recent_html}"); + Ok(page("dashboard", "Dashboard", &body)) +} diff --git a/src/ui/indexed.rs b/src/ui/indexed.rs new file mode 100644 index 0000000..84261e8 --- /dev/null +++ b/src/ui/indexed.rs @@ -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, + pub page: Option, +} + +pub async fn handler( + State(state): State, + Query(params): Query, +) -> Html { + match render(&state, ¶ms).await { + Ok(h) => h, + Err(e) => err_page(&e), + } +} + +async fn render(state: &AppState, params: &Params) -> anyhow::Result> { + 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#"
+ + + {clear} +
"#, + clear = if !q.is_empty() { + r#"Clear"# + } else { + "" + }, + ); + + let table_rows: String = if rows.is_empty() { + "Nothing found".into() + } else { + rows.iter() + .map(|r| { + let size = r + .size_bytes + .map(fmt_size) + .unwrap_or_else(|| "—".into()); + format!( + r#" + {title} + {cat} + {size} + {pk} + {hash_short} + {ts} +"#, + 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#"

Indexed ({total} total)

+{search} +
+ + + + + + {table_rows} +
TitleCategorySizePublisherInfo HashIndexed
+
+{pager}"# + ); + + Ok(page("indexed", "Indexed", &body)) +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..fa06c10 --- /dev/null +++ b/src/ui/mod.rs @@ -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 { + 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 { + 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#"{label}"#) + } else { + format!(r#"{label}"#) + } + }) + .collect::>() + .join("\n "); + + axum::response::Html(format!( + r#" + + + + +{title} — kindexr + + + + +
+{body} +
+ +"#, + css = CSS, + )) +} + +pub fn err_page(e: &anyhow::Error) -> axum::response::Html { + page("", "Error", &format!("
Error: {e}
")) +} + +pub fn esc(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +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::::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#"
"#.to_string()]; + if page > 1 { + parts.push(format!(r#""#, 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#"{p}"#)); + } else { + parts.push(format!(r#"{p}"#)); + } + } + if page < total_pages { + parts.push(format!(r#""#, page + 1)); + } + parts.push(format!( + r#"{page} / {total_pages}"# + )); + parts.push("
".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 { + 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); } +"#; diff --git a/src/ui/published.rs b/src/ui/published.rs new file mode 100644 index 0000000..88d78a6 --- /dev/null +++ b/src/ui/published.rs @@ -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, + pub page: Option, +} + +pub async fn handler( + State(state): State, + Query(params): Query, +) -> Html { + match render(&state, ¶ms).await { + Ok(h) => h, + Err(e) => err_page(&e), + } +} + +async fn render(state: &AppState, params: &Params) -> anyhow::Result> { + 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#""#, + c_done = if tab == "done" { "cur" } else { "" }, + c_pend = if tab == "pending" { "cur" } else { "" }, + ); + + let table_rows: String = if rows.is_empty() { + "Nothing here yet".into() + } else { + rows.iter().map(|r| { + let status: &str = if r.error.is_some() { + r#"error"# + } else if r.published_at.is_some() { + r#"published"# + } else { + r#"pending"# + }; + + let event_cell = match &r.event_id { + Some(id) => format!(r#"{short}…"#, + full = esc(id), short = &id[..12]), + None => match &r.error { + Some(e) => format!(r#"[error]"#, esc(e)), + None => { + let sched = fmt_ts_ago(r.scheduled_at); + format!(r#"in {sched}"#) + } + }, + }; + + let pub_ts = r.published_at + .map(|ts| fmt_ts(ts)) + .unwrap_or_else(|| fmt_ts(r.scheduled_at)); + + format!( + r#" + {title} + {cat} + {hash_short} + {status} + {event_cell} + {ts} +"#, + 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#"

Published ({total} items)

+{tabs} +
+ + + + + + {table_rows} +
TitleCategoryInfo HashStatusEvent IDTime
+
+{pager}"# + ); + + Ok(page("published", "Published", &body)) +} diff --git a/src/ui/publishers.rs b/src/ui/publishers.rs new file mode 100644 index 0000000..d665d0f --- /dev/null +++ b/src/ui/publishers.rs @@ -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, + pub msg: Option, + pub err: Option, +} + +pub async fn list_handler( + State(state): State, + Query(params): Query, +) -> Html { + match render(&state, ¶ms).await { + Ok(h) => h, + Err(e) => err_page(&e), + } +} + +async fn render(state: &AppState, params: &ListParams) -> anyhow::Result> { + 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) = ¶ms.msg { + format!(r#"
{}
"#, esc(msg)) + } else if let Some(err) = ¶ms.err { + format!(r#"
{}
"#, esc(err)) + } else { + String::new() + }; + + // Vouch form + let vouch_form = r#"
+
Vouch for a publisher
+
+
+ + + +
+
+ Vouching sets the trust score for this publisher independently of the WoT graph. + Blocked publishers' events are silently dropped at ingest. +
+
+
"#; + + // Publishers table + let table_rows: String = if rows.is_empty() { + "No publishers seen yet".into() + } else { + rows.iter().map(|r| { + let trust_badge = if r.blocked { + r#"blocked"# + } else if r.muted { + r#"muted"# + } else if r.trust >= 0.8 { + r#"trusted"# + } else if r.trust <= 0.0 { + r#"untrusted"# + } else { + r#"neutral"# + }; + + 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#"
+
"# + ) + } else { + format!( + r#"
+
"# + ) + }; + + let mute_btn = if !r.muted { + format!( + r#"
+
"# + ) + } else { + String::new() + }; + + format!( + r#" + {short} + {trust_badge} {score:.2} + {wot} + {events} + {reports} + {last} + {block_btn} {mute_btn} +"#, + 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#"

Publishers

+{flash} +{vouch_form} +
+
{total} known publishers
+
+ + + + + + {table_rows} +
PubkeyTrustWoT levelEventsReportsLast seenActions
+
+ {pager} +
"# + ); + + 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, + Form(form): Form, +) -> 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, + Path(pubkey): Path, +) -> Redirect { + let _ = db::block_publisher(&state.pool, &pubkey).await; + Redirect::to("/ui/publishers?msg=Blocked") +} + +pub async fn unblock_handler( + State(state): State, + Path(pubkey): Path, +) -> Redirect { + let _ = db::unblock_publisher(&state.pool, &pubkey).await; + Redirect::to("/ui/publishers?msg=Unblocked") +} + +pub async fn mute_handler( + State(state): State, + Path(pubkey): Path, +) -> Redirect { + let _ = db::mute_publisher(&state.pool, &pubkey).await; + Redirect::to("/ui/publishers?msg=Muted") +}