bac30ecfdf
- 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
179 lines
6.4 KiB
Rust
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),
|
|
)
|
|
}
|