355 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|