diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 2ac3c81..2b7ea38 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -5,6 +5,7 @@ mod lexicons; mod network_lexicons; mod records; mod stats; +mod tap_stats; mod types; use axum::Router; @@ -31,6 +32,7 @@ pub fn admin_routes(_state: AppState) -> Router { ) .route("/admins/{id}", delete(admins::delete_admin)) .route("/records", get(records::list_records)) + .route("/tap/stats", get(tap_stats::tap_stats)) .route( "/network-lexicons", post(network_lexicons::add).get(network_lexicons::list), diff --git a/src/admin/tap_stats.rs b/src/admin/tap_stats.rs new file mode 100644 index 0000000..7afcb20 --- /dev/null +++ b/src/admin/tap_stats.rs @@ -0,0 +1,24 @@ +use axum::Json; +use axum::extract::State; + +use crate::AppState; +use crate::error::AppError; +use crate::tap; + +use super::auth::AdminAuth; + +/// GET /admin/tap/stats — aggregate stats from Tap. +pub(super) async fn tap_stats( + State(state): State, + _admin: AdminAuth, +) -> Result, AppError> { + let stats = tap::get_stats( + &state.http, + &state.config.tap_url, + state.config.tap_admin_password.as_deref(), + ) + .await + .map_err(AppError::BadGateway)?; + + Ok(Json(stats)) +} diff --git a/src/error.rs b/src/error.rs index 3e2401b..13f6724 100644 --- a/src/error.rs +++ b/src/error.rs @@ -7,6 +7,7 @@ pub enum AppError { Auth(String), /// Auth failure with a DPoP nonce that the client should retry with. AuthDpopNonce(String), + BadGateway(String), BadRequest(String), Forbidden(String), Internal(String), @@ -19,6 +20,7 @@ impl std::fmt::Display for AppError { match self { AppError::Auth(msg) => write!(f, "auth error: {msg}"), AppError::AuthDpopNonce(nonce) => write!(f, "auth error: use_dpop_nonce ({nonce})"), + AppError::BadGateway(msg) => write!(f, "bad gateway: {msg}"), AppError::BadRequest(msg) => write!(f, "bad request: {msg}"), AppError::Forbidden(msg) => write!(f, "forbidden: {msg}"), AppError::Internal(msg) => write!(f, "internal error: {msg}"), @@ -48,7 +50,9 @@ impl IntoResponse for AppError { other => { let (status, message) = match &other { AppError::Auth(msg) => (StatusCode::UNAUTHORIZED, msg.clone()), + AppError::BadGateway(msg) => (StatusCode::BAD_GATEWAY, msg.clone()), AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), + AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()), AppError::Internal(msg) => { tracing::error!("{msg}"); diff --git a/src/tap.rs b/src/tap.rs index a321d10..e2229bc 100644 --- a/src/tap.rs +++ b/src/tap.rs @@ -1,5 +1,5 @@ use futures_util::{SinkExt, StreamExt}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use sqlx::PgPool; use tokio::sync::watch; @@ -95,6 +95,76 @@ async fn tap_post( Ok(()) } +async fn tap_get( + http: &reqwest::Client, + tap_url: &str, + path: &str, + password: Option<&str>, +) -> Result { + let url = format!("{}{}", tap_url.trim_end_matches('/'), path); + let mut req = http.get(&url); + if let Some(pw) = password { + req = req.basic_auth("admin", Some(pw)); + } + let resp = req + .send() + .await + .map_err(|e| format!("tap HTTP request failed: {e}"))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("tap returned {status}: {body}")); + } + resp.json::() + .await + .map_err(|e| format!("failed to parse tap response: {e}")) +} + +// --------------------------------------------------------------------------- +// Tap stats +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +pub struct TapStats { + pub repo_count: u64, + pub record_count: u64, + pub outbox_buffer: u64, +} + +#[derive(Deserialize)] +struct RepoCountResponse { + repo_count: u64, +} + +#[derive(Deserialize)] +struct RecordCountResponse { + record_count: u64, +} + +#[derive(Deserialize)] +struct OutboxBufferResponse { + outbox_buffer: u64, +} + +/// Fetch aggregate stats from Tap's monitoring endpoints in parallel. +pub async fn get_stats( + http: &reqwest::Client, + tap_url: &str, + tap_admin_password: Option<&str>, +) -> Result { + let (repo, record, outbox) = tokio::try_join!( + tap_get::(http, tap_url, "/stats/repo-count", tap_admin_password), + tap_get::(http, tap_url, "/stats/record-count", tap_admin_password), + tap_get::(http, tap_url, "/stats/outbox-buffer", tap_admin_password), + )?; + + Ok(TapStats { + repo_count: repo.repo_count, + record_count: record.record_count, + outbox_buffer: outbox.outbox_buffer, + }) +} + /// Sync Tap's collection filters and signal collections with HappyView's /// current record collections. pub async fn sync_collections( diff --git a/web/src/app/(dashboard)/backfill/page.tsx b/web/src/app/(dashboard)/backfill/page.tsx index 6c10c8f..956920c 100644 --- a/web/src/app/(dashboard)/backfill/page.tsx +++ b/web/src/app/(dashboard)/backfill/page.tsx @@ -1,16 +1,23 @@ -"use client" +"use client"; -import { useCallback, useEffect, useState } from "react" +import { useCallback, useEffect, useState } from "react"; -import { useAuth } from "@/lib/auth-context" +import { useAuth } from "@/lib/auth-context"; import { createBackfillJob, getBackfillJobs, + getTapStats, type BackfillJob, -} from "@/lib/api" -import { SiteHeader } from "@/components/site-header" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" + type TapStatsResponse, +} from "@/lib/api"; +import { SiteHeader } from "@/components/site-header"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { Dialog, DialogClose, @@ -20,9 +27,9 @@ import { DialogHeader, DialogTitle, DialogTrigger, -} from "@/components/ui/dialog" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Table, TableBody, @@ -30,43 +37,32 @@ import { TableHead, TableHeader, TableRow, -} from "@/components/ui/table" - -function statusVariant(status: string) { - switch (status) { - case "completed": - return "default" as const - case "running": - return "secondary" as const - case "failed": - return "destructive" as const - default: - return "outline" as const - } -} +} from "@/components/ui/table"; export default function BackfillPage() { - const { getToken } = useAuth() - const [jobs, setJobs] = useState([]) - const [error, setError] = useState(null) + const { getToken } = useAuth(); + const [jobs, setJobs] = useState([]); + const [tapStats, setTapStats] = useState(null); + const [error, setError] = useState(null); const load = useCallback(() => { - getBackfillJobs(getToken).then(setJobs).catch((e) => setError(e.message)) - }, [getToken]) + getBackfillJobs(getToken) + .then(setJobs) + .catch((e) => setError(e.message)); + getTapStats(getToken) + .then(setTapStats) + .catch(() => setTapStats(null)); + }, [getToken]); useEffect(() => { - load() - }, [load]) + load(); + }, [load]); - // Auto-refresh every 5 seconds when there are active jobs + // Auto-refresh every 5 seconds useEffect(() => { - const hasActive = jobs.some( - (j) => j.status === "pending" || j.status === "running" - ) - if (!hasActive) return - const interval = setInterval(load, 5000) - return () => clearInterval(interval) - }, [jobs, load]) + const interval = setInterval(load, 5000); + return () => clearInterval(interval); + }, [load]); return ( <> @@ -74,6 +70,33 @@ export default function BackfillPage() {
{error &&

{error}

} +
+ + + Tap Repos + + {tapStats ? tapStats.repo_count.toLocaleString() : "--"} + + + + + + Tap Records + + {tapStats ? tapStats.record_count.toLocaleString() : "--"} + + + + + + Outbox Buffer + + {tapStats ? tapStats.outbox_buffer.toLocaleString() : "--"} + + + +
+

Backfill Jobs

@@ -86,7 +109,6 @@ export default function BackfillPage() { ID Collection DID - Status Progress Records Started @@ -96,7 +118,7 @@ export default function BackfillPage() { {jobs.length === 0 && ( No backfill jobs yet. @@ -114,11 +136,6 @@ export default function BackfillPage() { {job.did ?? "All"} - - - {job.status} - - {job.processed_repos != null && job.total_repos != null ? `${job.processed_repos} / ${job.total_repos}` @@ -139,34 +156,34 @@ export default function BackfillPage() {
- ) + ); } function CreateDialog({ getToken, onSuccess, }: { - getToken: () => Promise - onSuccess: () => void + getToken: () => Promise; + onSuccess: () => void; }) { - const [collection, setCollection] = useState("") - const [did, setDid] = useState("") - const [error, setError] = useState(null) - const [open, setOpen] = useState(false) + const [collection, setCollection] = useState(""); + const [did, setDid] = useState(""); + const [error, setError] = useState(null); + const [open, setOpen] = useState(false); async function handleCreate() { - setError(null) + setError(null); try { await createBackfillJob(getToken, { collection: collection || undefined, did: did || undefined, - }) - setCollection("") - setDid("") - setOpen(false) - onSuccess() + }); + setCollection(""); + setDid(""); + setOpen(false); + onSuccess(); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)) + setError(e instanceof Error ? e.message : String(e)); } } @@ -212,5 +229,5 @@ function CreateDialog({ - ) + ); } diff --git a/web/src/app/(dashboard)/page.tsx b/web/src/app/(dashboard)/page.tsx index 7f58f34..5aded16 100644 --- a/web/src/app/(dashboard)/page.tsx +++ b/web/src/app/(dashboard)/page.tsx @@ -1,16 +1,16 @@ -"use client" +"use client"; -import { useEffect, useState } from "react" +import { useEffect, useState } from "react"; -import { useAuth } from "@/lib/auth-context" -import { getStats, type StatsResponse } from "@/lib/api" -import { SiteHeader } from "@/components/site-header" +import { useAuth } from "@/lib/auth-context"; +import { getStats, type StatsResponse } from "@/lib/api"; +import { SiteHeader } from "@/components/site-header"; import { Card, CardDescription, CardHeader, CardTitle, -} from "@/components/ui/card" +} from "@/components/ui/card"; import { Table, TableBody, @@ -18,37 +18,37 @@ import { TableHead, TableHeader, TableRow, -} from "@/components/ui/table" +} from "@/components/ui/table"; export default function DashboardPage() { - const { getToken } = useAuth() - const [stats, setStats] = useState(null) - const [error, setError] = useState(null) + const { getToken } = useAuth(); + const [stats, setStats] = useState(null); + const [error, setError] = useState(null); useEffect(() => { - getStats(getToken).then(setStats).catch((e) => setError(e.message)) - }, [getToken]) + getStats(getToken) + .then(setStats) + .catch((e) => setError(e.message)); + }, [getToken]); return ( <>
- {error && ( -

{error}

- )} -
+ {error &&

{error}

} +
- + Total Records - + {stats ? stats.total_records.toLocaleString() : "--"} - + Collections - + {stats ? stats.collections.length : "--"} @@ -81,5 +81,5 @@ export default function DashboardPage() { )}
- ) + ); } diff --git a/web/src/app/globals.css b/web/src/app/globals.css index b28c4c5..4cb81c6 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -7,6 +7,7 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); + --font-display: var(--font-zen-tokyo-zoo); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); --color-sidebar-ring: var(--sidebar-ring); @@ -143,4 +144,4 @@ html.dark .shiki span { font-style: var(--shiki-dark-font-style) !important; font-weight: var(--shiki-dark-font-weight) !important; text-decoration: var(--shiki-dark-text-decoration) !important; -} \ No newline at end of file +} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 5a72549..7826633 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,35 +1,40 @@ -import type { Metadata } from "next" -import { Geist, Geist_Mono } from "next/font/google" -import { ThemeProvider } from "next-themes" -import "./globals.css" -import { ConfigProvider } from "@/lib/config-context" -import { AuthProvider } from "@/lib/auth-context" -import { TooltipProvider } from "@/components/ui/tooltip" +import type { Metadata } from "next"; +import { Geist, Geist_Mono, Zen_Tokyo_Zoo } from "next/font/google"; +import { ThemeProvider } from "next-themes"; +import "./globals.css"; +import { ConfigProvider } from "@/lib/config-context"; +import { AuthProvider } from "@/lib/auth-context"; +import { TooltipProvider } from "@/components/ui/tooltip"; + +const zenTokyoZoo = Zen_Tokyo_Zoo({ + subsets: ["latin"], + weight: "400", +}); const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], -}) +}); const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"], -}) +}); export const metadata: Metadata = { title: "HappyView Admin", description: "Admin dashboard for HappyView AppView", -} +}; export default function RootLayout({ children, }: Readonly<{ - children: React.ReactNode + children: React.ReactNode; }>) { return ( @@ -40,5 +45,5 @@ export default function RootLayout({ - ) + ); } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 8a795c5..3106961 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -171,6 +171,17 @@ export function deleteNetworkLexicon( ) } +// Tap Stats +export interface TapStatsResponse { + repo_count: number + record_count: number + outbox_buffer: number +} + +export function getTapStats(getToken: () => Promise) { + return apiFetch("/admin/tap/stats", getToken) +} + // Backfill export interface BackfillJob { id: string