Files
manage/frontend/src/components/MachineMonitoringSection.tsx
T

355 lines
10 KiB
TypeScript

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