Files
silo-server/web/src/pages/AdminActivity.tsx
T

883 lines
33 KiB
TypeScript

import type { ReactNode } from "react";
import { useState, useMemo } from "react";
import { Link } from "react-router";
import { AdminSessionActions } from "@/components/AdminSessionActions";
import { useRealtimeEvents } from "@/components/realtimeEventsContext";
import { useOperationalLogs } from "@/hooks/queries/admin/logs";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import type { AdminSession, OperationalLogEntry, IPUserEntry } from "@/api/types";
import { useIPUsers } from "@/hooks/queries/admin/ips";
import { useAdminSessions } from "@/hooks/queries/admin/stats";
import {
formatAudioDetail,
formatAudioSummary,
formatDecisionLabel,
formatSessionBitrate,
formatVideoDetail,
formatVideoSummary,
} from "@/pages/adminActivityPresentation";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
RefreshCw,
Search,
Radio,
Filter,
X,
Play,
Terminal,
ChevronDown,
ChevronUp,
} from "lucide-react";
type SortField = "username" | "media" | "method" | "node" | "started";
type SortDir = "asc" | "desc";
export default function AdminActivity() {
const { data: sessions = [], isLoading, refetch: refresh } = useAdminSessions();
const { connectionState } = useRealtimeEvents();
const error = undefined;
const [search, setSearch] = useState("");
const [methodFilter, setMethodFilter] = useState<string | null>(null);
const [nodeFilter, setNodeFilter] = useState<string | null>(null);
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [sortField, setSortField] = useState<SortField>("started");
const [sortDir, setSortDir] = useState<SortDir>("desc");
const [ipSearch, setIPSearch] = useState("");
const [activeIP, setActiveIP] = useState("");
const { data: ipUsers = [], isLoading: ipLoading } = useIPUsers(activeIP);
// Aggregate counts
const methods = useMemo(() => {
const counts: Record<string, number> = {};
for (const s of sessions)
counts[s.play_method || "unknown"] = (counts[s.play_method || "unknown"] || 0) + 1;
return counts;
}, [sessions]);
const nodes = useMemo(() => {
const counts: Record<string, number> = {};
for (const s of sessions)
counts[s.reporting_node || "unknown"] = (counts[s.reporting_node || "unknown"] || 0) + 1;
return counts;
}, [sessions]);
// Filter + sort
const filtered = useMemo(() => {
let result = sessions;
if (search) {
const q = search.toLowerCase();
result = result.filter(
(s) =>
s.username?.toLowerCase().includes(q) ||
s.media_title?.toLowerCase().includes(q) ||
s.series_name?.toLowerCase().includes(q) ||
s.episode_name?.toLowerCase().includes(q),
);
}
if (methodFilter) result = result.filter((s) => s.play_method === methodFilter);
if (nodeFilter) result = result.filter((s) => s.reporting_node === nodeFilter);
if (typeFilter) result = result.filter((s) => s.media_type === typeFilter);
return [...result].sort((a, b) => {
let cmp = 0;
switch (sortField) {
case "username":
cmp = (a.username || "").localeCompare(b.username || "");
break;
case "media":
cmp = getDisplayTitle(a).localeCompare(getDisplayTitle(b));
break;
case "method":
cmp = (a.play_method || "").localeCompare(b.play_method || "");
break;
case "node":
cmp = (a.reporting_node || "").localeCompare(b.reporting_node || "");
break;
case "started":
cmp = new Date(a.started_at).getTime() - new Date(b.started_at).getTime();
break;
}
return sortDir === "asc" ? cmp : -cmp;
});
}, [sessions, search, methodFilter, nodeFilter, typeFilter, sortField, sortDir]);
const toggleSort = (field: SortField) => {
if (sortField === field) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortDir(field === "started" ? "desc" : "asc");
}
};
const activeFilters = [methodFilter, nodeFilter, typeFilter].filter(Boolean).length;
if (isLoading) return <div className="text-muted-foreground p-8">Loading activity...</div>;
return (
<div className="space-y-5 lg:space-y-6">
{/* Header */}
<div className="page-header">
<div className="space-y-3">
<div className="flex items-center gap-3">
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Activity</h1>
{sessions.length > 0 && (
<span className="live-badge flex items-center gap-1.5">
<Radio className="h-3 w-3" />
{sessions.length} live
</span>
)}
</div>
<p className="text-muted-foreground mt-1 text-[13px]">
{sessions.length === 0
? "No active streams"
: `${sessions.length} active stream${sessions.length !== 1 ? "s" : ""} across ${Object.keys(nodes).length} node${Object.keys(nodes).length !== 1 ? "s" : ""}`}
</p>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<div className="text-muted-foreground text-[10px] font-semibold tracking-wider uppercase">
Stream
</div>
<div className="text-[12px]">{formatConnectionState(connectionState)}</div>
{error && <div className="text-muted-foreground text-[11px]">{error}</div>}
</div>
<Button variant="outline" size="sm" onClick={() => void refresh()}>
<RefreshCw className="h-3.5 w-3.5" />
Refresh
</Button>
</div>
</div>
{/* IP Lookup */}
<details className="surface-panel rounded-2xl border-0">
<summary className="cursor-pointer px-4 py-3 text-sm font-medium select-none">
IP Lookup
</summary>
<div className="px-4 pb-4">
<form
onSubmit={(e) => {
e.preventDefault();
setActiveIP(ipSearch.trim());
}}
className="flex items-center gap-2"
>
<Input
type="text"
placeholder="IP lookup (e.g. 203.0.113.50)"
value={ipSearch}
onChange={(e) => setIPSearch(e.target.value)}
className="max-w-xs font-mono text-sm"
/>
<Button type="submit" variant="outline" size="sm" disabled={!ipSearch.trim()}>
<Search className="mr-1 h-3.5 w-3.5" />
Lookup
</Button>
</form>
{activeIP && (
<div className="mt-3">
{ipLoading ? (
<p className="text-muted-foreground text-sm">Searching...</p>
) : ipUsers.length === 0 ? (
<p className="text-muted-foreground text-sm">
No users found for {activeIP} in the last 30 days.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>First Seen</TableHead>
<TableHead>Last Seen</TableHead>
<TableHead className="text-right">Requests</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ipUsers.map((entry: IPUserEntry) => (
<TableRow key={entry.user_id}>
<TableCell>
<Link
to={`/admin/users/${entry.user_id}`}
className="text-primary font-medium hover:underline"
>
{entry.username || `User #${entry.user_id}`}
</Link>
</TableCell>
<TableCell className="text-sm">
{new Date(entry.first_seen).toLocaleString()}
</TableCell>
<TableCell className="text-sm">
{new Date(entry.last_seen).toLocaleString()}
</TableCell>
<TableCell className="text-right">
{entry.request_count.toLocaleString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)}
</div>
</details>
{/* Summary strip */}
{sessions.length > 0 && (
<div className="surface-panel rounded-2xl border-0 p-4">
{/* Method distribution bar */}
<div className="mb-3">
<div className="text-muted-foreground mb-2 text-[10px] font-semibold tracking-wider uppercase">
Play Method
</div>
<div className="flex h-1.5 overflow-hidden rounded-full">
{Object.entries(methods)
.sort(([a], [b]) => a.localeCompare(b))
.map(([method, count]) => (
<div
key={method}
className={`transition-all duration-500 ${methodBarColor(method)}`}
style={{ width: `${(count / sessions.length) * 100}%` }}
/>
))}
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1">
{Object.entries(methods)
.sort(([a], [b]) => a.localeCompare(b))
.map(([method, count]) => (
<button
key={method}
onClick={() => setMethodFilter(methodFilter === method ? null : method)}
className={`flex items-center gap-1.5 text-[11px] transition-opacity ${
methodFilter && methodFilter !== method ? "opacity-30" : ""
}`}
>
<span
className={`inline-block h-2 w-2 rounded-full ${methodDotColor(method)}`}
/>
<span className="font-medium capitalize">{method}</span>
<span className="text-muted-foreground tabular-nums">{count}</span>
</button>
))}
</div>
</div>
{/* Node breakdown */}
{Object.keys(nodes).length > 1 && (
<div className="border-border border-t pt-3">
<div className="text-muted-foreground mb-2 text-[10px] font-semibold tracking-wider uppercase">
By Node
</div>
<div className="flex flex-wrap gap-1.5">
{Object.entries(nodes)
.sort(([, a], [, b]) => b - a)
.map(([node, count]) => (
<button
key={node}
onClick={() => setNodeFilter(nodeFilter === node ? null : node)}
className={`bg-surface border-border hover:border-primary/20 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-all ${
nodeFilter === node
? "border-primary/40 bg-primary/10 text-primary"
: nodeFilter
? "opacity-30"
: ""
}`}
>
{node}
<span className="text-muted-foreground ml-1.5 tabular-nums">{count}</span>
</button>
))}
</div>
</div>
)}
</div>
)}
{/* Search + filters */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[200px] flex-1">
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2" />
<Input
placeholder="Filter by user or media..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 pl-9 text-[13px]"
/>
{search && (
<button
onClick={() => setSearch("")}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-2.5 -translate-y-1/2"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="flex gap-1">
{(["movie", "series"] as const).map((t) => (
<button
key={t}
onClick={() => setTypeFilter(typeFilter === t ? null : t)}
className={`rounded-md border px-2.5 py-1.5 text-[11px] font-medium capitalize transition-all ${
typeFilter === t
? "border-primary/40 bg-primary/10 text-primary"
: "border-border bg-surface text-muted-foreground hover:text-foreground"
}`}
>
{t === "movie" ? "Movies" : "Series"}
</button>
))}
</div>
{activeFilters > 0 && (
<button
onClick={() => {
setMethodFilter(null);
setNodeFilter(null);
setTypeFilter(null);
}}
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-[11px]"
>
<X className="h-3 w-3" />
Clear filters
</button>
)}
</div>
{/* Filter status */}
{(search || activeFilters > 0) && (
<div className="text-muted-foreground text-[11px]">
Showing {filtered.length} of {sessions.length} streams
</div>
)}
{/* Stream table */}
{filtered.length === 0 ? (
<EmptyState hasData={sessions.length > 0} />
) : (
<div className="bg-card border-border overflow-hidden rounded-lg border">
{/* Table header */}
<div className="border-border bg-surface/50 hidden grid-cols-[minmax(140px,1.5fr)_minmax(220px,2.2fr)_minmax(150px,1.4fr)_minmax(150px,1.4fr)_minmax(90px,1fr)_80px] items-center gap-3 border-b px-4 py-2.5 sm:grid">
<SortHeader field="username" current={sortField} dir={sortDir} onClick={toggleSort}>
User
</SortHeader>
<SortHeader field="media" current={sortField} dir={sortDir} onClick={toggleSort}>
Stream
</SortHeader>
<SortHeader field="method" current={sortField} dir={sortDir} onClick={toggleSort}>
Video
</SortHeader>
<div className="text-muted-foreground text-[10px] font-semibold tracking-wider uppercase">
Audio
</div>
<SortHeader field="node" current={sortField} dir={sortDir} onClick={toggleSort}>
Node
</SortHeader>
<SortHeader
field="started"
current={sortField}
dir={sortDir}
onClick={toggleSort}
className="justify-end"
>
Time
</SortHeader>
</div>
{/* Rows */}
<div className="max-h-[calc(100vh-420px)] min-h-[200px] overflow-y-auto">
{filtered.map((session, i) => (
<StreamRow key={session.session_id} session={session} even={i % 2 === 0} />
))}
</div>
</div>
)}
</div>
);
}
// --- Sub-components ---
function StreamRow({ session, even }: { session: AdminSession; even: boolean }) {
const [ffmpegOpen, setFFmpegOpen] = useState(false);
const title = getDisplayTitle(session);
const subtitle = getDisplaySubtitle(session);
const username = session.username || `User #${session.user_id}`;
const elapsed = getElapsed(session.started_at);
const historyHref = `/admin/history?user_id=${session.user_id}${session.profile_id ? `&profile_id=${encodeURIComponent(session.profile_id)}` : ""}`;
const sourceContainer = session.source_container?.trim().toUpperCase();
const streamBitrate = formatSessionBitrate(session.stream_bitrate_kbps);
const streamMeta = [sourceContainer, streamBitrate].filter(Boolean).join(" · ");
const userMeta = session.client_ip?.trim() || "—";
const videoDecision = session.video_decision || session.play_method;
const audioDecision =
session.audio_decision || (session.transcode_audio ? "transcode" : session.play_method);
const logsHref = `/admin/logs?playback_session_id=${encodeURIComponent(session.session_id)}&focus=playback`;
const ffmpegLogsHref = `${logsHref}&component=ffmpeg`;
const ffmpegLogs = useOperationalLogs(
{
playback_session_id: session.session_id,
component: "ffmpeg",
limit: 12,
},
ffmpegOpen,
);
const ffmpegRows = ffmpegLogs.data?.entries ?? [];
return (
<div
className={`border-border/30 hover:bg-surface/60 border-b transition-colors duration-100 ${
even ? "" : "bg-surface/20"
}`}
>
{/* Desktop row */}
<div className="hidden grid-cols-[minmax(140px,1.5fr)_minmax(220px,2.2fr)_minmax(150px,1.4fr)_minmax(150px,1.4fr)_minmax(90px,1fr)_80px] items-center gap-3 px-4 py-2.5 sm:grid">
{/* User */}
<div className="flex min-w-0 items-center gap-2">
<div
className="text-primary-foreground flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-[9px] font-bold"
style={{ background: "var(--primary)" }}
>
{username.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<Link
to={historyHref}
className="hover:text-primary block truncate text-[13px] font-medium transition-colors"
>
{username}
</Link>
<div className="text-muted-foreground truncate text-[10px]">{userMeta}</div>
</div>
</div>
{/* Stream */}
<div className="min-w-0">
{session.content_id ? (
<Link
to={`/item/${session.content_id}`}
className="hover:text-primary block truncate text-[13px] font-medium transition-colors"
>
{title}
</Link>
) : (
<div className="truncate text-[13px] font-medium">{title}</div>
)}
{subtitle && <div className="text-muted-foreground truncate text-[10px]">{subtitle}</div>}
{streamMeta && (
<div className="text-muted-foreground truncate text-[10px]">{streamMeta}</div>
)}
</div>
{/* Video */}
<div className="min-w-0">
<span
className={`mb-1 inline-flex rounded border px-1.5 py-0.5 text-[9px] font-semibold ${methodBadgeColor(videoDecision)}`}
>
{formatDecisionLabel(videoDecision)}
</span>
<div className="truncate text-[12px] font-medium">{formatVideoSummary(session)}</div>
<div className="text-muted-foreground truncate text-[10px]">
{formatVideoDetail(session)}
</div>
</div>
{/* Audio */}
<div className="min-w-0">
<span
className={`mb-1 inline-flex rounded border px-1.5 py-0.5 text-[9px] font-semibold ${methodBadgeColor(audioDecision)}`}
>
{formatDecisionLabel(audioDecision)}
</span>
<div className="truncate text-[12px] font-medium">{formatAudioSummary(session)}</div>
<div className="text-muted-foreground truncate text-[10px]">
{formatAudioDetail(session)}
</div>
</div>
{/* Node */}
<div className="min-w-0">
<div className="text-muted-foreground truncate text-[12px]">
{session.node_display_name || session.reporting_node || "—"}
</div>
{(session.profile_name || session.profile_id) && (
<div className="text-muted-foreground truncate text-[10px]">
{session.profile_name || session.profile_id}
</div>
)}
</div>
{/* Duration */}
<div className="text-muted-foreground text-right font-mono text-[12px] tabular-nums">
<div>{elapsed}</div>
<div className="mt-1 flex items-center justify-end gap-2">
<AdminSessionActions session={session} compact />
<button
type="button"
onClick={() => setFFmpegOpen((open) => !open)}
className="text-primary inline-flex items-center gap-1 text-[11px]"
>
<Terminal className="h-3 w-3" />
FFmpeg
{ffmpegOpen ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</button>
<Link to={logsHref} className="text-primary inline-block text-[11px]">
View Logs
</Link>
<Link to={ffmpegLogsHref} className="text-primary/80 inline-block text-[11px]">
FFmpeg Logs
</Link>
</div>
</div>
</div>
{/* Mobile row */}
<div className="flex gap-3 px-4 py-3 sm:hidden">
<div
className="text-primary-foreground flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-[10px] font-bold"
style={{ background: "var(--primary)" }}
>
{username.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link
to={historyHref}
className="hover:text-primary truncate text-[13px] font-semibold transition-colors"
>
{username}
</Link>
<span
className={`inline-flex flex-shrink-0 rounded border px-1.5 py-0.5 text-[9px] font-semibold ${methodBadgeColor(session.play_method)}`}
>
{session.play_method || "?"}
</span>
</div>
<div className="text-muted-foreground mt-0.5 flex items-center gap-1.5 text-[11px]">
{session.content_id ? (
<Link
to={`/item/${session.content_id}`}
className="hover:text-primary truncate transition-colors"
>
{title}
</Link>
) : (
<span className="truncate">{title}</span>
)}
<span className="flex-shrink-0">·</span>
<span className="flex-shrink-0 font-mono tabular-nums">{elapsed}</span>
</div>
<div className="text-muted-foreground mt-1 truncate text-[10px]">
{[userMeta, streamMeta || null].filter(Boolean).join(" · ")}
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<div className="rounded-md border border-white/6 bg-white/[0.03] px-2 py-1.5">
<div className="mb-1 flex items-center gap-1.5">
<span
className={`inline-flex rounded border px-1.5 py-0.5 text-[9px] font-semibold ${methodBadgeColor(videoDecision)}`}
>
{formatDecisionLabel(videoDecision)}
</span>
<span className="text-muted-foreground text-[9px] tracking-wide uppercase">
Video
</span>
</div>
<div className="truncate text-[11px] font-medium">{formatVideoSummary(session)}</div>
<div className="text-muted-foreground truncate text-[10px]">
{formatVideoDetail(session)}
</div>
</div>
<div className="rounded-md border border-white/6 bg-white/[0.03] px-2 py-1.5">
<div className="mb-1 flex items-center gap-1.5">
<span
className={`inline-flex rounded border px-1.5 py-0.5 text-[9px] font-semibold ${methodBadgeColor(audioDecision)}`}
>
{formatDecisionLabel(audioDecision)}
</span>
<span className="text-muted-foreground text-[9px] tracking-wide uppercase">
Audio
</span>
</div>
<div className="truncate text-[11px] font-medium">{formatAudioSummary(session)}</div>
<div className="text-muted-foreground truncate text-[10px]">
{formatAudioDetail(session)}
</div>
</div>
</div>
<div className="mt-2 flex items-center gap-3">
<AdminSessionActions session={session} compact />
<button
type="button"
onClick={() => setFFmpegOpen((open) => !open)}
className="text-primary inline-flex items-center gap-1 text-[11px] font-medium"
>
<Terminal className="h-3 w-3" />
FFmpeg
{ffmpegOpen ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</button>
<Link to={logsHref} className="text-primary inline-block text-[11px] font-medium">
View Logs
</Link>
<Link
to={ffmpegLogsHref}
className="text-primary/80 inline-block text-[11px] font-medium"
>
FFmpeg Logs
</Link>
</div>
</div>
</div>
{ffmpegOpen && (
<FFmpegLogPanel
sessionID={session.session_id}
rows={ffmpegRows}
isLoading={ffmpegLogs.isLoading}
isFetching={ffmpegLogs.isFetching}
logsHref={`${logsHref}&component=ffmpeg`}
/>
)}
</div>
);
}
function FFmpegLogPanel({
sessionID,
rows,
isLoading,
isFetching,
logsHref,
}: {
sessionID: string;
rows: OperationalLogEntry[];
isLoading: boolean;
isFetching: boolean;
logsHref: string;
}) {
return (
<div className="terminal-surface border-border/50 bg-card border-t px-4 py-3">
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<div className="rounded-full border border-[var(--terminal-border)] bg-[var(--terminal-bg)] px-2 py-0.5 text-[10px] font-semibold tracking-[0.2em] text-[var(--terminal-fg)] uppercase">
FFmpeg
</div>
<div className="text-foreground/85 text-[11px] font-medium">Live transcode console</div>
</div>
<div className="text-muted-foreground mt-1 font-mono text-[10px]">{sessionID}</div>
</div>
<div className="flex items-center gap-3">
{isFetching && <div className="text-muted-foreground text-[10px]">Refreshing…</div>}
<Link
to={logsHref}
className="text-[11px] font-medium text-[var(--terminal-fg)] hover:text-[var(--terminal-fg)]/80"
>
Open full ffmpeg logs
</Link>
</div>
</div>
<div className="overflow-hidden rounded-xl border border-[var(--terminal-border)] bg-[var(--terminal-bg)] shadow-[0_18px_60px_rgba(0,0,0,0.35)]">
{isLoading ? (
<div className="px-4 py-6 font-mono text-[11px] text-[var(--terminal-muted)]">
Loading ffmpeg output…
</div>
) : rows.length === 0 ? (
<div className="px-4 py-6 font-mono text-[11px] text-[var(--terminal-muted)]">
No ffmpeg rows yet for this session. If the session is direct play or remux without a
transcode worker, nothing will appear here.
</div>
) : (
<div className="max-h-64 overflow-y-auto">
{rows.map((row) => (
<div
key={row.id}
className="grid grid-cols-[120px_1fr] gap-3 border-b border-[var(--terminal-border)]/30 px-4 py-2.5 last:border-b-0"
>
<div className="space-y-1">
<div className="font-mono text-[10px] text-[var(--terminal-muted)]">
{formatTimeOnly(row.timestamp)}
</div>
<div className="text-[10px] tracking-[0.18em] text-[var(--terminal-muted)]/60 uppercase">
{row.message.includes("stderr") ? "stderr" : "event"}
</div>
</div>
<div className="min-w-0">
<div className="font-mono text-[11px] leading-5 break-words text-[var(--terminal-fg)]">
{ffmpegRowText(row)}
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[10px] text-[var(--terminal-muted)]/60">
{row.node_id && <span>{row.node_id}</span>}
{stringAttr(row, "target_resolution") !== "-" && (
<span>{stringAttr(row, "target_resolution")}</span>
)}
{stringAttr(row, "hw_accel") !== "-" && (
<span>{stringAttr(row, "hw_accel")}</span>
)}
{stringAttr(row, "restart_count") !== "-" && (
<span>restart {stringAttr(row, "restart_count")}</span>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
function SortHeader({
field,
current,
dir,
onClick,
children,
className = "",
}: {
field: SortField;
current: SortField;
dir: SortDir;
onClick: (field: SortField) => void;
children: ReactNode;
className?: string;
}) {
const active = current === field;
return (
<button
onClick={() => onClick(field)}
className={`text-muted-foreground flex items-center gap-1 text-[10px] font-semibold tracking-wider uppercase transition-colors ${
active ? "text-foreground" : "hover:text-foreground"
} ${className}`}
>
{children}
{active && <span className="text-[8px]">{dir === "asc" ? "▲" : "▼"}</span>}
</button>
);
}
function formatConnectionState(state: "connecting" | "live" | "disconnected") {
switch (state) {
case "connecting":
return "Connecting";
case "live":
return "Live";
default:
return "Disconnected";
}
}
function ffmpegRowText(entry: OperationalLogEntry) {
const line = stringAttr(entry, "ffmpeg_line");
if (line !== "-") return line;
const event = stringAttr(entry, "ffmpeg_event");
if (event !== "-") return event;
return entry.message;
}
function formatTimeOnly(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleTimeString();
}
function EmptyState({ hasData }: { hasData: boolean }) {
return (
<div className="text-muted-foreground flex flex-col items-center justify-center py-20 text-sm">
{hasData ? (
<>
<Filter className="mb-3 h-8 w-8 opacity-20" />
<span>No streams match your filters</span>
</>
) : (
<>
<Play className="mb-3 h-8 w-8 opacity-20" />
<span>No active streams</span>
</>
)}
</div>
);
}
// --- Helpers ---
function getDisplayTitle(session: AdminSession): string {
if (session.series_name && session.season_number != null && session.episode_number != null) {
return session.episode_name || `S${session.season_number}E${session.episode_number}`;
}
return session.media_title || `File #${session.media_file_id}`;
}
function getDisplaySubtitle(session: AdminSession): string | null {
if (session.series_name && session.season_number != null && session.episode_number != null) {
const ep = `S${session.season_number}E${session.episode_number}`;
return session.series_name ? `${ep} · ${session.series_name}` : ep;
}
if (session.media_type === "movie") return "Movie";
if (session.media_type === "series") return "Series";
return null;
}
function getElapsed(dateStr: string): string {
const diff = Math.max(0, Date.now() - new Date(dateStr).getTime());
const totalSec = Math.floor(diff / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
return `${m}:${String(s).padStart(2, "0")}`;
}
function stringAttr(entry: OperationalLogEntry, key: string) {
const value = entry.attrs?.[key];
if (typeof value === "string" && value.length > 0) return value;
if (typeof value === "number") return String(value);
return "-";
}
function methodBadgeColor(method: string): string {
switch (method) {
case "direct":
return "bg-success/10 text-success border-success/15";
case "remux":
return "bg-info/10 text-info border-info/15";
case "transcode":
return "bg-warning/10 text-warning border-warning/15";
default:
return "bg-surface text-muted-foreground border-border";
}
}
function methodBarColor(method: string): string {
switch (method) {
case "direct":
return "bg-success";
case "remux":
return "bg-info";
case "transcode":
return "bg-warning";
default:
return "bg-muted-foreground";
}
}
function methodDotColor(method: string): string {
switch (method) {
case "direct":
return "bg-success";
case "remux":
return "bg-info";
case "transcode":
return "bg-warning";
default:
return "bg-muted-foreground";
}
}