Files
kindexr/src/ui/dashboard.rs
T
enki bac30ecfdf add SOCKS5/Tor/I2P proxy support and improve network UI clarity
- NetworkConfig/ProxyConfig in config.rs: mode=direct|tor|i2p|socks5
  with socks5_addr() helper that maps tor→9050, i2p→4446, socks5→url
- build_http_client() routes reqwest through SOCKS5 when configured
- build_nostr_opts() routes nostr-sdk WebSocket connections through
  SOCKS5 using Connection::new().proxy(addr)
- Shared http_client passed to Enricher and BlossomClient so proxy
  applies to TMDB and Blossom blob fetches as well
- nostr_opts passed to Reader, WotBuilder, ProfileFetcher
- Dashboard: network status card showing proxy mode, listen address,
  IPv4/IPv6; relay section now shows X connected / Y configured
- Publishers: rename vouch form to "Override publisher trust", add
  clear note that this is local-only (not broadcast to Nostr), explain
  WoT comes from NIP-02 kind 3; rename column headers for clarity
- Example and dev configs updated with network.proxy section
2026-05-18 21:46:09 -07:00

179 lines
6.4 KiB
Rust

use axum::extract::State;
use axum::response::Html;
use std::sync::atomic::Ordering;
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,
);
// Network status
let network_html = build_network_html(state);
// Relay status
let relay_count = state.relay_count.load(Ordering::Relaxed);
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))),
// "no events yet" is normal on a fresh start — the relay is connected
// but kindexr hasn't received any NIP-35 events from it this session.
None => ("dot-dim", "connected — no events received 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 — {relay_count} connected / {} 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| {
let pub_display = r.publisher_name.as_deref()
.or(r.publisher_nip05.as_deref())
.map(esc)
.unwrap_or_else(|| short_pubkey(&r.pubkey));
format!(
r#"<tr>
<td>{title}</td>
<td><span class="badge badge-dim">{cat}</span></td>
<td title="{pk_full}">{pub_display}</td>
<td class="mono">{ts}</td>
</tr>"#,
title = esc(&r.title),
cat = esc(&r.category),
pk_full = esc(&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}{network_html}{relays_html}{recent_html}");
Ok(page("dashboard", "Dashboard", &body))
}
fn build_network_html(state: &AppState) -> String {
let proxy = &state.cfg.network.proxy;
let listen = &state.cfg.server.listen;
// Proxy mode badge
let (proxy_badge_class, proxy_label, proxy_detail) = match proxy.mode.as_str() {
"tor" => (
"badge-warn",
"Tor",
"SOCKS5 → 127.0.0.1:9050 — all outbound traffic routed through Tor".to_string(),
),
"i2p" => (
"badge-accent",
"I2P",
"SOCKS5 → 127.0.0.1:4446 — all outbound traffic routed through I2P".to_string(),
),
"socks5" => {
let addr = proxy.url.trim_start_matches("socks5://").trim_start_matches("socks5h://");
(
"badge-accent",
"SOCKS5",
format!("SOCKS5 → {addr} — all outbound traffic proxied"),
)
}
_ => (
"badge-dim",
"Direct",
format!("No proxy — outbound connections use your host's network stack directly"),
),
};
// IP version from listen address
let ip_ver = if listen.starts_with('[') || listen.contains("::") {
r#"<span class="badge badge-accent">IPv6</span>"#
} else if listen.starts_with("0.0.0.0") || listen.starts_with("127.") || listen.chars().next().map_or(false, |c| c.is_ascii_digit()) {
r#"<span class="badge badge-dim">IPv4</span>"#
} else {
""
};
format!(
r#"<div class="section">
<div class="section-title">Network</div>
<div class="card" style="margin-bottom:.75rem">
<div style="display:flex;gap:1rem;align-items:flex-start;flex-wrap:wrap">
<div style="min-width:160px">
<div style="font-size:.65rem;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:.3rem">Proxy / anonymity</div>
<span class="badge {proxy_badge_class}" style="font-size:12px;padding:.2rem .6rem">{proxy_label}</span>
<div style="font-size:11px;color:var(--muted);margin-top:.4rem;max-width:440px">{proxy_detail}</div>
</div>
<div style="min-width:160px">
<div style="font-size:.65rem;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:.3rem">Listening on</div>
<span class="mono" style="font-size:12px">{listen}</span>
<span style="margin-left:.5rem">{ip_ver}</span>
</div>
</div>
</div>
</div>"#,
listen = esc(listen),
)
}