Phase 2: Docker and OIDC auth

This commit is contained in:
2026-05-04 13:50:53 +02:00
parent 47baee854b
commit 4226628d5a
71 changed files with 9722 additions and 1347 deletions
+39 -38
View File
@@ -1,3 +1,4 @@
import { Card, CardContent, Grid, Stack, Typography } from "@mui/material";
import type { LibraryCount } from "../types";
interface Props {
@@ -9,47 +10,47 @@ export function LibraryOverview({ libraries }: Props) {
const tvLibs = libraries.filter((l) => l.type === "tvshows");
return (
<div className="grid grid-cols-2 gap-6">
{movieLibs.length > 0 && (
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-2">
Movie libraries
</p>
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
Movie libraries
</Typography>
<Stack spacing={1.5}>
{movieLibs.map((lib) => (
<div key={lib.library} className="rounded-lg border p-4 mb-2">
<p className="font-semibold">{lib.library}</p>
<div className="flex gap-6 mt-2 text-sm">
<span>
Total: <strong>{lib.total.toLocaleString()}</strong>
</span>
<span>
Movies: <strong>{lib.movies.toLocaleString()}</strong>
</span>
</div>
</div>
<Card key={lib.library} variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{lib.library}
</Typography>
<Typography variant="body2" color="text.secondary">
Total: {lib.total.toLocaleString()} | Movies:{" "}
{lib.movies.toLocaleString()}
</Typography>
</CardContent>
</Card>
))}
</div>
)}
{tvLibs.length > 0 && (
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-2">
TV libraries
</p>
</Stack>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
TV libraries
</Typography>
<Stack spacing={1.5}>
{tvLibs.map((lib) => (
<div key={lib.library} className="rounded-lg border p-4 mb-2">
<p className="font-semibold">{lib.library}</p>
<div className="flex gap-6 mt-2 text-sm">
<span>
Total: <strong>{lib.total.toLocaleString()}</strong>
</span>
<span>
Series: <strong>{lib.series.toLocaleString()}</strong>
</span>
</div>
</div>
<Card key={lib.library} variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{lib.library}
</Typography>
<Typography variant="body2" color="text.secondary">
Total: {lib.total.toLocaleString()} | Series:{" "}
{lib.series.toLocaleString()}
</Typography>
</CardContent>
</Card>
))}
</div>
)}
</div>
</Stack>
</Grid>
</Grid>
);
}
+25 -9
View File
@@ -1,3 +1,5 @@
import { Card, CardContent, Typography } from "@mui/material";
interface Props {
label: string;
value: string;
@@ -6,14 +8,28 @@ interface Props {
export function MetricCard({ label, value, subtext }: Props) {
return (
<div className="rounded-lg border p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide">{label}</p>
<p className="text-2xl font-bold mt-1">{value}</p>
{subtext && (
<p className="text-xs text-gray-400 mt-1 whitespace-pre-line">
{subtext}
</p>
)}
</div>
<Card variant="outlined">
<CardContent>
<Typography
variant="caption"
color="text.secondary"
sx={{ textTransform: "uppercase" }}
>
{label}
</Typography>
<Typography variant="h5" sx={{ mt: 0.5, fontWeight: 700 }}>
{value}
</Typography>
{subtext && (
<Typography
variant="caption"
color="text.secondary"
sx={{ whiteSpace: "pre-line" }}
>
{subtext}
</Typography>
)}
</CardContent>
</Card>
);
}
+784 -148
View File
@@ -1,28 +1,70 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as d3 from "d3";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
Box,
Button,
Card,
CardContent,
Checkbox,
Chip,
FormControlLabel,
Grid,
Typography,
} from "@mui/material";
import type { MonitoringSample } from "../types";
interface Props {
samples: MonitoringSample[];
}
function formatTime(ts: number) {
return new Date(ts * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
type MetricKey =
| "cpu"
| "iowait"
| "mem"
| "netDown"
| "netUp"
| "diskRead"
| "diskWrite";
interface DataPoint {
ts: number;
cpu: number;
iowait: number;
mem: number;
netDown: number;
netUp: number;
diskRead: number;
diskWrite: number;
}
interface MetricConfig {
key: MetricKey;
label: string;
color: string;
}
interface ChartProps {
title: string;
data: DataPoint[];
metrics: MetricConfig[];
showAverages: boolean;
averages: Record<MetricKey, number[]>;
yFormatter?: (v: number) => string;
}
interface BrushProps {
data: DataPoint[];
selectionRange: [number, number] | null;
onBrush: (range: [number, number] | null) => void;
}
const MOVING_AVG_WINDOW = 10;
const CHART_HEIGHT = 280;
const BRUSH_HEIGHT = 84;
const BRUSH_LABEL_HEIGHT = 24;
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B/s";
if (!bytes) return "0 B/s";
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
let value = bytes;
let unitIdx = 0;
@@ -33,147 +75,741 @@ function formatBytes(bytes: number): string {
return `${value.toFixed(1)} ${units[unitIdx]}`;
}
function formatTime(ts: number) {
return new Date(ts * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
function movingAverage(values: number[]): number[] {
if (values.length === 0) return [];
return values.map((_, index) => {
const start = Math.max(0, index - MOVING_AVG_WINDOW + 1);
const slice = values.slice(start, index + 1);
return slice.reduce((sum, value) => sum + value, 0) / slice.length;
});
}
function buildAverages(samples: DataPoint[]): Record<string, number[]> {
return {
cpu: movingAverage(samples.map((sample) => sample.cpu)),
iowait: movingAverage(samples.map((sample) => sample.iowait)),
mem: movingAverage(samples.map((sample) => sample.mem)),
netDown: movingAverage(samples.map((sample) => sample.netDown)),
netUp: movingAverage(samples.map((sample) => sample.netUp)),
diskRead: movingAverage(samples.map((sample) => sample.diskRead)),
diskWrite: movingAverage(samples.map((sample) => sample.diskWrite)),
};
}
function formatRangeLabel(range: [number, number] | null) {
if (!range) return "Full range";
return `${formatTime(range[0])} ${formatTime(range[1])}`;
}
function metricsKey(metrics: MetricConfig[]) {
return metrics.map((m) => `${m.key}:${m.label}:${m.color}`).join("|");
}
// ══════════════════════════════════════════════════════════════════════════
// Shared brush slider (<MonitoringBrush>)
// ══════════════════════════════════════════════════════════════════════════
function MonitoringBrush({ data, selectionRange, onBrush }: BrushProps) {
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const [width, setWidth] = useState(0);
const brushGroupRef = useRef<d3.Selection<
SVGGElement,
unknown,
null,
unknown
> | null>(null);
const brushRef = useRef<d3.BrushBehavior<unknown> | null>(null);
const brushXRef = useRef<d3.ScaleTime<number, number> | null>(null);
const isUserBrushingRef = useRef(false);
const isProgrammaticMoveRef = useRef(false);
const margin = { top: 18, right: 24, bottom: 22, left: 48 };
const innerHeight = BRUSH_HEIGHT - margin.top - margin.bottom;
const brushedColor = "rgba(99, 102, 241, 0.25)";
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) setWidth(entry.contentRect.width);
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
// Build the brush UI when the available data or layout width changes.
useEffect(() => {
if (!svgRef.current || width === 0 || data.length === 0) return;
const innerWidth = Math.max(0, width - margin.left - margin.right);
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const root = svg
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const minTs = d3.min(data, (d) => d.ts) ?? 0;
const maxTs = d3.max(data, (d) => d.ts) ?? 0;
const x = d3
.scaleTime()
.domain([new Date(minTs * 1000), new Date(maxTs * 1000)])
.range([0, innerWidth]);
brushXRef.current = x;
const yMax = Math.max(1, d3.max(data, (d) => Math.max(d.cpu, d.mem)) ?? 1);
const y = d3
.scaleLinear()
.domain([0, yMax * 1.1])
.range([innerHeight, 0])
.nice();
root
.append("g")
.call(d3.axisLeft(y).ticks(3))
.selectAll("text")
.style("font-size", "9px");
root
.append("g")
.attr("transform", `translate(0,${innerHeight})`)
.call(
d3
.axisBottom(x)
.ticks(Math.min(data.length || 1, 12))
.tickFormat((v) => d3.timeFormat("%H:%M")(v as Date)),
)
.selectAll("text")
.style("font-size", "8.5px");
root
.append("g")
.attr("stroke", "currentColor")
.attr("stroke-opacity", 0.08)
.call(
d3
.axisLeft(y)
.ticks(3)
.tickSize(-innerWidth)
.tickFormat(() => ""),
);
const overviewMetrics: Array<{ key: MetricKey; color: string }> = [
{ key: "cpu", color: "#2563eb" },
{ key: "mem", color: "#16a34a" },
];
overviewMetrics.forEach(({ key, color }) => {
const line = d3
.line<DataPoint>()
.x((d) => x(new Date(d.ts * 1000)))
.y((d) => y((d[key] as number) || 0))
.curve(d3.curveMonotoneX);
root
.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", color)
.attr("stroke-width", 1.2)
.attr("opacity", 0.6)
.attr("d", line);
});
const brush = d3
.brushX()
.handleSize(14)
.extent([
[0, 0],
[innerWidth, innerHeight],
])
.on("start", () => {
isUserBrushingRef.current = true;
})
.on("brush", (event: d3.D3BrushEvent<unknown>) => {
if (isProgrammaticMoveRef.current) return;
if (!event.selection) return;
const sel = event.selection as [number, number];
const start = Math.floor(x.invert(sel[0]).getTime() / 1000);
const end = Math.floor(x.invert(sel[1]).getTime() / 1000);
onBrush([start, end]);
})
.on("end", (event: d3.D3BrushEvent<unknown>) => {
isUserBrushingRef.current = false;
if (isProgrammaticMoveRef.current) return;
if (!event.selection) onBrush(null);
});
const brushG = root.append("g").call(brush);
brushGroupRef.current = brushG;
brushRef.current = brush;
brushG
.selectAll("rect.selection")
.attr("fill", brushedColor)
.attr("stroke", "#6366f1")
.attr("stroke-width", 1);
brushG
.selectAll("rect.handle")
.attr("fill", "#6366f1")
.attr("stroke", "#fff")
.attr("rx", 2)
.attr("ry", 2)
.style("cursor", "ew-resize");
}, [data, width, margin.left, margin.top, innerHeight, onBrush]);
// Keep the brush selection in sync with external changes (zoom buttons / reset)
useEffect(() => {
if (!brushGroupRef.current || !brushRef.current || !brushXRef.current)
return;
if (isUserBrushingRef.current) return;
const x = brushXRef.current;
const brush = brushRef.current;
const brushG = brushGroupRef.current;
const selection = selectionRange
? ([x(selectionRange[0]), x(selectionRange[1])] as [number, number])
: (x.range() as unknown as [number, number]);
isProgrammaticMoveRef.current = true;
const moveBrush = brush.move as unknown as (
group: d3.Selection<SVGGElement, unknown, null, unknown>,
selection: d3.BrushSelection,
) => void;
moveBrush(brushG, selection as d3.BrushSelection);
window.setTimeout(() => {
isProgrammaticMoveRef.current = false;
}, 0);
}, [selectionRange, width, margin.left, margin.right]);
const totalHeight = BRUSH_LABEL_HEIGHT + BRUSH_HEIGHT;
return (
<Box sx={{ width: "100%" }}>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mb: 0.5, pl: 0.5 }}
>
Time range drag the left/right ends or the middle
</Typography>
<Box
ref={containerRef}
sx={{ width: "100%", height: totalHeight, px: 2 }}
>
<svg
ref={svgRef}
width={width}
height={totalHeight}
style={{ overflow: "visible" }}
/>
</Box>
</Box>
);
}
// ══════════════════════════════════════════════════════════════════════════
// Parent: MonitoringCharts
// ══════════════════════════════════════════════════════════════════════════
export function MonitoringCharts({ samples }: Props) {
if (samples.length === 0) {
const [showAverages, setShowAverages] = useState(false);
const [selectionRange, setSelectionRange] = useState<[number, number] | null>(
null,
);
const baseData = useMemo<DataPoint[]>(
() =>
samples.map((sample) => ({
ts: sample.ts,
cpu: sample.cpu_pct,
iowait: sample.iowait_pct ?? 0,
mem: sample.mem_pct,
netDown: sample.net_rx_bytes_per_sec,
netUp: sample.net_tx_bytes_per_sec,
diskRead: sample.disk_read_bps,
diskWrite: sample.disk_write_bps,
})),
[samples],
);
const averages = useMemo(() => buildAverages(baseData), [baseData]);
const displayData = useMemo(() => {
if (!selectionRange) return baseData;
const [start, end] = selectionRange;
return baseData.filter((sample) => sample.ts >= start && sample.ts <= end);
}, [baseData, selectionRange]);
const zoomOptions = useMemo(
() => [
{ label: "1h", seconds: 60 * 60 },
{ label: "8h", seconds: 8 * 60 * 60 },
{ label: "1 day", seconds: 24 * 60 * 60 },
{ label: "7 days", seconds: 7 * 24 * 60 * 60 },
],
[],
);
const zoomTo = useCallback(
(seconds: number) => {
if (!baseData.length) return;
const start = Math.max(
baseData[0].ts,
baseData[baseData.length - 1].ts - seconds,
);
setSelectionRange([start, baseData[baseData.length - 1].ts]);
},
[baseData],
);
const selectionLabel = useMemo(
() =>
selectionRange
? `${formatRangeLabel(selectionRange)} · ${displayData.length} samples`
: `All ${baseData.length} samples`,
[selectionRange, displayData.length, baseData.length],
);
if (!samples.length) {
return (
<p className="text-sm text-gray-500">No monitoring samples available.</p>
<Typography color="text.secondary">
No monitoring samples available.
</Typography>
);
}
const data = samples.map((s) => ({
time: formatTime(s.ts),
ts: s.ts,
cpu: s.cpu_pct,
iowait: s.iowait_pct ?? 0,
mem: s.mem_pct,
net_down: s.net_rx_bytes_per_sec,
net_up: s.net_tx_bytes_per_sec,
disk_read: s.disk_read_bps,
disk_write: s.disk_write_bps,
}));
return (
<div className="space-y-8">
<div>
<h3 className="text-sm font-semibold mb-2">
CPU, IO Wait, and RAM - last hour
</h3>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis unit="%" domain={[0, 100]} />
<Tooltip />
<Legend />
<Line
type="monotone"
dataKey="cpu"
name="CPU %"
stroke="#2563eb"
dot={false}
strokeWidth={1.5}
<Box sx={{ width: "100%" }}>
{/* Toolbar */}
<Box
sx={{
mb: 2,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
}}
>
<FormControlLabel
control={
<Checkbox
checked={showAverages}
onChange={(event) => setShowAverages(event.target.checked)}
/>
<Line
type="monotone"
dataKey="iowait"
name="IO Wait %"
stroke="#dc2626"
dot={false}
strokeWidth={1.5}
/>
<Line
type="monotone"
dataKey="mem"
name="RAM %"
stroke="#16a34a"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
}
label={`Show ${MOVING_AVG_WINDOW}-point moving average`}
/>
<Box
sx={{
display: "flex",
gap: 1,
alignItems: "center",
flexWrap: "wrap",
}}
>
<Chip size="small" label={selectionLabel} variant="outlined" />
{zoomOptions.map((option) => (
<Button
key={option.label}
variant="outlined"
size="small"
onClick={() => zoomTo(option.seconds)}
>
{option.label}
</Button>
))}
<Button
variant="outlined"
size="small"
disabled={!selectionRange}
onClick={() => setSelectionRange(null)}
>
Reset zoom
</Button>
</Box>
</Box>
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className="text-sm font-semibold mb-2">Network download</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => formatBytes(v)} />
<Tooltip formatter={(v) => formatBytes(Number(v))} />
<Line
type="monotone"
dataKey="net_down"
name="Download"
stroke="#2563eb"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Network upload</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => formatBytes(v)} />
<Tooltip formatter={(v) => formatBytes(Number(v))} />
<Line
type="monotone"
dataKey="net_up"
name="Upload"
stroke="#9333ea"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* Shared brush slider above all graphs */}
<MonitoringBrush
data={baseData}
selectionRange={selectionRange}
onBrush={setSelectionRange}
/>
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className="text-sm font-semibold mb-2">Disk read</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => formatBytes(v)} />
<Tooltip formatter={(v) => formatBytes(Number(v))} />
<Line
type="monotone"
dataKey="disk_read"
name="Read"
stroke="#ea580c"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div>
<h3 className="text-sm font-semibold mb-2">Disk write</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => formatBytes(v)} />
<Tooltip formatter={(v) => formatBytes(Number(v))} />
<Line
type="monotone"
dataKey="disk_write"
name="Write"
stroke="#0891b2"
dot={false}
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Chart grid */}
<Grid container spacing={3}>
<Grid size={12}>
<MonitoringD3Chart
title="CPU, IO Wait, and RAM"
data={displayData}
metrics={[
{ key: "cpu", label: "CPU %", color: "#2563eb" },
{ key: "iowait", label: "IO Wait %", color: "#dc2626" },
{ key: "mem", label: "RAM %", color: "#16a34a" },
]}
showAverages={showAverages}
averages={averages}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<MonitoringD3Chart
title="Network traffic"
data={displayData}
metrics={[
{ key: "netDown", label: "Download", color: "#2563eb" },
{ key: "netUp", label: "Upload", color: "#9333ea" },
]}
showAverages={showAverages}
averages={averages}
yFormatter={formatBytes}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<MonitoringD3Chart
title="Disk I/O"
data={displayData}
metrics={[
{ key: "diskRead", label: "Read", color: "#ea580c" },
{ key: "diskWrite", label: "Write", color: "#0891b2" },
]}
showAverages={showAverages}
averages={averages}
yFormatter={formatBytes}
/>
</Grid>
</Grid>
</Box>
);
}
// ══════════════════════════════════════════════════════════════════════════
// MonitoringD3Chart single chart (lines + hover, no brush)
// ══════════════════════════════════════════════════════════════════════════
function MonitoringD3Chart({
title,
data,
metrics,
showAverages,
averages,
yFormatter,
}: ChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const [width, setWidth] = useState(0);
const mk = metricsKey(metrics);
const margin = useMemo(
() => ({ top: 18, right: 24, bottom: 26, left: 56 }),
[],
);
const innerHeight = CHART_HEIGHT - margin.top - margin.bottom;
const summary = useMemo(() => {
const values = metrics.flatMap((metric) =>
data.map((sample) => (sample[metric.key] as number) || 0),
);
const avg = values.length
? values.reduce((sum, value) => sum + value, 0) / values.length
: 0;
return { min: d3.min(values) ?? 0, avg, max: d3.max(values) ?? 0 };
}, [data, metrics]);
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) setWidth(entry.contentRect.width);
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
// One effect rebuild chart layer only
useEffect(() => {
if (!svgRef.current || width === 0) return;
const innerWidth = Math.max(0, width - margin.left - margin.right);
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const root = svg
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
if (data.length === 0) return;
const x = d3
.scaleTime()
.domain(d3.extent(data, (d) => new Date(d.ts * 1000)) as [Date, Date])
.range([0, innerWidth]);
const yMax =
d3.max(data, (d) =>
Math.max(...metrics.map((m) => (d[m.key] as number) || 0)),
) ?? 1;
const y = d3
.scaleLinear()
.domain([0, yMax * 1.1])
.nice()
.range([innerHeight, 0]);
// X axis
root
.append("g")
.attr("transform", `translate(0,${innerHeight})`)
.call(
d3
.axisBottom(x)
.ticks(Math.min(data.length || 1, 10))
.tickFormat((v) => d3.timeFormat("%H:%M")(v as Date)),
)
.selectAll("text")
.style("font-size", "10px");
// Y axis
const yAxis = d3.axisLeft(y).ticks(5);
if (yFormatter) yAxis.tickFormat((v) => yFormatter(Number(v)));
root.append("g").call(yAxis).selectAll("text").style("font-size", "10px");
// Grid
root
.append("g")
.attr("stroke", "currentColor")
.attr("stroke-opacity", 0.1)
.call(
d3
.axisLeft(y)
.ticks(5)
.tickSize(-innerWidth)
.tickFormat(() => ""),
);
// ── Lines ───────────────────────────────────────────
metrics.forEach((metric) => {
const line = d3
.line<DataPoint>()
.x((d) => x(new Date(d.ts * 1000)))
.y((d) => y((d[metric.key] as number) || 0))
.curve(d3.curveMonotoneX);
root
.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", metric.color)
.attr("stroke-width", 1.6)
.attr("d", line);
if (showAverages && averages?.[metric.key]) {
const avgLine = d3
.line<DataPoint>()
.x((d) => x(new Date(d.ts * 1000)))
.y((_, i) =>
y(averages[metric.key as keyof typeof averages]?.[i] || 0),
)
.curve(d3.curveMonotoneX);
root
.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", metric.color)
.attr("stroke-width", 1.4)
.attr("stroke-dasharray", "5,3")
.attr("opacity", 0.7)
.attr("d", avgLine);
}
});
// ── Cursor line ─────────────────────────────────────
const cursorLine = root
.append("line")
.attr("y1", 0)
.attr("y2", innerHeight)
.attr("stroke", "currentColor")
.attr("stroke-opacity", 0.45)
.attr("stroke-dasharray", "4,4")
.style("display", "none");
// ── Cursor markers ──────────────────────────────────
const cursorMarkers = root
.append("g")
.attr("pointer-events", "none")
.style("display", "none");
cursorMarkers
.selectAll<SVGCircleElement, MetricConfig>("circle")
.data(metrics)
.join("circle")
.attr("r", 4.5)
.attr("stroke", "#fff")
.attr("stroke-width", 1.4);
// ── Tooltip ─────────────────────────────────────────
const tooltip = root
.append("g")
.attr("pointer-events", "none")
.style("display", "none");
tooltip
.append("rect")
.attr("rx", 6)
.attr("ry", 6)
.attr("fill", "rgba(15,23,42,0.92)");
const tooltipText = tooltip
.append("text")
.attr("fill", "#fff")
.attr("font-size", 11)
.attr("font-family", "monospace");
// ── Hit area ────────────────────────────────────────
const bisect = d3.bisector((d: DataPoint) => d.ts).center;
root
.append("rect")
.attr("width", innerWidth)
.attr("height", innerHeight)
.attr("fill", "transparent")
.attr("pointer-events", "all")
.on("mousemove", (event) => {
const [mx, my] = d3.pointer(event, root.node() as SVGGElement);
const ts = x.invert(mx).getTime() / 1000;
const idx = bisect(data, ts);
const sample = data[Math.max(0, Math.min(data.length - 1, idx))];
if (!sample) return;
const xP = x(new Date(sample.ts * 1000));
cursorLine.style("display", null).attr("x1", xP).attr("x2", xP);
cursorMarkers
.style("display", null)
.attr("transform", `translate(${xP},0)`)
.selectAll<SVGCircleElement, MetricConfig>("circle")
.data(metrics)
.attr("cx", 0)
.attr("cy", (m) => y((sample[m.key] as number) || 0))
.attr("fill", (m) => m.color);
const lines = [
formatTime(sample.ts),
...metrics.map((m) => {
const raw = (sample[m.key] as number) || 0;
const avgV =
showAverages &&
averages?.[m.key as keyof typeof averages]?.[idx] != null
? averages[m.key as keyof typeof averages][idx]
: null;
const fmt = yFormatter ? yFormatter(raw) : `${raw.toFixed(1)}%`;
return avgV == null
? `${m.label}: ${fmt}`
: `${m.label}: ${fmt} (avg ${yFormatter ? yFormatter(avgV) : avgV.toFixed(1)})`;
}),
];
const lh = 14,
pad = 8;
const bw = Math.min(
Math.max(...lines.map((l) => l.length)) * 6.5 + pad * 2,
260,
);
const bh = lines.length * lh + pad * 2;
const px = Math.min(mx + 12, innerWidth - bw - 4);
const py = Math.max(4, Math.min(my - bh - 12, innerHeight - bh - 4));
tooltip
.style("display", null)
.attr("transform", `translate(${px},${py})`);
tooltip.select("rect").attr("width", bw).attr("height", bh);
tooltipText.selectAll("tspan").remove();
lines.forEach((line, i) =>
tooltipText
.append("tspan")
.attr("x", pad)
.attr("y", pad + 12 + i * lh)
.text(line),
);
})
.on("mouseleave", () => {
tooltip.style("display", "none");
cursorLine.style("display", "none");
cursorMarkers.style("display", "none");
});
}, [
data,
mk,
showAverages,
averages,
width,
yFormatter,
margin.left,
margin.top,
innerHeight,
]);
// ── JSX ───────────────────────────────────────────────
return (
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" gutterBottom>
{title}
</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Chip
size="small"
label={`Min ${yFormatter ? yFormatter(summary.min) : summary.min.toFixed(1)}`}
variant="outlined"
/>
<Chip
size="small"
label={`Avg ${yFormatter ? yFormatter(summary.avg) : summary.avg.toFixed(1)}`}
variant="outlined"
/>
<Chip
size="small"
label={`Max ${yFormatter ? yFormatter(summary.max) : summary.max.toFixed(1)}`}
variant="outlined"
/>
</Box>
<Box ref={containerRef} sx={{ width: "100%", height: CHART_HEIGHT }}>
<svg
ref={svgRef}
width={width}
height={CHART_HEIGHT}
style={{ overflow: "visible" }}
/>
</Box>
<Box sx={{ mt: 1, display: "flex", gap: 2, flexWrap: "wrap" }}>
{metrics.map((metric) => (
<Box
key={metric.key}
sx={{ display: "flex", alignItems: "center", gap: 0.5 }}
>
<Box
sx={{
width: 12,
height: 12,
bgcolor: metric.color,
borderRadius: "2px",
}}
/>
<Typography variant="caption">{metric.label}</Typography>
</Box>
))}
{showAverages ? (
<Typography variant="caption">Dashed = moving average</Typography>
) : null}
</Box>
</CardContent>
</Card>
);
}
+13 -37
View File
@@ -1,46 +1,22 @@
import { Card, CardContent } from "@mui/material";
import type { NowPlayingSession } from "../types";
import { SessionActivityPanel } from "./SessionActivityPanel";
interface Props {
sessions: NowPlayingSession[];
onSelectSession?: (session: NowPlayingSession) => void;
}
export function NowPlaying({ sessions }: Props) {
if (sessions.length === 0) {
return (
<p className="text-sm text-gray-500">
No active playback sessions right now.
</p>
);
}
export function NowPlaying({ sessions, onSelectSession }: Props) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b text-left text-gray-600">
<th className="py-2 pr-4">User</th>
<th className="py-2 pr-4">Title</th>
<th className="py-2 pr-4">Type</th>
<th className="py-2 pr-4">State</th>
<th className="py-2 pr-4">Transcoding</th>
<th className="py-2 pr-4">Transcode type</th>
<th className="py-2 pr-4">Device</th>
</tr>
</thead>
<tbody>
{sessions.map((s) => (
<tr key={s.session_id} className="border-b hover:bg-gray-50">
<td className="py-2 pr-4 font-medium">{s.user}</td>
<td className="py-2 pr-4">{s.title}</td>
<td className="py-2 pr-4">{s.type}</td>
<td className="py-2 pr-4">{s.state}</td>
<td className="py-2 pr-4">{s.transcoding}</td>
<td className="py-2 pr-4">{s.transcoding_type}</td>
<td className="py-2 pr-4">{s.device}</td>
</tr>
))}
</tbody>
</table>
</div>
<Card variant="outlined">
<CardContent>
<SessionActivityPanel
sessions={sessions}
onSelectSession={onSelectSession}
emptyMessage="No recent user activity sessions right now."
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,248 @@
import {
Button,
Chip,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import type { NowPlayingSession } from "../types";
interface Props {
sessions: NowPlayingSession[];
emptyMessage?: string;
selectedUserLabel?: string;
onSelectSession?: (session: NowPlayingSession) => void;
}
function formatStateLabel(state: string): string {
const normalized = String(state || "")
.trim()
.toLowerCase();
if (normalized === "playing") {
return "Playing";
}
if (normalized === "paused") {
return "Paused";
}
if (normalized === "idle") {
return "Idle";
}
return normalized
? normalized.charAt(0).toUpperCase() + normalized.slice(1)
: "Unknown";
}
function buildStatusSummary(sessions: NowPlayingSession[]) {
const playing = sessions.filter(
(session) =>
String(session.state || "")
.trim()
.toLowerCase() === "playing",
).length;
const paused = sessions.filter(
(session) =>
String(session.state || "")
.trim()
.toLowerCase() === "paused",
).length;
const idle = sessions.filter(
(session) =>
String(session.state || "")
.trim()
.toLowerCase() === "idle",
).length;
return `${sessions.length} session${sessions.length === 1 ? "" : "s"} · ${playing} playing · ${paused} paused · ${idle} idle`;
}
export function SessionActivityPanel({
sessions,
emptyMessage = "No live sessions matched to this user.",
selectedUserLabel,
onSelectSession,
}: Props) {
const userFallback = selectedUserLabel || "Unknown user";
if (!sessions.length) {
return (
<Typography variant="body2" color="text.secondary">
{emptyMessage}
</Typography>
);
}
return (
<TableContainer
component={Paper}
variant="outlined"
sx={{ maxHeight: 280, borderColor: "divider", borderRadius: 1 }}
>
<Table size="small" stickyHeader aria-label="Session activity details">
<TableHead>
<TableRow>
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
minWidth: 160,
}}
>
User
</TableCell>
<TableCell
sx={{ fontWeight: 700, bgcolor: "background.default", width: 82 }}
>
State
</TableCell>
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
minWidth: 140,
}}
>
Title / Type
</TableCell>
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
minWidth: 140,
}}
>
Device
</TableCell>
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
width: 118,
}}
>
Transcoding
</TableCell>
{onSelectSession ? (
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
width: 150,
}}
>
Action
</TableCell>
) : null}
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell
colSpan={onSelectSession ? 6 : 5}
sx={{ py: 0.75, bgcolor: "background.paper" }}
>
<Typography variant="caption" color="text.secondary">
{buildStatusSummary(sessions)}
</Typography>
</TableCell>
</TableRow>
{sessions.map((session) => {
const state = String(session.state || "")
.trim()
.toLowerCase();
const sessionLabel = formatStateLabel(session.state);
return (
<TableRow
key={session.session_id}
hover
sx={{ cursor: onSelectSession ? "pointer" : "default" }}
onClick={
onSelectSession ? () => onSelectSession(session) : undefined
}
>
<TableCell sx={{ py: 0.75, minWidth: 160 }}>
<Typography
variant="body2"
noWrap
title={session.user || userFallback}
>
{session.user || userFallback}
</Typography>
<Typography
variant="caption"
color="text.secondary"
noWrap
title={session.session_id}
>
{session.session_id}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
<Chip
size="small"
label={sessionLabel}
color={
state === "playing"
? "primary"
: state === "paused"
? "warning"
: "default"
}
variant={
state === "playing" || state === "paused"
? "filled"
: "outlined"
}
/>
</TableCell>
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
<Typography
variant="body2"
noWrap
title={session.title || ""}
>
{session.title || "(idle)"}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
{session.type || "—"}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
<Typography variant="body2" noWrap>
{session.device || "Unknown device"}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
<Typography variant="body2" noWrap>
{session.transcoding === "yes"
? session.transcoding_type
? `yes (${session.transcoding_type})`
: "yes"
: "no"}
</Typography>
</TableCell>
{onSelectSession ? (
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
<Button
size="small"
variant="outlined"
onClick={(event) => {
event.stopPropagation();
onSelectSession(session);
}}
>
Open in Users
</Button>
</TableCell>
) : null}
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);
}