now supports multi machine monitoring
This commit is contained in:
@@ -28,6 +28,7 @@ import { AuthProvider, useAuth } from "react-oidc-context";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { Monitoring } from "./pages/Monitoring";
|
||||
import { Media } from "./pages/Media";
|
||||
import { Settings } from "./pages/Settings";
|
||||
import { UsersPage } from "./pages/Users";
|
||||
import { FileBrowser } from "./pages/FileBrowser";
|
||||
import { getAppTheme } from "./theme";
|
||||
@@ -175,6 +176,12 @@ function Shell({
|
||||
<Tab value="/media" label="Media" component={NavLink} to="/media" />
|
||||
<Tab value="/users" label="Users" component={NavLink} to="/users" />
|
||||
<Tab value="/files" label="Files" component={NavLink} to="/files" />
|
||||
<Tab
|
||||
value="/settings"
|
||||
label="Settings"
|
||||
component={NavLink}
|
||||
to="/settings"
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
</AppBar>
|
||||
@@ -188,6 +195,7 @@ function Shell({
|
||||
<Route path="/media" element={<Media />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/files" element={<FileBrowser />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</Container>
|
||||
</>
|
||||
|
||||
@@ -10,9 +10,14 @@ import type {
|
||||
UserMessageResponse,
|
||||
UserMessageQueueStatus,
|
||||
NowPlayingSession,
|
||||
MonitoringPollerStatus,
|
||||
MonitoringOverviewResponse,
|
||||
MonitoringStatus,
|
||||
MonitoringMetrics,
|
||||
DiskSpace,
|
||||
MonitoringMachine,
|
||||
MonitoringMachineInput,
|
||||
MonitoringMachineAction,
|
||||
MediaIndexStatus,
|
||||
MediaIndexActionResponse,
|
||||
MediaQueryResponse,
|
||||
@@ -109,6 +114,17 @@ async function postForm<T>(path: string, body: FormData): Promise<T> {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function del<T>(path: string): Promise<T> {
|
||||
const response = await fetch(buildUrl(path), {
|
||||
method: "DELETE",
|
||||
headers: buildHeaders(false),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
export const fetchCounts = () => get<MediaCounts>("/api/dashboard/counts");
|
||||
export const fetchLibraries = () =>
|
||||
@@ -121,23 +137,80 @@ export const fetchUsers = () => get<UserDirectoryResponse>("/api/users");
|
||||
export const fetchNowPlaying = fetchActivity;
|
||||
|
||||
// Monitoring
|
||||
export const fetchMonitoringStatus = () =>
|
||||
get<MonitoringStatus>("/api/monitoring/status");
|
||||
export const fetchMonitoringMachines = () =>
|
||||
get<MonitoringMachine[]>("/api/monitoring/machines");
|
||||
export const fetchMonitoringPoller = () =>
|
||||
get<MonitoringPollerStatus>("/api/monitoring/poller");
|
||||
export const fetchMonitoringOverview = () =>
|
||||
get<MonitoringOverviewResponse>("/api/dashboard/monitoring");
|
||||
export const fetchMonitoringStatus = (machineId?: string) =>
|
||||
get<MonitoringStatus>(
|
||||
"/api/monitoring/status",
|
||||
machineId ? { machine_id: machineId } : undefined,
|
||||
);
|
||||
export const fetchMonitoringMetrics = (
|
||||
lastSeconds?: number | null,
|
||||
maxLines = 70_000,
|
||||
machineId?: string,
|
||||
) =>
|
||||
get<MonitoringMetrics>("/api/monitoring/metrics", {
|
||||
...(lastSeconds == null ? {} : { last_seconds: String(lastSeconds) }),
|
||||
max_lines: String(maxLines),
|
||||
...(machineId ? { machine_id: machineId } : {}),
|
||||
});
|
||||
export const fetchDiskSpace = () => get<DiskSpace>("/api/monitoring/disk");
|
||||
export const startCollector = () =>
|
||||
post<{ message: string }>("/api/monitoring/start");
|
||||
export const stopCollector = () =>
|
||||
post<{ message: string }>("/api/monitoring/stop");
|
||||
export const restartCollector = () =>
|
||||
post<{ message: string }>("/api/monitoring/restart");
|
||||
export const fetchDiskSpace = (machineId?: string) =>
|
||||
get<DiskSpace>(
|
||||
"/api/monitoring/disk",
|
||||
machineId ? { machine_id: machineId } : undefined,
|
||||
);
|
||||
export const startCollector = (machineId?: string) =>
|
||||
post<{ message: string }>(
|
||||
machineId
|
||||
? `/api/monitoring/start?machine_id=${encodeURIComponent(machineId)}`
|
||||
: "/api/monitoring/start",
|
||||
);
|
||||
export const stopCollector = (machineId?: string) =>
|
||||
post<{ message: string }>(
|
||||
machineId
|
||||
? `/api/monitoring/stop?machine_id=${encodeURIComponent(machineId)}`
|
||||
: "/api/monitoring/stop",
|
||||
);
|
||||
export const restartCollector = (machineId?: string) =>
|
||||
post<{ message: string }>(
|
||||
machineId
|
||||
? `/api/monitoring/restart?machine_id=${encodeURIComponent(machineId)}`
|
||||
: "/api/monitoring/restart",
|
||||
);
|
||||
|
||||
export const fetchMonitoringSettings = () =>
|
||||
get<MonitoringMachine[]>("/api/settings/machines");
|
||||
export const fetchMonitoringMachineActions = (machineId: string, limit = 10) =>
|
||||
get<{ items: MonitoringMachineAction[]; total: number }>(
|
||||
`/api/monitoring/machines/${encodeURIComponent(machineId)}/actions`,
|
||||
{ limit: String(limit) },
|
||||
);
|
||||
export const saveMonitoringMachine = (machine: MonitoringMachineInput) =>
|
||||
fetch(
|
||||
buildUrl(
|
||||
machine.id
|
||||
? `/api/settings/machines/${encodeURIComponent(machine.id)}`
|
||||
: "/api/settings/machines",
|
||||
),
|
||||
{
|
||||
method: machine.id ? "PUT" : "POST",
|
||||
headers: buildHeaders(true),
|
||||
body: JSON.stringify(machine),
|
||||
},
|
||||
).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
||||
}
|
||||
return response.json() as Promise<MonitoringMachine>;
|
||||
});
|
||||
export const deleteMonitoringMachine = (machineId: string) =>
|
||||
del<{ status: string }>(
|
||||
`/api/settings/machines/${encodeURIComponent(machineId)}`,
|
||||
);
|
||||
|
||||
// Media
|
||||
export const fetchMediaStatus = () =>
|
||||
|
||||
@@ -79,9 +79,19 @@ export function SessionActivityPanel({
|
||||
<TableContainer
|
||||
component={Paper}
|
||||
variant="outlined"
|
||||
sx={{ maxHeight: 280, borderColor: "divider", borderRadius: 1, overflowX: "auto" }}
|
||||
sx={{
|
||||
maxHeight: 280,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
overflowX: "auto",
|
||||
}}
|
||||
>
|
||||
<Table size="small" stickyHeader aria-label="Session activity details">
|
||||
<Table
|
||||
size="small"
|
||||
stickyHeader
|
||||
aria-label="Session activity details"
|
||||
sx={{ minWidth: 880 }}
|
||||
>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
@@ -198,7 +208,7 @@ export function SessionActivityPanel({
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.75, minWidth: 140, display: { xs: "none", md: "table-cell" } }}>
|
||||
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
noWrap
|
||||
@@ -210,12 +220,12 @@ export function SessionActivityPanel({
|
||||
{session.type || "—"}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.75, minWidth: 140, display: { xs: "none", md: "table-cell" } }}>
|
||||
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
|
||||
<Typography variant="body2" noWrap>
|
||||
{session.device || "Unknown device"}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap", display: { xs: "none", md: "table-cell" } }}>
|
||||
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
|
||||
<Typography variant="body2" noWrap>
|
||||
{session.transcoding === "yes"
|
||||
? session.transcoding_type
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchCounts, fetchLibraries, fetchActivity } from "../api/client";
|
||||
import {
|
||||
fetchActivity,
|
||||
fetchCounts,
|
||||
fetchLibraries,
|
||||
fetchMonitoringOverview,
|
||||
} from "../api/client";
|
||||
|
||||
export function useCounts() {
|
||||
return useQuery({
|
||||
@@ -25,5 +30,13 @@ export function useActivity() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonitoringOverview() {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "monitoring"],
|
||||
queryFn: fetchMonitoringOverview,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
// Backward-compatible alias used by older code.
|
||||
export const useNowPlaying = useActivity;
|
||||
|
||||
@@ -1,52 +1,83 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchMonitoringStatus,
|
||||
fetchMonitoringMetrics,
|
||||
fetchDiskSpace,
|
||||
fetchMonitoringMachines,
|
||||
fetchMonitoringMachineActions,
|
||||
fetchMonitoringPoller,
|
||||
startCollector,
|
||||
stopCollector,
|
||||
restartCollector,
|
||||
} from "../api/client";
|
||||
|
||||
export function useMonitoringStatus() {
|
||||
export function useMonitoringMachines() {
|
||||
return useQuery({
|
||||
queryKey: ["monitoring", "status"],
|
||||
queryFn: fetchMonitoringStatus,
|
||||
queryKey: ["monitoring", "machines"],
|
||||
queryFn: fetchMonitoringMachines,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonitoringMetrics() {
|
||||
export function useMonitoringPoller() {
|
||||
return useQuery({
|
||||
queryKey: ["monitoring", "metrics"],
|
||||
queryFn: () => fetchMonitoringMetrics(),
|
||||
queryKey: ["monitoring", "poller"],
|
||||
queryFn: fetchMonitoringPoller,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonitoringStatus(machineId?: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["monitoring", "status", machineId ?? "default"],
|
||||
queryFn: () => fetchMonitoringStatus(machineId),
|
||||
refetchInterval: 30_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonitoringMetrics(machineId?: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["monitoring", "metrics", machineId ?? "default"],
|
||||
queryFn: () => fetchMonitoringMetrics(undefined, 70_000, machineId),
|
||||
refetchInterval: 15_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDiskSpace() {
|
||||
export function useDiskSpace(machineId?: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["monitoring", "disk"],
|
||||
queryFn: fetchDiskSpace,
|
||||
queryKey: ["monitoring", "disk", machineId ?? "default"],
|
||||
queryFn: () => fetchDiskSpace(machineId),
|
||||
staleTime: 60_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCollectorControls() {
|
||||
export function useMachineActions(machineId: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["monitoring", "actions", machineId],
|
||||
queryFn: () => fetchMonitoringMachineActions(machineId),
|
||||
refetchInterval: 15_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCollectorControls(machineId?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["monitoring"] });
|
||||
|
||||
const start = useMutation({
|
||||
mutationFn: startCollector,
|
||||
mutationFn: () => startCollector(machineId),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const stop = useMutation({
|
||||
mutationFn: stopCollector,
|
||||
mutationFn: () => stopCollector(machineId),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const restart = useMutation({
|
||||
mutationFn: restartCollector,
|
||||
mutationFn: () => restartCollector(machineId),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,83 +1,22 @@
|
||||
import { useMemo } from "react";
|
||||
import { Box, Divider, Grid, Stack, Typography } from "@mui/material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCounts, useLibraries, useActivity } from "../hooks/useDashboard";
|
||||
import { useMonitoringMetrics, useDiskSpace } from "../hooks/useMonitoring";
|
||||
import {
|
||||
useCounts,
|
||||
useLibraries,
|
||||
useActivity,
|
||||
useMonitoringOverview,
|
||||
} from "../hooks/useDashboard";
|
||||
import { NowPlaying } from "../components/NowPlaying";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import { DiskSpaceCard } from "../components/DiskSpaceCard";
|
||||
import { LibraryOverview } from "../components/LibraryOverview";
|
||||
|
||||
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 formatPct(value: number): string {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function summarize(values: number[]) {
|
||||
if (values.length === 0) return null;
|
||||
const total = values.reduce((sum, value) => sum + value, 0);
|
||||
return {
|
||||
avg: total / values.length,
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
};
|
||||
}
|
||||
import { MonitoringOverviewTable } from "../components/MonitoringOverviewTable";
|
||||
|
||||
export function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { data: counts } = useCounts();
|
||||
const { data: libraries } = useLibraries();
|
||||
const { data: activity } = useActivity();
|
||||
const { data: metrics } = useMonitoringMetrics();
|
||||
const { data: disk } = useDiskSpace();
|
||||
|
||||
const monitoringWindow = useMemo(() => {
|
||||
const samples = metrics?.samples ?? [];
|
||||
if (samples.length === 0) return [];
|
||||
const latestTs = samples.at(-1)?.ts ?? 0;
|
||||
const windowStart = latestTs - 10 * 60;
|
||||
const windowed = samples.filter((sample) => sample.ts >= windowStart);
|
||||
return windowed.length > 0 ? windowed : samples;
|
||||
}, [metrics?.samples]);
|
||||
|
||||
const cpuSummary = summarize(
|
||||
monitoringWindow.map((sample) => sample.cpu_pct),
|
||||
);
|
||||
const iowaitSummary = summarize(
|
||||
monitoringWindow
|
||||
.map((sample) => sample.iowait_pct)
|
||||
.filter((value): value is number => value !== undefined),
|
||||
);
|
||||
const memSummary = summarize(
|
||||
monitoringWindow.map((sample) => sample.mem_pct),
|
||||
);
|
||||
const netRxSummary = summarize(
|
||||
monitoringWindow.map((sample) => sample.net_rx_bytes_per_sec),
|
||||
);
|
||||
const netTxSummary = summarize(
|
||||
monitoringWindow.map((sample) => sample.net_tx_bytes_per_sec),
|
||||
);
|
||||
const diskReadSummary = summarize(
|
||||
monitoringWindow.map((sample) => sample.disk_read_bps),
|
||||
);
|
||||
const diskWriteSummary = summarize(
|
||||
monitoringWindow.map((sample) => sample.disk_write_bps),
|
||||
);
|
||||
const { data: monitoringOverview } = useMonitoringOverview();
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
@@ -101,95 +40,7 @@ export function Dashboard() {
|
||||
<Typography variant="h5" sx={{ mb: 1.5 }}>
|
||||
Monitoring Overview
|
||||
</Typography>
|
||||
<Grid container spacing={2} sx={{ alignItems: "stretch" }}>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="CPU (10m avg)"
|
||||
value={cpuSummary ? formatPct(cpuSummary.avg) : "-"}
|
||||
subtext={
|
||||
cpuSummary
|
||||
? `High: ${formatPct(cpuSummary.max)}\nLow: ${formatPct(cpuSummary.min)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="IO Wait (10m avg)"
|
||||
value={iowaitSummary ? formatPct(iowaitSummary.avg) : "-"}
|
||||
subtext={
|
||||
iowaitSummary
|
||||
? `High: ${formatPct(iowaitSummary.max)}\nLow: ${formatPct(iowaitSummary.min)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="RAM (10m avg)"
|
||||
value={memSummary ? formatPct(memSummary.avg) : "-"}
|
||||
subtext={
|
||||
memSummary
|
||||
? `High: ${formatPct(memSummary.max)}\nLow: ${formatPct(memSummary.min)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="Net down (10m avg)"
|
||||
value={netRxSummary ? formatRate(netRxSummary.avg) : "-"}
|
||||
subtext={
|
||||
netRxSummary
|
||||
? `High: ${formatRate(netRxSummary.max)}\nLow: ${formatRate(netRxSummary.min)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="Net up (10m avg)"
|
||||
value={netTxSummary ? formatRate(netTxSummary.avg) : "-"}
|
||||
subtext={
|
||||
netTxSummary
|
||||
? `High: ${formatRate(netTxSummary.max)}\nLow: ${formatRate(netTxSummary.min)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="Disk read (10m avg)"
|
||||
value={diskReadSummary ? formatRate(diskReadSummary.avg) : "-"}
|
||||
subtext={
|
||||
diskReadSummary
|
||||
? `High: ${formatRate(diskReadSummary.max)}\nLow: ${formatRate(diskReadSummary.min)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3, lg: 12 / 7 }}>
|
||||
<MetricCard
|
||||
label="Disk write (10m avg)"
|
||||
value={diskWriteSummary ? formatRate(diskWriteSummary.avg) : "-"}
|
||||
subtext={
|
||||
diskWriteSummary
|
||||
? `High: ${formatRate(diskWriteSummary.max)}\nLow: ${formatRate(diskWriteSummary.min)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{disk && (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<DiskSpaceCard
|
||||
used={disk.used}
|
||||
available={disk.available}
|
||||
size={disk.size}
|
||||
usedPct={disk.used_pct}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<MonitoringOverviewTable overview={monitoringOverview} />
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
@@ -1,173 +1,80 @@
|
||||
import { Alert, Button, Chip, Stack, Typography } from "@mui/material";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Divider,
|
||||
Grid,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
useMonitoringStatus,
|
||||
useMonitoringMetrics,
|
||||
useDiskSpace,
|
||||
useCollectorControls,
|
||||
useMonitoringMachines,
|
||||
useMonitoringPoller,
|
||||
} from "../hooks/useMonitoring";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import { MonitoringCharts } from "../components/MonitoringCharts";
|
||||
|
||||
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`;
|
||||
}
|
||||
import { MachineMonitoringSection } from "../components/MachineMonitoringSection";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function Monitoring() {
|
||||
const { data: status } = useMonitoringStatus();
|
||||
const { data: metrics } = useMonitoringMetrics();
|
||||
const { data: disk } = useDiskSpace();
|
||||
const { start, stop, restart } = useCollectorControls();
|
||||
|
||||
const samples = metrics?.samples ?? [];
|
||||
const latest = samples.at(-1);
|
||||
|
||||
const avg = (arr: number[]) =>
|
||||
arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
|
||||
const max = (arr: number[]) => (arr.length ? Math.max(...arr) : 0);
|
||||
|
||||
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 { data: machines, isLoading, error } = useMonitoringMachines();
|
||||
const { data: poller } = useMonitoringPoller();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Stack spacing={3}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1.5}
|
||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
<Typography variant="h5">Monitoring</Typography>
|
||||
<Chip
|
||||
label={status?.status ?? "unknown"}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => start.mutate()}
|
||||
disabled={start.isPending}
|
||||
<Stack spacing={0.5}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => restart.mutate()}
|
||||
disabled={restart.isPending}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => stop.mutate()}
|
||||
disabled={stop.isPending}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
<Typography variant="h5">Monitoring</Typography>
|
||||
{poller && (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={poller.worker_running ? "success" : "default"}
|
||||
label={
|
||||
poller.worker_running
|
||||
? `Poller running · ${poller.interval_seconds}s`
|
||||
: "Poller stopped"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Each machine below is monitored with its own settings and collector
|
||||
state, and recent activity is gathered automatically by the backend.
|
||||
</Typography>
|
||||
</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>
|
||||
{error && <Alert severity="error">{String(error)}</Alert>}
|
||||
{isLoading && (
|
||||
<Alert severity="info">Loading monitoring machines...</Alert>
|
||||
)}
|
||||
{!isLoading && (machines?.length ?? 0) === 0 && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
action={
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={() => navigate("/settings")}
|
||||
>
|
||||
Open Settings
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
No monitoring machines are configured yet.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Box>
|
||||
<MonitoringCharts samples={samples} />
|
||||
</Box>
|
||||
{machines?.map((machine) => (
|
||||
<Stack
|
||||
key={machine.id}
|
||||
spacing={2}
|
||||
sx={{
|
||||
p: 2,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 2,
|
||||
bgcolor: "background.paper",
|
||||
}}
|
||||
>
|
||||
<MachineMonitoringSection machine={machine} />
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,99 @@ export interface NowPlayingSession {
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
export interface MonitoringMachine {
|
||||
id: string;
|
||||
name: string;
|
||||
mode: "local" | "ssh";
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
key_directory: string;
|
||||
key_name: string;
|
||||
password_set: boolean;
|
||||
media_root: string;
|
||||
path_prefix: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface MonitoringMachineInput {
|
||||
id?: string | null;
|
||||
name: string;
|
||||
mode: "local" | "ssh";
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
key_directory: string;
|
||||
key_name: string;
|
||||
password: string;
|
||||
media_root: string;
|
||||
path_prefix: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface MonitoringMachineAction {
|
||||
machine_id: string;
|
||||
machine_name: string;
|
||||
mode: "local" | "ssh";
|
||||
action: string;
|
||||
status: string;
|
||||
created_at: number;
|
||||
duration_ms: number;
|
||||
request_id: string;
|
||||
message: string;
|
||||
error: string;
|
||||
stdout_tail: string;
|
||||
stderr_tail: string;
|
||||
}
|
||||
|
||||
export interface MetricSummary {
|
||||
avg: number;
|
||||
min: number;
|
||||
max: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MonitoringPollerStatus {
|
||||
worker_running: boolean;
|
||||
stop_requested: boolean;
|
||||
last_run_at: number | null;
|
||||
last_success_at: number | null;
|
||||
last_error: string;
|
||||
last_cycle_ms: number | null;
|
||||
poll_count: number;
|
||||
error_count: number;
|
||||
interval_seconds: number;
|
||||
initial_delay_seconds: number;
|
||||
retention_days: number;
|
||||
}
|
||||
|
||||
export interface MonitoringMachineOverview {
|
||||
machine: MonitoringMachine;
|
||||
status: string;
|
||||
status_error: string;
|
||||
metrics_error: string;
|
||||
disk_error: string;
|
||||
latest_sample: MonitoringSample | null;
|
||||
sample_count: number;
|
||||
cpu_summary: MetricSummary | null;
|
||||
iowait_summary: MetricSummary | null;
|
||||
mem_summary: MetricSummary | null;
|
||||
net_rx_summary: MetricSummary | null;
|
||||
net_tx_summary: MetricSummary | null;
|
||||
disk_read_summary: MetricSummary | null;
|
||||
disk_write_summary: MetricSummary | null;
|
||||
disk: DiskSpace | null;
|
||||
}
|
||||
|
||||
export interface MonitoringOverviewResponse {
|
||||
poller: MonitoringPollerStatus;
|
||||
machines: MonitoringMachineOverview[];
|
||||
total: number;
|
||||
enabled: number;
|
||||
}
|
||||
|
||||
export interface MonitoringStatus {
|
||||
status: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user