Add missing frontend and backend files

This commit is contained in:
2026-05-06 23:46:08 +02:00
parent b789034bbe
commit 016e3255f5
20 changed files with 5226 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
import type { ReactNode } from "react";
import { Box, Button, DialogActions } from "@mui/material";
interface DialogFooterProps {
onCancel: () => void;
cancelLabel?: string;
onConfirm: () => void;
confirmLabel: string;
confirmBusyLabel?: string;
confirmDisabled?: boolean;
confirmColor?: "primary" | "error" | "warning" | "success" | "inherit";
confirmVariant?: "contained" | "outlined" | "text";
confirmStartIcon?: ReactNode;
secondaryAction?: ReactNode;
}
export function DialogFooter({
onCancel,
cancelLabel = "Cancel",
onConfirm,
confirmLabel,
confirmBusyLabel,
confirmDisabled,
confirmColor = "primary",
confirmVariant = "contained",
confirmStartIcon,
secondaryAction,
}: DialogFooterProps) {
return (
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onCancel}>{cancelLabel}</Button>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{secondaryAction}
<Button
variant={confirmVariant}
color={confirmColor}
disabled={confirmDisabled}
startIcon={confirmStartIcon}
onClick={onConfirm}
>
{confirmBusyLabel ?? confirmLabel}
</Button>
</Box>
</DialogActions>
);
}
@@ -0,0 +1,32 @@
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import { IconButton } from "@mui/material";
interface HoverEditButtonProps {
onClick: () => void;
label?: string;
}
export function HoverEditButton({
onClick,
label = "Edit",
}: HoverEditButtonProps) {
return (
<IconButton
className="rail-edit"
aria-label={label}
size="small"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
sx={{
opacity: 0,
transition: "opacity 120ms ease",
color: "text.secondary",
}}
>
<EditOutlinedIcon fontSize="inherit" />
</IconButton>
);
}
@@ -0,0 +1,354 @@
import { useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Chip,
FormControl,
Grid,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import { MetricCard } from "./MetricCard";
import { MonitoringCharts } from "./MonitoringCharts";
import type { MonitoringMachine } from "../types";
import {
useCollectorControls,
useDiskSpace,
useMachineActions,
useMonitoringMetrics,
useMonitoringStatus,
} from "../hooks/useMonitoring";
function formatBytes(bytes: number): string {
if (!bytes || bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIdx = 0;
while (value >= 1000 && unitIdx < units.length - 1) {
value /= 1000;
unitIdx++;
}
return `${value.toFixed(1)} ${units[unitIdx]}`;
}
function formatRate(bytes: number): string {
return `${formatBytes(bytes)}/s`;
}
function avg(arr: number[]) {
return arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
}
function max(arr: number[]) {
return arr.length ? Math.max(...arr) : 0;
}
function formatActionTime(epochSeconds: number): string {
return new Date(epochSeconds * 1000).toLocaleString();
}
function ErrorBanner({ label, error }: { label: string; error: unknown }) {
if (!error) return null;
return (
<Alert severity="error">
{label}: {String(error)}
</Alert>
);
}
export function MachineMonitoringSection({
machine,
}: {
machine: MonitoringMachine;
}) {
const statusQuery = useMonitoringStatus(machine.id, machine.enabled);
const metricsQuery = useMonitoringMetrics(machine.id, machine.enabled);
const diskQuery = useDiskSpace(machine.id, machine.enabled);
const { start, stop, restart } = useCollectorControls(machine.id);
const actionsQuery = useMachineActions(machine.id, machine.enabled);
const [actionFilter, setActionFilter] = useState("all");
const [resultFilter, setResultFilter] = useState("all");
const status = statusQuery.data;
const metrics = metricsQuery.data;
const disk = diskQuery.data;
const samples = metrics?.samples ?? [];
const latest = samples.at(-1);
const cpuArr = samples.map((s) => s.cpu_pct);
const iowArr = samples.map((s) => s.iowait_pct ?? 0);
const memArr = samples.map((s) => s.mem_pct);
const netDownArr = samples.map((s) => s.net_rx_bytes_per_sec);
const netUpArr = samples.map((s) => s.net_tx_bytes_per_sec);
const diskReadArr = samples.map((s) => s.disk_read_bps);
const diskWriteArr = samples.map((s) => s.disk_write_bps);
const actions = actionsQuery.data?.items ?? [];
const visibleActions = useMemo(
() =>
actions.filter((action) => {
const actionMatches =
actionFilter === "all" || action.action === actionFilter;
const resultMatches =
resultFilter === "all" || action.status === resultFilter;
return actionMatches && resultMatches;
}),
[actions, actionFilter, resultFilter],
);
const hasQueryError =
statusQuery.error ||
metricsQuery.error ||
diskQuery.error ||
actionsQuery.error;
return (
<Stack spacing={2}>
<Stack
direction="row"
spacing={1.5}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Box>
<Typography variant="h6" sx={{ fontWeight: 700 }}>
{machine.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{machine.mode === "local"
? "Local API host"
: `${machine.username || "user"}@${machine.host || "host"}:${machine.port}`}
{machine.media_root ? ` · media root ${machine.media_root}` : ""}
</Typography>
</Box>
<Chip label={machine.mode} variant="outlined" />
<Chip
label={machine.enabled ? "Enabled" : "Disabled"}
color={machine.enabled ? "success" : "default"}
variant="outlined"
/>
<Chip
label={status?.status ?? "unknown"}
color={status?.status?.includes("running") ? "success" : "primary"}
variant="outlined"
/>
<Button
size="small"
variant="outlined"
onClick={() => start.mutate()}
disabled={start.isPending || !machine.enabled}
>
Start
</Button>
<Button
size="small"
variant="outlined"
onClick={() => restart.mutate()}
disabled={restart.isPending || !machine.enabled}
>
Restart
</Button>
<Button
size="small"
variant="outlined"
onClick={() => stop.mutate()}
disabled={stop.isPending || !machine.enabled}
>
Stop
</Button>
</Stack>
{hasQueryError && (
<Stack spacing={1}>
<ErrorBanner label="Status" error={statusQuery.error} />
<ErrorBanner label="Metrics" error={metricsQuery.error} />
<ErrorBanner label="Disk" error={diskQuery.error} />
<ErrorBanner label="Recent activity" error={actionsQuery.error} />
</Stack>
)}
<Grid container spacing={1.5}>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="CPU now"
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
subtext={`avg ${avg(cpuArr).toFixed(1)}%\npeak ${max(cpuArr).toFixed(1)}%`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="IO Wait"
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
subtext={`avg ${avg(iowArr).toFixed(1)}%\npeak ${max(iowArr).toFixed(1)}%`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="RAM now"
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
subtext={`avg ${avg(memArr).toFixed(1)}%\npeak ${max(memArr).toFixed(1)}%`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Net down"
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
subtext={`avg ${formatRate(avg(netDownArr))}\npeak ${formatRate(max(netDownArr))}`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Net up"
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
subtext={`avg ${formatRate(avg(netUpArr))}\npeak ${formatRate(max(netUpArr))}`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Disk read"
value={latest ? formatRate(latest.disk_read_bps) : "-"}
subtext={`avg ${formatRate(avg(diskReadArr))}\npeak ${formatRate(max(diskReadArr))}`}
/>
</Grid>
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
<MetricCard
label="Disk write"
value={latest ? formatRate(latest.disk_write_bps) : "-"}
subtext={`avg ${formatRate(avg(diskWriteArr))}\npeak ${formatRate(max(diskWriteArr))}`}
/>
</Grid>
</Grid>
{disk && (
<Grid container spacing={1.5}>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard
label="Disk available"
value={formatBytes(disk.available)}
/>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<MetricCard label="Used %" value={disk.used_pct} />
</Grid>
</Grid>
)}
<Box>
<MonitoringCharts samples={samples} />
</Box>
<Stack spacing={1.5}>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Recent activity
</Typography>
<Typography variant="caption" color="text.secondary">
Collected automatically by the backend poller.
</Typography>
</Box>
<Chip
label={`${visibleActions.length}/${actions.length || 0}`}
size="small"
variant="outlined"
/>
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>Action</InputLabel>
<Select
label="Action"
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value as string)}
>
<MenuItem value="all">All actions</MenuItem>
<MenuItem value="status lookup">Status</MenuItem>
<MenuItem value="metrics read">Metrics</MenuItem>
<MenuItem value="disk lookup">Disk</MenuItem>
<MenuItem value="collector start">Start</MenuItem>
<MenuItem value="collector stop">Stop</MenuItem>
<MenuItem value="collector restart">Restart</MenuItem>
<MenuItem value="collector diagnostics">Diagnostics</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>Result</InputLabel>
<Select
label="Result"
value={resultFilter}
onChange={(e) => setResultFilter(e.target.value as string)}
>
<MenuItem value="all">All results</MenuItem>
<MenuItem value="ok">OK</MenuItem>
<MenuItem value="error">Error</MenuItem>
</Select>
</FormControl>
</Stack>
{actionsQuery.error && (
<Alert severity="error">
Recent activity: {String(actionsQuery.error)}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Time</TableCell>
<TableCell>Action</TableCell>
<TableCell>Status</TableCell>
<TableCell align="right">Duration</TableCell>
<TableCell>Message</TableCell>
</TableRow>
</TableHead>
<TableBody>
{visibleActions.length === 0 ? (
<TableRow>
<TableCell colSpan={5}>
<Typography variant="body2" color="text.secondary">
No activity matches the current filters.
</Typography>
</TableCell>
</TableRow>
) : (
visibleActions.map((action) => (
<TableRow
key={`${action.machine_id}-${action.created_at}-${action.action}-${action.status}`}
>
<TableCell>{formatActionTime(action.created_at)}</TableCell>
<TableCell>{action.action}</TableCell>
<TableCell>
<Chip
size="small"
label={action.status}
color={action.status === "ok" ? "success" : "error"}
variant="outlined"
/>
</TableCell>
<TableCell align="right">{action.duration_ms} ms</TableCell>
<TableCell>
{action.message || action.error || "-"}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Stack>
</Stack>
);
}
@@ -0,0 +1,517 @@
import { useMemo, useState } from "react";
import {
Box,
Chip,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TableSortLabel,
Typography,
} from "@mui/material";
import type {
MonitoringMachineOverview,
MonitoringOverviewResponse,
} from "../types";
type SortKey =
| "machine"
| "mode"
| "status"
| "cpu"
| "iowait"
| "mem"
| "net_rx"
| "net_tx"
| "disk_read"
| "disk_write"
| "disk_used"
| "updated";
function formatBytes(bytes: number): string {
if (!bytes || bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIdx = 0;
while (value >= 1000 && unitIdx < units.length - 1) {
value /= 1000;
unitIdx++;
}
return `${value.toFixed(1)} ${units[unitIdx]}`;
}
function formatRate(bytes: number): string {
return `${formatBytes(bytes)}/s`;
}
function formatTime(epochSeconds: number | null): string {
if (!epochSeconds) return "-";
return new Date(epochSeconds * 1000).toLocaleString();
}
function formatAge(epochSeconds: number | null): string {
if (!epochSeconds) return "-";
const diff = Date.now() / 1000 - epochSeconds;
if (diff < 60) return `${Math.max(0, Math.round(diff))}s ago`;
if (diff < 3600) return `${Math.round(diff / 60)}m ago`;
return `${Math.round(diff / 3600)}h ago`;
}
function formatSummary(
summary: { avg: number; min: number; max: number } | null,
formatter: (value: number) => string,
) {
if (!summary) return { value: "-", subtext: "" };
return {
value: formatter(summary.avg),
subtext: `Low ${formatter(summary.min)}\nHigh ${formatter(summary.max)}`,
};
}
function sortableText(value: string) {
return value.toLowerCase();
}
function metricSortValue(
row: MonitoringMachineOverview,
key: SortKey,
): number | string {
switch (key) {
case "machine":
return sortableText(row.machine.name);
case "mode":
return row.machine.mode;
case "status":
return sortableText(row.status || row.status_error || "");
case "cpu":
return row.cpu_summary?.avg ?? -1;
case "iowait":
return row.iowait_summary?.avg ?? -1;
case "mem":
return row.mem_summary?.avg ?? -1;
case "net_rx":
return row.net_rx_summary?.avg ?? -1;
case "net_tx":
return row.net_tx_summary?.avg ?? -1;
case "disk_read":
return row.disk_read_summary?.avg ?? -1;
case "disk_write":
return row.disk_write_summary?.avg ?? -1;
case "disk_used":
return parseFloat((row.disk?.used_pct || "0").replace("%", "")) || -1;
case "updated":
return row.latest_sample?.ts ?? -1;
default:
return 0;
}
}
function metricCell(value: string, subtext?: string) {
return (
<Stack
spacing={0.1}
sx={{
alignItems: "center",
justifyContent: "center",
width: "100%",
textAlign: "center",
}}
>
<Typography
variant="body2"
sx={{
fontWeight: 700,
fontSize: "0.95rem",
lineHeight: 1.1,
}}
>
{value}
</Typography>
{subtext ? (
<Typography
variant="caption"
color="text.secondary"
sx={{
whiteSpace: "pre-line",
lineHeight: 1.0,
fontSize: "0.64rem",
opacity: 0.9,
}}
>
{subtext}
</Typography>
) : null}
</Stack>
);
}
export function MonitoringOverviewTable({
overview,
embedded = false,
}: {
overview?: MonitoringOverviewResponse;
embedded?: boolean;
}) {
const poller = overview?.poller;
const rows = overview?.machines ?? [];
const [sortKey, setSortKey] = useState<SortKey>("machine");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const sortedRows = useMemo(() => {
const factor = sortDirection === "asc" ? 1 : -1;
return [...rows].sort((a, b) => {
const av = metricSortValue(a, sortKey);
const bv = metricSortValue(b, sortKey);
if (typeof av === "number" && typeof bv === "number") {
return (av - bv) * factor;
}
return String(av).localeCompare(String(bv)) * factor;
});
}, [rows, sortDirection, sortKey]);
const setSort = (key: SortKey) => {
if (sortKey === key) {
setSortDirection((current) => (current === "asc" ? "desc" : "asc"));
return;
}
setSortKey(key);
setSortDirection("asc");
};
return (
<Stack spacing={1.25}>
{!embedded ? (
<Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Machine monitoring
</Typography>
<Chip
size="small"
variant="outlined"
color={poller?.worker_running ? "success" : "default"}
label={
poller?.worker_running
? `Poller running · ${poller.interval_seconds}s`
: "Poller stopped"
}
/>
<Chip
size="small"
variant="outlined"
label={`Machines: ${overview?.enabled ?? 0}/${overview?.total ?? 0}`}
/>
<Typography variant="caption" color="text.secondary">
Last success: {formatTime(poller?.last_success_at ?? null)} · last
run: {formatAge(poller?.last_run_at ?? null)}
</Typography>
</Stack>
) : null}
{!embedded && poller?.last_error ? (
<Box>
<Typography variant="caption" color="error.main">
Poller error: {poller.last_error}
</Typography>
</Box>
) : null}
<TableContainer
component={embedded ? Box : Paper}
variant={embedded ? undefined : "outlined"}
sx={
embedded
? {
border: 1,
borderColor: "divider",
borderRadius: 1,
overflow: "hidden",
}
: undefined
}
>
<Table size="small">
<TableHead>
<TableRow>
<TableCell
sortDirection={sortKey === "machine" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "machine"}
direction={sortKey === "machine" ? sortDirection : "asc"}
onClick={() => setSort("machine")}
>
Machine
</TableSortLabel>
</TableCell>
<TableCell
sortDirection={sortKey === "mode" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "mode"}
direction={sortKey === "mode" ? sortDirection : "asc"}
onClick={() => setSort("mode")}
>
Mode
</TableSortLabel>
</TableCell>
<TableCell
sortDirection={sortKey === "status" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "status"}
direction={sortKey === "status" ? sortDirection : "asc"}
onClick={() => setSort("status")}
>
Status
</TableSortLabel>
</TableCell>
<TableCell
align="right"
sortDirection={sortKey === "cpu" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "cpu"}
direction={sortKey === "cpu" ? sortDirection : "asc"}
onClick={() => setSort("cpu")}
>
CPU
</TableSortLabel>
</TableCell>
<TableCell
align="right"
sortDirection={sortKey === "iowait" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "iowait"}
direction={sortKey === "iowait" ? sortDirection : "asc"}
onClick={() => setSort("iowait")}
>
IO wait
</TableSortLabel>
</TableCell>
<TableCell
align="right"
sortDirection={sortKey === "mem" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "mem"}
direction={sortKey === "mem" ? sortDirection : "asc"}
onClick={() => setSort("mem")}
>
RAM
</TableSortLabel>
</TableCell>
<TableCell
align="right"
sortDirection={sortKey === "net_rx" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "net_rx"}
direction={sortKey === "net_rx" ? sortDirection : "asc"}
onClick={() => setSort("net_rx")}
>
Net down
</TableSortLabel>
</TableCell>
<TableCell
align="right"
sortDirection={sortKey === "net_tx" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "net_tx"}
direction={sortKey === "net_tx" ? sortDirection : "asc"}
onClick={() => setSort("net_tx")}
>
Net up
</TableSortLabel>
</TableCell>
<TableCell
align="right"
sortDirection={sortKey === "disk_read" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "disk_read"}
direction={sortKey === "disk_read" ? sortDirection : "asc"}
onClick={() => setSort("disk_read")}
>
Disk read
</TableSortLabel>
</TableCell>
<TableCell
align="right"
sortDirection={sortKey === "disk_write" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "disk_write"}
direction={sortKey === "disk_write" ? sortDirection : "asc"}
onClick={() => setSort("disk_write")}
>
Disk write
</TableSortLabel>
</TableCell>
<TableCell
align="right"
sortDirection={sortKey === "disk_used" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "disk_used"}
direction={sortKey === "disk_used" ? sortDirection : "asc"}
onClick={() => setSort("disk_used")}
>
Disk used
</TableSortLabel>
</TableCell>
<TableCell
sortDirection={sortKey === "updated" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "updated"}
direction={sortKey === "updated" ? sortDirection : "asc"}
onClick={() => setSort("updated")}
>
Updated
</TableSortLabel>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedRows.length === 0 ? (
<TableRow>
<TableCell colSpan={12}>
<Typography variant="body2" color="text.secondary">
No monitoring machines are configured.
</Typography>
</TableCell>
</TableRow>
) : (
sortedRows.map((row) => {
const machine = row.machine;
const status = row.status || row.status_error || "-";
const note =
row.metrics_error ||
row.disk_error ||
machine.notes ||
row.disk?.mount ||
"";
const cpu = formatSummary(
row.cpu_summary,
(value) => `${value.toFixed(1)}%`,
);
const iowait = formatSummary(
row.iowait_summary,
(value) => `${value.toFixed(1)}%`,
);
const mem = formatSummary(
row.mem_summary,
(value) => `${value.toFixed(1)}%`,
);
const netDown = formatSummary(row.net_rx_summary, formatRate);
const netUp = formatSummary(row.net_tx_summary, formatRate);
const diskRead = formatSummary(
row.disk_read_summary,
formatRate,
);
const diskWrite = formatSummary(
row.disk_write_summary,
formatRate,
);
return (
<TableRow key={machine.id}>
<TableCell>
<Stack spacing={0.25}>
<Stack
direction="row"
spacing={0.75}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{machine.name}
</Typography>
{!machine.enabled && (
<Chip
size="small"
label="disabled"
variant="outlined"
/>
)}
</Stack>
<Typography variant="caption" color="text.secondary">
{machine.mode === "local"
? "Local API host"
: `${machine.username || "user"}@${machine.host || "host"}:${machine.port}`}
</Typography>
</Stack>
</TableCell>
<TableCell>{machine.mode}</TableCell>
<TableCell>
<Chip
size="small"
variant="outlined"
color={row.status_error ? "error" : "success"}
label={status}
/>
</TableCell>
<TableCell align="right">
{metricCell(cpu.value, cpu.subtext)}
</TableCell>
<TableCell align="right">
{metricCell(iowait.value, iowait.subtext)}
</TableCell>
<TableCell align="right">
{metricCell(mem.value, mem.subtext)}
</TableCell>
<TableCell align="right">
{metricCell(netDown.value, netDown.subtext)}
</TableCell>
<TableCell align="right">
{metricCell(netUp.value, netUp.subtext)}
</TableCell>
<TableCell align="right">
{metricCell(diskRead.value, diskRead.subtext)}
</TableCell>
<TableCell align="right">
{metricCell(diskWrite.value, diskWrite.subtext)}
</TableCell>
<TableCell align="right">
{row.disk
? metricCell(
row.disk.used_pct,
`Used ${formatBytes(row.disk.used)}\nAvail ${formatBytes(row.disk.available)}`,
)
: "-"}
</TableCell>
<TableCell>
<Stack spacing={0.25}>
<Typography variant="caption" color="text.secondary">
{formatTime(row.latest_sample?.ts ?? null)}
</Typography>
{note && (
<Typography
variant="caption"
color={
row.metrics_error || row.disk_error
? "error.main"
: "text.secondary"
}
>
{note}
</Typography>
)}
</Stack>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
</Stack>
);
}
+47
View File
@@ -0,0 +1,47 @@
import type { ReactNode } from "react";
import { Box, Card, CardContent, Stack, Typography } from "@mui/material";
interface SectionCardProps {
title: string;
description?: string;
action?: ReactNode;
children: ReactNode;
}
export function SectionCard({
title,
description,
action,
children,
}: SectionCardProps) {
return (
<Card variant="outlined">
<CardContent sx={{ p: 1.5 }}>
<Stack spacing={1.25}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 1,
flexWrap: "wrap",
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{description ? (
<Typography variant="body2" color="text.secondary">
{description}
</Typography>
) : null}
</Box>
{action}
</Box>
{children}
</Stack>
</CardContent>
</Card>
);
}
@@ -0,0 +1,71 @@
import type { ReactNode } from "react";
import { Box, Card, CardContent, Typography } from "@mui/material";
interface SelectionRailCardProps {
title: string;
description?: string;
children: ReactNode;
footer?: ReactNode;
minHeight?: number;
contentSx?: object;
bodySx?: object;
}
export function SelectionRailCard({
title,
description,
children,
footer,
minHeight = 420,
contentSx,
bodySx,
}: SelectionRailCardProps) {
return (
<Card variant="outlined" sx={{ alignSelf: "start", height: "fit-content" }}>
<CardContent
sx={{
p: 0,
display: "flex",
flexDirection: "column",
minHeight,
...contentSx,
}}
>
<Box
sx={{
px: 1.5,
py: 1.25,
borderBottom: 1,
borderColor: "divider",
bgcolor: "action.hover",
}}
>
<Typography
variant="subtitle2"
sx={{ fontWeight: 800, letterSpacing: 0.2 }}
>
{title}
</Typography>
{description ? (
<Typography variant="body2" color="text.secondary">
{description}
</Typography>
) : null}
</Box>
<Box sx={{ flex: 1, overflowY: "auto", ...bodySx }}>{children}</Box>
{footer ? (
<Box
sx={{
p: 1,
borderTop: 1,
borderColor: "divider",
bgcolor: "background.paper",
}}
>
{footer}
</Box>
) : null}
</CardContent>
</Card>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { ReactElement, ReactNode } from "react";
import { Box, Card, CardContent, Tabs } from "@mui/material";
interface TabbedCardProps {
value: string;
onChange: (value: string) => void;
tabs: ReactElement[];
children: ReactNode;
contentSx?: object;
tabsSx?: object;
}
export function TabbedCard({
value,
onChange,
tabs,
children,
contentSx,
tabsSx,
}: TabbedCardProps) {
return (
<Card variant="outlined">
<CardContent sx={{ p: 0 }}>
<Tabs
value={value}
onChange={(_, next) => onChange(String(next))}
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
sx={{ px: 1, borderBottom: 1, borderColor: "divider", ...tabsSx }}
>
{tabs}
</Tabs>
<Box sx={{ p: 1.5, ...contentSx }}>{children}</Box>
</CardContent>
</Card>
);
}
+160
View File
@@ -0,0 +1,160 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
deleteMonitoringMachine,
deleteSSHKey,
fetchMonitoringSettings,
fetchSSHKeys,
fetchSavedTaskRuns,
fetchSavedTasks,
generateSSHKey,
resetLocalDatabase,
saveMonitoringMachine,
saveSSHKey,
saveTask,
deleteTask,
runTask,
} from "../api/client";
import type {
MonitoringMachineInput,
ResetLocalDatabaseInput,
SavedTaskInput,
SSHKeyInput,
} from "../types";
export function useMonitoringSettings() {
return useQuery({
queryKey: ["settings", "monitoring-machines"],
queryFn: fetchMonitoringSettings,
refetchInterval: 30_000,
});
}
export function useSSHKeys() {
return useQuery({
queryKey: ["settings", "ssh-keys"],
queryFn: fetchSSHKeys,
refetchInterval: 30_000,
});
}
export function useGenerateSSHKey() {
return useMutation({
mutationFn: (payload: {
name: string;
passphrase: string;
notes: string;
bits?: number;
}) => generateSSHKey(payload),
});
}
export function useSaveSSHKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (key: SSHKeyInput) => saveSSHKey(key),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
});
}
export function useDeleteSSHKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (keyId: string) => deleteSSHKey(keyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
},
});
}
export function useTasks() {
return useQuery({
queryKey: ["tasks"],
queryFn: fetchSavedTasks,
refetchInterval: 30_000,
});
}
export function useTaskRuns(taskId?: string) {
return useQuery({
queryKey: ["tasks", taskId ?? "none", "runs"],
queryFn: () => fetchSavedTaskRuns(taskId ?? ""),
enabled: Boolean(taskId),
refetchInterval: 30_000,
});
}
export function useSaveTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (task: SavedTaskInput) => saveTask(task),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
}
export function useDeleteTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (taskId: string) => deleteTask(taskId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
}
export function useRunTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
taskId,
machineId,
}: {
taskId: string;
machineId?: string;
}) => runTask(taskId, machineId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
}
export function useSaveMonitoringMachine() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (machine: MonitoringMachineInput) =>
saveMonitoringMachine(machine),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
},
});
}
export function useDeleteMonitoringMachine() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (machineId: string) => deleteMonitoringMachine(machineId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
},
});
}
export function useResetLocalDatabase() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: ResetLocalDatabaseInput) =>
resetLocalDatabase(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
queryClient.invalidateQueries({ queryKey: ["media"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
+641
View File
@@ -0,0 +1,641 @@
import { useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
Dialog,
DialogContent,
DialogTitle,
Divider,
FormControl,
InputLabel,
MenuItem,
Select,
Stack,
Tab,
Tabs,
TextField,
Typography,
} from "@mui/material";
import type { MonitoringMachine, SavedTaskInput } from "../types";
import {
useDeleteTask,
useMonitoringSettings,
useRunTask,
useSaveTask,
useTaskRuns,
useTasks,
} from "../hooks/useSettings";
import { DialogFooter } from "../components/DialogFooter";
import { HoverEditButton } from "../components/HoverEditButton";
import { SelectionRailCard } from "../components/SelectionRailCard";
type ActionTab = "new" | string;
function emptyTask(): SavedTaskInput {
return {
id: null,
name: "",
task_type: "shell",
content: "",
enabled: true,
default_machine_id: "",
notes: "",
};
}
function sameTask(a: SavedTaskInput, b: SavedTaskInput) {
return (
a.id === b.id &&
a.name === b.name &&
a.task_type === b.task_type &&
a.content === b.content &&
a.enabled === b.enabled &&
a.default_machine_id === b.default_machine_id &&
a.notes === b.notes
);
}
function TaskEditor({
task,
machines,
onChange,
}: {
task: SavedTaskInput;
machines: MonitoringMachine[];
onChange: (task: SavedTaskInput) => void;
}) {
const selectedMachine = machines.find(
(machine) => machine.id === task.default_machine_id,
);
return (
<Stack spacing={1.5}>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{task.id ? "Edit action" : "New action"}
</Typography>
<Chip size="small" variant="outlined" label={task.task_type} />
<Chip
size="small"
variant="outlined"
label={task.enabled ? "enabled" : "disabled"}
/>
{selectedMachine && (
<Chip
size="small"
variant="outlined"
label={`default: ${selectedMachine.name}`}
/>
)}
</Stack>
<Stack spacing={1.25}>
<TextField
fullWidth
size="small"
label="Name"
value={task.name}
onChange={(e) => onChange({ ...task, name: e.target.value })}
/>
<Stack direction="row" spacing={1.25} sx={{ flexWrap: "wrap" }}>
<FormControl size="small" sx={{ minWidth: 180, flex: "1 1 180px" }}>
<InputLabel>Type</InputLabel>
<Select
label="Type"
value={task.task_type}
onChange={(e) =>
onChange({
...task,
task_type: e.target.value as SavedTaskInput["task_type"],
})
}
>
<MenuItem value="shell">Shell</MenuItem>
<MenuItem value="python">Python</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 220, flex: "1 1 220px" }}>
<InputLabel>Default machine</InputLabel>
<Select
label="Default machine"
value={task.default_machine_id}
onChange={(e) =>
onChange({
...task,
default_machine_id: String(e.target.value),
})
}
>
<MenuItem value="">None</MenuItem>
{machines.map((machine) => (
<MenuItem key={machine.id} value={machine.id}>
{machine.name}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<TextField
fullWidth
size="small"
label="Notes"
value={task.notes}
onChange={(e) => onChange({ ...task, notes: e.target.value })}
/>
<TextField
fullWidth
multiline
minRows={9}
size="small"
label={
task.task_type === "python" ? "Python script" : "Shell command"
}
value={task.content}
onChange={(e) => onChange({ ...task, content: e.target.value })}
helperText={
task.task_type === "python"
? "Python is run as `python3 -c`."
: "Shell commands are run through `/bin/sh -c`."
}
/>
</Stack>
</Stack>
);
}
function TaskDialog({
open,
task,
baseline,
machines,
onClose,
onChange,
onSave,
onDelete,
}: {
open: boolean;
task: SavedTaskInput;
baseline: SavedTaskInput;
machines: MonitoringMachine[];
onClose: () => void;
onChange: (task: SavedTaskInput) => void;
onSave: () => void;
onDelete?: () => void;
}) {
const requestClose = () => {
if (
!sameTask(task, baseline) &&
!window.confirm("Discard unsaved changes?")
) {
return;
}
onClose();
};
return (
<Dialog open={open} onClose={requestClose} fullWidth maxWidth="md">
<DialogTitle>{task.id ? "Edit action" : "New action"}</DialogTitle>
<DialogContent dividers>
<TaskEditor task={task} machines={machines} onChange={onChange} />
</DialogContent>
<DialogFooter
onCancel={requestClose}
cancelLabel="Cancel"
onConfirm={onSave}
confirmLabel="Save action"
confirmBusyLabel="Save action"
secondaryAction={
onDelete ? (
<Button variant="outlined" color="error" onClick={onDelete}>
Delete
</Button>
) : undefined
}
/>
</Dialog>
);
}
export function Actions() {
const { data: machines = [] } = useMonitoringSettings();
const { data: tasks = [] } = useTasks();
const saveTask = useSaveTask();
const deleteTask = useDeleteTask();
const runTask = useRunTask();
const [tab, setTab] = useState<ActionTab>("new");
const [draft, setDraft] = useState<SavedTaskInput>(emptyTask());
const [draftBaseline, setDraftBaseline] = useState<SavedTaskInput>(
emptyTask(),
);
const [runMachineId, setRunMachineId] = useState("");
const [editOpen, setEditOpen] = useState(false);
const selectedTask = useMemo(
() => tasks.find((task) => task.id === tab) ?? null,
[tasks, tab],
);
const selectedRuns = useTaskRuns(selectedTask?.id);
const createNew = () => {
const initial = emptyTask();
setDraft(initial);
setDraftBaseline(initial);
setRunMachineId(machines[0]?.id || "");
setEditOpen(true);
};
const saveDraft = async () => {
const saved = await saveTask.mutateAsync(draft);
setTab(saved.id);
setEditOpen(false);
const nextDraft = {
id: saved.id,
name: saved.name,
task_type: saved.task_type,
content: saved.content,
enabled: saved.enabled,
default_machine_id: saved.default_machine_id,
notes: saved.notes,
};
setDraft(nextDraft);
setDraftBaseline(nextDraft);
};
const editingTask = selectedTask;
return (
<Stack spacing={2.25}>
<Stack
direction="row"
spacing={1}
sx={{
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
}}
>
<Box>
<Typography variant="h5" sx={{ fontWeight: 800 }}>
Actions
</Typography>
<Typography variant="body2" color="text.secondary">
Save reusable server tasks and switch between them with tabs.
</Typography>
</Box>
<Chip label={`${tasks.length} saved`} variant="outlined" />
</Stack>
{saveTask.error && (
<Alert severity="error">{String(saveTask.error)}</Alert>
)}
{deleteTask.error && (
<Alert severity="error">{String(deleteTask.error)}</Alert>
)}
{runTask.error && <Alert severity="error">{String(runTask.error)}</Alert>}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "280px minmax(0, 1fr)" },
gap: 2,
}}
>
<SelectionRailCard
title="Saved actions"
description="Pick a saved task, then edit or run it from the detail pane."
contentSx={{ maxHeight: { xs: 520, md: 620 } }}
footer={
<Button fullWidth variant="contained" onClick={createNew}>
+ New action
</Button>
}
>
<Tabs
value={tab}
onChange={(_, value) => setTab(value)}
orientation="vertical"
variant="scrollable"
sx={{ borderRight: 1, borderColor: "divider" }}
>
{tasks.map((task) => (
<Box
key={task.id}
sx={{
position: "relative",
width: "100%",
"&:hover .rail-edit": { opacity: 1 },
}}
>
<Tab
value={task.id}
label={task.name}
sx={{
alignItems: "flex-start",
justifyContent: "flex-start",
width: 1,
pr: 5,
}}
onClick={() => setTab(task.id)}
onDoubleClick={() => {
const initial = {
id: task.id,
name: task.name,
task_type: task.task_type,
content: task.content,
enabled: task.enabled,
default_machine_id: task.default_machine_id,
notes: task.notes,
};
setDraft(initial);
setDraftBaseline(initial);
setEditOpen(true);
}}
/>
<Box
sx={{
position: "absolute",
right: 4,
top: "50%",
transform: "translateY(-50%)",
}}
>
<HoverEditButton
onClick={() => {
const initial = {
id: task.id,
name: task.name,
task_type: task.task_type,
content: task.content,
enabled: task.enabled,
default_machine_id: task.default_machine_id,
notes: task.notes,
};
setDraft(initial);
setDraftBaseline(initial);
setEditOpen(true);
}}
/>
</Box>
</Box>
))}
</Tabs>
</SelectionRailCard>
<Stack spacing={2}>
{editingTask ? (
<Card variant="outlined">
<CardContent sx={{ p: 1.5 }}>
<Stack spacing={1.5}>
<Stack
direction="row"
spacing={1}
sx={{
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
}}
>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{editingTask.name}
</Typography>
<Typography variant="body2" color="text.secondary">
Open the editor popup to modify this action.
</Typography>
</Box>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap" }}
>
<Button
variant="outlined"
onClick={() => {
const initial = {
id: editingTask.id,
name: editingTask.name,
task_type: editingTask.task_type,
content: editingTask.content,
enabled: editingTask.enabled,
default_machine_id: editingTask.default_machine_id,
notes: editingTask.notes,
};
setDraft(initial);
setDraftBaseline(initial);
setEditOpen(true);
}}
>
Edit
</Button>
<Button
variant="contained"
disabled={runTask.isPending || !runMachineId}
onClick={async () => {
await runTask.mutateAsync({
taskId: editingTask.id,
machineId: runMachineId,
});
}}
>
{runTask.isPending ? "Running..." : "Run action"}
</Button>
</Stack>
</Stack>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<FormControl size="small" sx={{ minWidth: 240 }}>
<InputLabel>Run on machine</InputLabel>
<Select
label="Run on machine"
value={runMachineId}
onChange={(e) =>
setRunMachineId(String(e.target.value))
}
>
{machines.map((machine) => (
<MenuItem key={machine.id} value={machine.id}>
{machine.name}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<Divider />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
Recent runs
</Typography>
{selectedRuns.data?.items?.length ? (
<Stack spacing={1.25}>
{selectedRuns.data.items.map((run) => (
<Card key={run.id} variant="outlined">
<CardContent sx={{ p: 1.5 }}>
<Stack spacing={1}>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Chip
size="small"
variant="outlined"
label={run.status}
/>
<Typography
variant="body2"
color="text.secondary"
>
{run.machine_name} ·{" "}
{new Date(
run.created_at * 1000,
).toLocaleString()}
</Typography>
</Stack>
{run.stdout_tail && (
<Box
sx={{
px: 1,
py: 0.75,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Typography
variant="caption"
color="text.secondary"
>
stdout
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "monospace",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{run.stdout_tail}
</Typography>
</Box>
)}
{run.stderr_tail && (
<Box
sx={{
px: 1,
py: 0.75,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Typography
variant="caption"
color="text.secondary"
>
stderr
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "monospace",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{run.stderr_tail}
</Typography>
</Box>
)}
{run.error && (
<Alert severity="error">{run.error}</Alert>
)}
</Stack>
</CardContent>
</Card>
))}
</Stack>
) : (
<Alert severity="info">No runs yet.</Alert>
)}
</Stack>
</CardContent>
</Card>
) : (
<Stack spacing={2}>
<Card variant="outlined">
<CardContent sx={{ p: 2 }}>
<Stack spacing={1.25}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
No action selected
</Typography>
<Typography variant="body2" color="text.secondary">
Select a saved action from the list on the left to view
its details, run it, or open the editor popup. Use the
button at the bottom to add a new action.
</Typography>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap" }}
>
<Button variant="contained" onClick={createNew}>
+ New action
</Button>
{tasks[0] && (
<Button
variant="outlined"
onClick={() => setTab(tasks[0].id)}
>
Select first action
</Button>
)}
</Stack>
</Stack>
</CardContent>
</Card>
<Card variant="outlined">
<CardContent sx={{ p: 2 }}>
<Stack spacing={1}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
What this panel shows
</Typography>
<Typography variant="body2" color="text.secondary">
Saved actions stay on the left rail, while details, run
controls, and recent history appear here.
</Typography>
</Stack>
</CardContent>
</Card>
</Stack>
)}
</Stack>
</Box>
<TaskDialog
open={editOpen}
task={draft}
baseline={draftBaseline}
machines={machines}
onClose={() => setEditOpen(false)}
onChange={setDraft}
onSave={saveDraft}
onDelete={
draft.id ? () => deleteTask.mutate(String(draft.id)) : undefined
}
/>
</Stack>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { useMemo, useState } from "react";
import {
Alert,
Box,
Card,
CardContent,
Chip,
Grid,
Stack,
Tab,
Typography,
} from "@mui/material";
import { useSearchParams } from "react-router-dom";
import { Media } from "./Media";
import { useCounts, useLibraries } from "../hooks/useDashboard";
import { useMonitoringSettings } from "../hooks/useSettings";
import { SectionCard } from "../components/SectionCard";
import { TabbedCard } from "../components/TabbedCard";
function JellyfinLibraryStats() {
const [searchParams] = useSearchParams();
const { data: machines = [] } = useMonitoringSettings();
const jellyfinMachines = useMemo(
() =>
machines.filter(
(machine) => machine.enabled && machine.services.includes("jellyfin"),
),
[machines],
);
const selectedMachineId =
searchParams.get("machine_id") || jellyfinMachines[0]?.id || "";
const { data: counts } = useCounts(selectedMachineId || undefined);
const { data: libraries } = useLibraries(selectedMachineId || undefined);
return (
<SectionCard
title="Library stats"
description="Compact Jellyfin summary for the selected machine."
action={
<Chip
label={selectedMachineId ? "Selected machine" : "Default machine"}
variant="outlined"
size="small"
/>
}
>
<Stack spacing={1.25}>
{counts ? (
<Grid container spacing={1}>
<Grid size={{ xs: 6, md: 3 }}>
<Card variant="outlined">
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
<Typography variant="caption" color="text.secondary">
Total
</Typography>
<Typography
variant="h6"
sx={{ fontWeight: 800, lineHeight: 1.1 }}
>
{(
counts.movies +
counts.series +
counts.episodes
).toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Card variant="outlined">
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
<Typography variant="caption" color="text.secondary">
Movies
</Typography>
<Typography
variant="h6"
sx={{ fontWeight: 800, lineHeight: 1.1 }}
>
{counts.movies.toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Card variant="outlined">
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
<Typography variant="caption" color="text.secondary">
Series
</Typography>
<Typography
variant="h6"
sx={{ fontWeight: 800, lineHeight: 1.1 }}
>
{counts.series.toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Card variant="outlined">
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
<Typography variant="caption" color="text.secondary">
Episodes
</Typography>
<Typography
variant="h6"
sx={{ fontWeight: 800, lineHeight: 1.1 }}
>
{counts.episodes.toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
) : null}
{libraries?.length ? (
<Grid container spacing={1}>
{libraries.map((library) => (
<Grid key={library.library} size={{ xs: 12, md: 6 }}>
<Card variant="outlined">
<CardContent sx={{ py: 1.1, px: 1.5 }}>
<Stack spacing={0.5}>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700 }}
noWrap
>
{library.library}
</Typography>
<Typography variant="body2" color="text.secondary">
Total {library.total.toLocaleString()} · Movies{" "}
{library.movies.toLocaleString()} · Series{" "}
{library.series.toLocaleString()}
</Typography>
</Stack>
</CardContent>
</Card>
</Grid>
))}
</Grid>
) : null}
</Stack>
</SectionCard>
);
}
export function Applications() {
const [tab, setTab] = useState("jellyfin");
return (
<Stack spacing={2.25}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 800 }}>
Applications
</Typography>
<Typography variant="body2" color="text.secondary">
Browse application-specific tools from a compact tabbed workspace.
</Typography>
</Box>
<TabbedCard
value={tab}
onChange={setTab}
tabs={[
<Tab key="jellyfin" value="jellyfin" label="Jellyfin" />,
<Tab key="nextcloud" value="nextcloud" label="Nextcloud" />,
]}
>
{tab === "jellyfin" ? (
<Stack spacing={2}>
<JellyfinLibraryStats />
<Media />
</Stack>
) : (
<Card variant="outlined">
<CardContent sx={{ p: 1.5 }}>
<Alert severity="info">
Nextcloud support will be added in a future update.
</Alert>
</CardContent>
</Card>
)}
</TabbedCard>
</Stack>
);
}
File diff suppressed because it is too large Load Diff