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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user