Phase 2: Docker and OIDC auth
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user