import { useState, useMemo, useEffect, useRef } from "react"; import { Link, useLocation } from "react-router"; import { Popover as PopoverPrimitive } from "radix-ui"; import { Activity, ChevronRight, Loader, ScanLine } from "lucide-react"; import { useAdminSessions } from "@/hooks/queries/admin/stats"; import { useTasks } from "@/hooks/queries/admin/tasks"; import { useActiveScans } from "@/hooks/queries/admin/scans"; import { useAdminLibraries } from "@/hooks/queries/admin/libraries"; import { useRealtimeEvents, type RealtimeConnectionState, } from "@/components/realtimeEventsContext"; import type { TaskInfo, ScanRun } from "@/api/types"; import { activityMethodMeta, classifyActivityMethod, compareActivityMethods, } from "@/pages/adminActivityPresentation"; const CONNECTION_PROBLEM_INDICATOR_DELAY_MS = 4_000; const MAX_ACTIVITY_SCAN_ROWS = 25; const MAX_BADGE_COUNT = 99; const ACTIVE_SCAN_SNAPSHOT_LIMIT = 500; interface ServerActivityProps { /** Hide the trigger button entirely when there is no activity */ hideWhenEmpty?: boolean; } // ── Data hook ──────────────────────────────────────────────── // Channel subscriptions are owned by the parent layout // (AdminLayoutEventChannels / AdminEventChannels), not here. function useServerActivityData() { const { data: sessions = [] } = useAdminSessions(); const { data: tasks = [] } = useTasks(); const { data: scans } = useActiveScans(); const { data: libraries = [] } = useAdminLibraries(); const { connectionState } = useRealtimeEvents(); const activeScans = useMemo( () => (scans ?? []).filter((s) => s.status === "accepted" || s.status === "running"), [scans], ); const runningTasks = useMemo(() => tasks.filter((t) => t.state === "running"), [tasks]); const streamCounts = useMemo(() => { const counts: Record = {}; for (const s of sessions) { const method = classifyActivityMethod(s); counts[method] = (counts[method] || 0) + 1; } return counts; }, [sessions]); const totalActive = sessions.length + runningTasks.length + activeScans.length; const libraryName = (id: number) => libraries.find((l) => l.id === id)?.name ?? `Library #${id}`; return { sessions, runningTasks, activeScans, streamCounts, totalActive, libraryName, connectionState, scansLoaded: scans !== undefined, }; } // ── Main component ─────────────────────────────────────────── function useDelayedConnectionProblem(connectionState: RealtimeConnectionState) { const isNonLive = connectionState !== "live"; const previousIsNonLiveRef = useRef(false); const [connectionProblemState, setConnectionProblemState] = useState(false); useEffect(() => { const wasNonLive = previousIsNonLiveRef.current; previousIsNonLiveRef.current = isNonLive; if (!isNonLive) { setConnectionProblemState(false); return; } if (wasNonLive) { return; } const timeoutID = window.setTimeout( () => setConnectionProblemState(true), CONNECTION_PROBLEM_INDICATOR_DELAY_MS, ); return () => window.clearTimeout(timeoutID); }, [isNonLive]); return isNonLive && connectionProblemState; } export default function ServerActivity({ hideWhenEmpty = false }: ServerActivityProps) { const [open, setOpen] = useState(false); const location = useLocation(); const { sessions, runningTasks, activeScans, streamCounts, totalActive, libraryName, connectionState, scansLoaded, } = useServerActivityData(); const showConnectionProblem = useDelayedConnectionProblem(connectionState); const visibleActiveScans = activeScans.slice(0, MAX_ACTIVITY_SCAN_ROWS); const hiddenActiveScanCount = activeScans.length - visibleActiveScans.length; const activeScansMayBeTruncated = activeScans.length >= ACTIVE_SCAN_SNAPSHOT_LIMIT; const totalActiveLabel = formatBadgeCount(totalActive, activeScansMayBeTruncated); // Keep mounted while popover is open so Radix can animate closed if (hideWhenEmpty && totalActive === 0 && !open) return null; return ( {/* Header */}
Server Activity {connectionState !== "live" && ( {connectionState === "connecting" ? "Connecting…" : "Disconnected"} )}
{totalActive === 0 && scansLoaded ? (
No active server activity
) : ( <> {/* Streams */} setOpen(false)} > {sessions.length > 0 ? (
{Object.entries(streamCounts) .sort(([a], [b]) => compareActivityMethods(a, b)) .map(([method, count]) => ( ))}
) : ( No active streams )}
{/* Tasks */} setOpen(false)} > {runningTasks.length > 0 ? (
{runningTasks.map((task) => ( ))}
) : ( No running tasks )}
{/* Scans */} setOpen(false)} last > {activeScans.length > 0 ? (
{visibleActiveScans.map((scan) => ( ))} {hiddenActiveScanCount > 0 && ( )}
) : ( No active scans )}
)}
); } // ── Sub-components ─────────────────────────────────────────── function ActivitySection({ title, count, countIsLowerBound = false, href, onNavigate, last = false, children, }: { title: string; count: number; countIsLowerBound?: boolean; href: string; onNavigate: () => void; last?: boolean; children: React.ReactNode; }) { return (
{title} {count > 0 && ( {formatBadgeCount(count, countIsLowerBound)} )}
View all
{children}
); } function formatBadgeCount(count: number, countIsLowerBound = false) { if (countIsLowerBound) { return count > MAX_BADGE_COUNT ? `${MAX_BADGE_COUNT}+` : `${count}+`; } return count > MAX_BADGE_COUNT ? `${MAX_BADGE_COUNT}+` : count.toString(); } function StreamCountRow({ method, count }: { method: string; count: number }) { const meta = activityMethodMeta(method); return (
{count} {meta.label}
); } function TaskRow({ task }: { task: TaskInfo }) { return (
{task.name} {Math.round(task.progress)}%
{task.progress_message && (
{task.progress_message}
)}
); } function ScanRow({ scan, libraryName }: { scan: ScanRun; libraryName: string }) { const progressLabel = formatScanProgress(scan); return (
{scan.status === "running" ? ( ) : ( )}
{libraryName} {scan.status === "running" ? "Scanning…" : "Queued"}
{formatScanLabel(scan)} {scan.path ? ` · ${scan.path}` : ""}
{progressLabel && (
{progressLabel}
)}
); } function formatScanLabel(scan: ScanRun) { switch (scan.mode) { case "library": return "Full library"; case "subtree": return "Subtree"; case "file": return "Single file"; default: return scan.mode; } } function formatScanProgress(scan: ScanRun) { const result = scan.result; if (!result) { return null; } if (result.total_files && result.files_processed) { const percent = Math.max( 0, Math.min(100, Math.round((result.files_processed / result.total_files) * 100)), ); return `${result.message ?? "Processing files"} · ${result.files_processed.toLocaleString()} / ${result.total_files.toLocaleString()} (${percent}%)`; } if (result.message) { return result.message; } return null; } function EmptyRow({ children }: { children: React.ReactNode }) { return
{children}
; } function MoreRows({ count, label, countIsLowerBound = false, }: { count: number; label: string; countIsLowerBound?: boolean; }) { return (
{countIsLowerBound ? "At least " : ""} {count.toLocaleString()} {label}
); }