* style(web): run prettier over drifted files prettier --check was failing on 19 files at HEAD. The pinned prettier version (3.8.1) has not changed since the initial migration, so the drift came from unformatted commits, not a toolchain bump. Formatting changes only, no behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(web): sync stale test harnesses with current components 15 tests across 6 files were failing at HEAD; the components were committed already out of sync with their tests in the initial migration. All fixes are in test files only: - SectionRow / SeriesContent: mock the watch playback controller required by ContinueWatchingCard and MediaItemMenu, mock the new useSimilarItems hook, add useOptionalAuth to the auth mock, and wrap renders in QueryClientProvider for MediaItemMenu query hooks - SeriesContent: read rating from item.user_rating (the useRating hook no longer exists) - ProfilesSettings: add useHouseholdSessions to the profiles mock for the new HouseholdStreamsPanel - ImpersonationBanner: match the redesigned banner ("Viewing as", sticky full-width bar instead of sidebar offset) - EpisodeRow: watched indicator uses the text-success token now - profile-management: use a valid DiceBear preset ref; "adventurer" no longer resolves and round-trips to "" Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
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";
|
|
|
|
const CONNECTION_PROBLEM_INDICATOR_DELAY_MS = 4_000;
|
|
|
|
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<string, number> = {};
|
|
for (const s of sessions) {
|
|
const method = s.play_method || "unknown";
|
|
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);
|
|
|
|
// Keep mounted while popover is open so Radix can animate closed
|
|
if (hideWhenEmpty && totalActive === 0 && !open) return null;
|
|
|
|
return (
|
|
<PopoverPrimitive.Root key={location.pathname} open={open} onOpenChange={setOpen}>
|
|
<PopoverPrimitive.Trigger asChild>
|
|
<button
|
|
type="button"
|
|
aria-label={
|
|
totalActive > 0 ? `Server activity: ${totalActive} active` : "Server activity"
|
|
}
|
|
className="hover:bg-accent/60 relative flex h-9 w-9 items-center justify-center rounded-xl transition-colors"
|
|
>
|
|
<Activity
|
|
className={`h-[18px] w-[18px] ${
|
|
totalActive > 0 ? "text-primary" : "text-muted-foreground"
|
|
}`}
|
|
/>
|
|
{totalActive > 0 && (
|
|
<span className="absolute -top-0.5 -right-0.5 flex h-[18px] min-w-[18px] animate-[pulse-opacity_2s_ease-in-out_infinite] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white shadow-sm">
|
|
{totalActive}
|
|
</span>
|
|
)}
|
|
{showConnectionProblem && (
|
|
<span
|
|
className="ring-background absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full bg-red-500 ring-2"
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
</button>
|
|
</PopoverPrimitive.Trigger>
|
|
|
|
<PopoverPrimitive.Portal>
|
|
<PopoverPrimitive.Content
|
|
side="bottom"
|
|
align="end"
|
|
sideOffset={8}
|
|
className="data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 z-50 w-[320px] origin-[var(--radix-popover-content-transform-origin)] rounded-2xl border border-[color-mix(in_srgb,var(--border)_72%,transparent)] bg-[color-mix(in_srgb,var(--surface)_95%,transparent)] p-0 shadow-[0_24px_50px_-12px_rgb(0_0_0/0.5)] backdrop-blur-2xl"
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-[color-mix(in_srgb,var(--border)_50%,transparent)] px-4 py-3">
|
|
<span className="text-[13px] font-bold">Server Activity</span>
|
|
{connectionState !== "live" && (
|
|
<span className="text-warning text-[10px] font-medium">
|
|
{connectionState === "connecting" ? "Connecting…" : "Disconnected"}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="max-h-[400px] overflow-y-auto">
|
|
{totalActive === 0 && scansLoaded ? (
|
|
<div className="text-muted-foreground px-4 py-8 text-center text-sm">
|
|
No active server activity
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Streams */}
|
|
<ActivitySection
|
|
title="Streams"
|
|
count={sessions.length}
|
|
href="/admin/activity"
|
|
onNavigate={() => setOpen(false)}
|
|
>
|
|
{sessions.length > 0 ? (
|
|
<div className="space-y-1.5">
|
|
{Object.entries(streamCounts).map(([method, count]) => (
|
|
<StreamCountRow key={method} method={method} count={count} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<EmptyRow>No active streams</EmptyRow>
|
|
)}
|
|
</ActivitySection>
|
|
|
|
{/* Tasks */}
|
|
<ActivitySection
|
|
title="Tasks"
|
|
count={runningTasks.length}
|
|
href="/admin/tasks"
|
|
onNavigate={() => setOpen(false)}
|
|
>
|
|
{runningTasks.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{runningTasks.map((task) => (
|
|
<TaskRow key={task.key} task={task} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<EmptyRow>No running tasks</EmptyRow>
|
|
)}
|
|
</ActivitySection>
|
|
|
|
{/* Scans */}
|
|
<ActivitySection
|
|
title="Scans"
|
|
count={activeScans.length}
|
|
href="/admin/libraries"
|
|
onNavigate={() => setOpen(false)}
|
|
last
|
|
>
|
|
{activeScans.length > 0 ? (
|
|
<div className="space-y-1.5">
|
|
{activeScans.map((scan) => (
|
|
<ScanRow
|
|
key={scan.id}
|
|
scan={scan}
|
|
libraryName={libraryName(scan.library_id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<EmptyRow>No active scans</EmptyRow>
|
|
)}
|
|
</ActivitySection>
|
|
</>
|
|
)}
|
|
</div>
|
|
</PopoverPrimitive.Content>
|
|
</PopoverPrimitive.Portal>
|
|
</PopoverPrimitive.Root>
|
|
);
|
|
}
|
|
|
|
// ── Sub-components ───────────────────────────────────────────
|
|
|
|
function ActivitySection({
|
|
title,
|
|
count,
|
|
href,
|
|
onNavigate,
|
|
last = false,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
count: number;
|
|
href: string;
|
|
onNavigate: () => void;
|
|
last?: boolean;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={
|
|
last
|
|
? "px-4 py-3"
|
|
: "border-b border-[color-mix(in_srgb,var(--border)_40%,transparent)] px-4 py-3"
|
|
}
|
|
>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground text-[10px] font-semibold tracking-[0.12em] uppercase">
|
|
{title}
|
|
</span>
|
|
{count > 0 && (
|
|
<span className="bg-primary/10 text-primary rounded-md px-1.5 py-0.5 text-[10px] leading-none font-bold">
|
|
{count}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Link
|
|
to={href}
|
|
onClick={onNavigate}
|
|
className="text-muted-foreground hover:text-primary flex items-center gap-0.5 text-[10px] font-medium transition-colors"
|
|
>
|
|
View all
|
|
<ChevronRight className="h-3 w-3" />
|
|
</Link>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const METHOD_META: Record<string, { label: string; color: string }> = {
|
|
direct: { label: "Direct Play", color: "bg-success" },
|
|
remux: { label: "Remux", color: "bg-info" },
|
|
transcode: { label: "Transcode", color: "bg-warning" },
|
|
};
|
|
|
|
function StreamCountRow({ method, count }: { method: string; count: number }) {
|
|
const { label = method, color = "bg-muted-foreground" } = METHOD_META[method] ?? {};
|
|
return (
|
|
<div className="flex items-center gap-2.5">
|
|
<span className={`h-2 w-2 rounded-full ${color}`} />
|
|
<span className="text-[12px] font-medium">
|
|
{count} {label}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TaskRow({ task }: { task: TaskInfo }) {
|
|
return (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between">
|
|
<span className="truncate text-[12px] font-medium">{task.name}</span>
|
|
<span className="text-muted-foreground ml-2 shrink-0 text-[10px] font-semibold tabular-nums">
|
|
{Math.round(task.progress)}%
|
|
</span>
|
|
</div>
|
|
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
|
|
<div
|
|
className="bg-primary h-full rounded-full transition-[width] duration-300 ease-out"
|
|
style={{ width: `${Math.min(100, Math.max(0, task.progress))}%` }}
|
|
/>
|
|
</div>
|
|
{task.progress_message && (
|
|
<div className="text-muted-foreground truncate text-[10px]">{task.progress_message}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ScanRow({ scan, libraryName }: { scan: ScanRun; libraryName: string }) {
|
|
const progressLabel = formatScanProgress(scan);
|
|
return (
|
|
<div className="flex items-start gap-2.5">
|
|
{scan.status === "running" ? (
|
|
<Loader className="text-primary mt-0.5 h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<ScanLine className="text-muted-foreground mt-0.5 h-3.5 w-3.5" />
|
|
)}
|
|
<div className="min-w-0 flex-1 space-y-0.5">
|
|
<div className="flex items-center gap-2">
|
|
<span className="truncate text-[12px] font-medium">{libraryName}</span>
|
|
<span className="text-muted-foreground ml-auto shrink-0 text-[10px]">
|
|
{scan.status === "running" ? "Scanning…" : "Queued"}
|
|
</span>
|
|
</div>
|
|
<div className="text-muted-foreground truncate text-[10px]">
|
|
{formatScanLabel(scan)}
|
|
{scan.path ? ` · ${scan.path}` : ""}
|
|
</div>
|
|
{progressLabel && (
|
|
<div className="text-muted-foreground/80 truncate text-[10px]">{progressLabel}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 <div className="text-muted-foreground py-1 text-[11px]">{children}</div>;
|
|
}
|