Add missing frontend and backend files
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user