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>
);
}