feat(observability): add Prometheus/Grafana/Loki/Alertmanager/Alloy stack and remove legacy Monitoring UI

This commit is contained in:
Developer
2026-06-16 11:08:32 +00:00
parent 615e02e970
commit e2ad731b5f
80 changed files with 5020 additions and 3665 deletions
+8 -50
View File
@@ -35,7 +35,7 @@
"@tailwindcss/postcss": "^4.3.0",
"@tailwindcss/vite": "^4.3.0",
"@types/d3": "^7.4.3",
"@types/node": "^24.12.2",
"@types/node": "^24.13.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
@@ -3375,9 +3375,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3395,9 +3392,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3415,9 +3409,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3435,9 +3426,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3455,9 +3443,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3475,9 +3460,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3737,9 +3719,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3757,9 +3736,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3777,9 +3753,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3797,9 +3770,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4254,12 +4224,12 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
"undici-types": "~7.18.0"
}
},
"node_modules/@types/parse-json": {
@@ -7532,9 +7502,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7556,9 +7523,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7580,9 +7544,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7604,9 +7565,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -9774,9 +9732,9 @@
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT"
},
"node_modules/unicorn-magic": {
+1 -1
View File
@@ -37,7 +37,7 @@
"@tailwindcss/postcss": "^4.3.0",
"@tailwindcss/vite": "^4.3.0",
"@types/d3": "^7.4.3",
"@types/node": "^24.12.2",
"@types/node": "^24.13.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
+11 -12
View File
@@ -5,6 +5,7 @@ import {
NavLink,
useLocation,
Outlet,
Navigate,
} from "react-router-dom";
import {
QueryClient,
@@ -14,13 +15,13 @@ import {
import { useEffect, useMemo, useState } from "react";
import { AuthProvider, useAuth } from "react-oidc-context";
import { Dashboard } from "./pages/Dashboard";
import { Monitoring } from "./pages/Monitoring";
import { Applications } from "./pages/Applications";
import { Settings } from "./pages/Settings";
import { UsersPage } from "./pages/Users";
import { FileBrowser } from "./pages/FileBrowser";
import { Actions } from "./pages/Actions";
import BackupsPage from "./components/BackupsPage";
import { ObservabilityPage } from "./components/ObservabilityPage";
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
import { fetchAppVersion } from "./api/client";
import { FRONTEND_VERSION_LABEL } from "./version";
@@ -80,7 +81,7 @@ function useDarkMode() {
// Navigation items for sidebar
const navItems = [
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
{ path: "/monitoring", label: "Monitoring", icon: Activity },
{ path: "/observability", label: "Observability", icon: Activity },
{ path: "/applications", label: "Media", icon: Monitor },
{ path: "/files", label: "Files", icon: FolderOpen },
{ path: "/users", label: "Users", icon: Users },
@@ -114,9 +115,7 @@ function Sidebar({
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground">
M
</div>
{!collapsed && (
<span className="font-semibold">Manage</span>
)}
{!collapsed && <span className="font-semibold">Manage</span>}
</div>
<Button
variant="ghost"
@@ -155,9 +154,7 @@ function Sidebar({
<Icon className="h-5 w-5" />
</NavLink>
</TooltipTrigger>
<TooltipContent side="right">
{item.label}
</TooltipContent>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
) : (
<NavLink
@@ -313,8 +310,8 @@ function ShellLayout({
onToggleDarkMode: () => void;
}) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [isMobile, setIsMobile] = useState(() =>
window.matchMedia("(max-width: 768px)").matches,
const [isMobile, setIsMobile] = useState(
() => window.matchMedia("(max-width: 768px)").matches,
);
useEffect(() => {
@@ -435,13 +432,14 @@ function AppInner() {
<Routes>
<Route element={<AuthenticatedApp />}>
<Route path="/" element={<Dashboard />} />
<Route path="/monitoring" element={<Monitoring />} />
<Route path="/monitoring" element={<Navigate to="/observability" replace />} />
<Route path="/applications" element={<Applications />} />
<Route path="/media" element={<Applications />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/actions" element={<Actions />} />
<Route path="/files" element={<FileBrowser />} />
<Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Routes>
@@ -459,13 +457,14 @@ function AppInner() {
}
>
<Route path="/" element={<Dashboard />} />
<Route path="/monitoring" element={<Monitoring />} />
<Route path="/monitoring" element={<Navigate to="/observability" replace />} />
<Route path="/applications" element={<Applications />} />
<Route path="/media" element={<Applications />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/actions" element={<Actions />} />
<Route path="/files" element={<FileBrowser />} />
<Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Routes>
+13 -54
View File
@@ -10,12 +10,7 @@ import type {
UserMessageResponse,
UserMessageQueueStatus,
NowPlayingSession,
MonitoringPollerStatus,
MonitoringOverviewResponse,
AppVersionInfo,
MonitoringStatus,
MonitoringMetrics,
DiskSpace,
SSHKey,
SSHKeyInput,
SSHKeyGenerated,
@@ -24,7 +19,6 @@ import type {
SavedTaskRun,
MonitoringMachine,
MonitoringMachineInput,
MonitoringMachineAction,
MediaIndexStatus,
MediaIndexActionResponse,
MediaQueryResponse,
@@ -37,6 +31,9 @@ import type {
SSHValidationResult,
DashboardShortcut,
DashboardShortcutInput,
AlertmanagerAlertSummary,
AlertmanagerStatus,
PrometheusTarget,
} from "../types";
const BASE_URL = import.meta.env.VITE_API_URL || "/api";
@@ -165,11 +162,7 @@ export const fetchNowPlaying = fetchActivity;
// Monitoring
export const fetchMonitoringMachines = () =>
get<MonitoringMachine[]>("/api/monitoring/machines");
export const fetchMonitoringPoller = () =>
get<MonitoringPollerStatus>("/api/monitoring/poller");
export const fetchAppVersion = () => get<AppVersionInfo>("/api/version");
export const fetchMonitoringOverview = () =>
get<MonitoringOverviewResponse>("/api/dashboard/monitoring");
export const fetchDashboardShortcuts = () =>
get<DashboardShortcut[]>("/api/dashboard/shortcuts");
export const saveDashboardShortcut = (shortcut: DashboardShortcutInput) =>
@@ -194,45 +187,6 @@ export const deleteDashboardShortcut = (shortcutId: string) =>
del<{ status: string }>(
`/api/dashboard/shortcuts/${encodeURIComponent(shortcutId)}`,
);
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 = (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 fetchSSHKeys = () => get<SSHKey[]>("/api/settings/ssh-keys");
@@ -242,11 +196,6 @@ export const generateSSHKey = (payload: {
notes: string;
bits?: number;
}) => post<SSHKeyGenerated>("/api/settings/ssh-keys/generate", payload);
export const fetchMonitoringMachineActions = (machineId: string, limit = 10) =>
get<{ items: MonitoringMachineAction[]; total: number }>(
`/api/monitoring/machines/${encodeURIComponent(machineId)}/actions`,
{ limit: String(limit) },
);
export const saveSSHKey = (key: SSHKeyInput) =>
fetch(
buildUrl(
@@ -428,3 +377,13 @@ export const fetchUserMessageQueueStatus = () =>
export const sendUserMessage = (formData: FormData) =>
postForm<UserMessageResponse>("/api/users/message", formData);
// Observability summary endpoints
export const fetchAlertmanagerAlerts = () =>
get<AlertmanagerAlertSummary>("/api/monitoring/alerts");
export const fetchAlertmanagerStatus = () =>
get<AlertmanagerStatus>("/api/monitoring/alertmanager-status");
export const fetchPrometheusTargets = () =>
get<PrometheusTarget[]>("/api/monitoring/prometheus-targets");
@@ -1,354 +0,0 @@
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>
);
}
File diff suppressed because it is too large Load Diff
@@ -1 +0,0 @@
export { MonitoringCharts } from "./MonitoringCharts.impl";
@@ -1,716 +0,0 @@
import { useMemo, useState } from "react";
import {
Box,
Chip,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TableSortLabel,
Tooltip,
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 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 formatReadableTime(epochSeconds: number | null): string {
if (!epochSeconds) return "-";
const date = new Date(epochSeconds * 1000);
return date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
function formatClockTime(epochSeconds: number | null): string {
if (!epochSeconds) return "-";
const date = new Date(epochSeconds * 1000);
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
function formatUpdatedDetails(epochSeconds: number | null): [string, string] {
if (!epochSeconds) return ["-", "-"];
return [
formatReadableTime(epochSeconds),
`${formatClockTime(epochSeconds)} · ${formatAge(epochSeconds)}`,
];
}
function formatSummary(
summary: { avg: number; min: number; max: number } | null,
formatter: (value: number) => string,
) {
if (!summary) return { value: "-", min: "", max: "" };
return {
value: formatter(summary.avg),
min: formatter(summary.min),
max: formatter(summary.max),
};
}
function sortableText(value: string) {
return value.toLowerCase();
}
function getStatusChipProps(row: MonitoringMachineOverview) {
if (row.status_error) {
return { color: "error" as const, label: row.status_error };
}
if (row.status === "running") {
return { color: "success" as const, label: "running" };
}
if (row.status === "not running") {
return { color: "warning" as const, label: "not running" };
}
return { color: "default" as const, label: row.status || "-" };
}
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 NoDataCell() {
return (
<Stack
sx={{
width: "100%",
minWidth: 0,
height: "100%",
minHeight: 118,
textAlign: "center",
justifyContent: "center",
py: 0.5,
}}
>
<Typography
variant="caption"
color="text.secondary"
sx={{
fontSize: "0.7rem",
fontStyle: "italic",
lineHeight: 1.2,
}}
>
No data
</Typography>
</Stack>
);
}
function MetricCell({
value,
min,
max,
}: {
value: string;
min?: string;
max?: string;
}) {
return (
<Stack
sx={{
width: "100%",
minWidth: 0,
height: "100%",
minHeight: 118,
textAlign: "center",
justifyContent: "space-between",
py: 0.5,
}}
>
<Box
sx={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
px: 1.5,
py: 1.25,
minHeight: 68,
borderRadius: 1,
}}
>
<Stack spacing={0.25} sx={{ alignItems: "center" }}>
<Typography
variant="caption"
color="text.secondary"
sx={{
fontSize: "0.6rem",
lineHeight: 1,
textTransform: "uppercase",
letterSpacing: "0.04em",
}}
>
10m avg
</Typography>
<Typography
variant="body1"
sx={{
fontWeight: 900,
fontSize: "1.12rem",
lineHeight: 1,
textAlign: "center",
whiteSpace: "nowrap",
fontVariantNumeric: "tabular-nums",
}}
>
{value}
</Typography>
</Stack>
</Box>
<Stack spacing={0.25} sx={{ width: "100%" }}>
{min ? (
<Box
sx={{
width: "100%",
border: 1,
borderColor: "divider",
borderRadius: 999,
px: 0.75,
py: 0.15,
fontSize: "0.6rem",
lineHeight: 1.2,
color: "text.secondary",
textAlign: "center",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
Min {min}
</Box>
) : null}
{max ? (
<Box
sx={{
width: "100%",
border: 1,
borderColor: "divider",
borderRadius: 999,
px: 0.75,
py: 0.15,
fontSize: "0.6rem",
lineHeight: 1.2,
color: "text.secondary",
textAlign: "center",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
Max {max}
</Box>
) : null}
</Stack>
</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 ? "running" : "stopped"}
/>
<Chip
size="small"
variant="outlined"
label={`Machines: ${overview?.enabled ?? 0}/${overview?.total ?? 0}`}
/>
<Typography variant="caption" color="text.secondary">
Last success:{" "}
{poller?.last_success_at
? new Date(poller.last_success_at * 1000).toLocaleString()
: "-"}{" "}
· 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,
overflowX: "auto",
overflowY: "hidden",
}
: {
maxWidth: "100%",
overflowX: "auto",
}
}
>
<Table
size="small"
sx={{
minWidth: 1500,
tableLayout: "fixed",
"& .MuiTableCell-root": {
px: 1.1,
py: 0.9,
verticalAlign: "middle",
},
"& .MuiTableHead .MuiTableCell-root": {
fontSize: "0.7rem",
fontWeight: 700,
lineHeight: 1.15,
whiteSpace: "nowrap",
},
}}
>
<TableHead>
<TableRow>
<TableCell
sx={{ width: 260 }}
sortDirection={sortKey === "machine" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "machine"}
direction={sortKey === "machine" ? sortDirection : "asc"}
onClick={() => setSort("machine")}
>
Machine
</TableSortLabel>
</TableCell>
<TableCell
sx={{ width: 90 }}
sortDirection={sortKey === "mode" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "mode"}
direction={sortKey === "mode" ? sortDirection : "asc"}
onClick={() => setSort("mode")}
>
Mode
</TableSortLabel>
</TableCell>
<TableCell
sx={{ width: 120 }}
sortDirection={sortKey === "status" ? sortDirection : false}
>
<TableSortLabel
active={sortKey === "status"}
direction={sortKey === "status" ? sortDirection : "asc"}
onClick={() => setSort("status")}
>
Status
</TableSortLabel>
</TableCell>
{[
["cpu", "CPU"],
["iowait", "IO wait"],
["mem", "RAM"],
["net_rx", "Net down"],
["net_tx", "Net up"],
["disk_read", "Disk read"],
["disk_write", "Disk write"],
["disk_used", "Disk used"],
].map(([key, label]) => (
<TableCell
key={key}
align="center"
sx={{ width: 150 }}
sortDirection={sortKey === key ? sortDirection : false}
>
<TableSortLabel
active={sortKey === key}
direction={sortKey === key ? sortDirection : "asc"}
onClick={() => setSort(key as SortKey)}
>
{label}
</TableSortLabel>
</TableCell>
))}
<TableCell
sx={{ width: 180 }}
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 hasMetrics = row.sample_count > 0;
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,
);
const disk = row.disk
? {
value: row.disk.used_pct,
min: `Used ${formatBytes(row.disk.used)}`,
max: `Avail ${formatBytes(row.disk.available)}`,
}
: null;
const updated = formatUpdatedDetails(
row.latest_sample?.ts ?? null,
);
return (
<TableRow key={machine.id}>
<TableCell sx={{ width: 260 }}>
<Stack spacing={0.25} sx={{ minWidth: 0 }}>
<Stack
direction="row"
spacing={0.75}
sx={{
alignItems: "center",
flexWrap: "nowrap",
minWidth: 0,
}}
>
<Typography
variant="body2"
sx={{
fontWeight: 700,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{machine.name}
</Typography>
{!machine.enabled && (
<Chip
size="small"
label="disabled"
variant="outlined"
/>
)}
</Stack>
<Typography
variant="caption"
color="text.secondary"
sx={{
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{machine.mode === "local"
? "Local API host"
: `${machine.username || "user"}@${machine.host || "host"}:${machine.port}`}
</Typography>
</Stack>
</TableCell>
<TableCell sx={{ width: 90 }}>{machine.mode}</TableCell>
<TableCell sx={{ width: 120 }}>
{(() => {
const chip = getStatusChipProps(row);
return (
<Tooltip
title={
row.metrics_error
? `Metrics: ${row.metrics_error}`
: row.sample_count === 0
? "No metrics collected yet. Start the collector to begin monitoring."
: ""
}
arrow
>
<Chip
size="small"
variant="outlined"
color={chip.color}
label={chip.label}
/>
</Tooltip>
);
})()}
</TableCell>
<TableCell align="center">
{hasMetrics ? (
<MetricCell
value={cpu.value}
min={cpu.min}
max={cpu.max}
/>
) : (
<NoDataCell />
)}
</TableCell>
<TableCell align="center">
{hasMetrics ? (
<MetricCell
value={iowait.value}
min={iowait.min}
max={iowait.max}
/>
) : (
<NoDataCell />
)}
</TableCell>
<TableCell align="center">
{hasMetrics ? (
<MetricCell
value={mem.value}
min={mem.min}
max={mem.max}
/>
) : (
<NoDataCell />
)}
</TableCell>
<TableCell align="center">
{hasMetrics ? (
<MetricCell
value={netDown.value}
min={netDown.min}
max={netDown.max}
/>
) : (
<NoDataCell />
)}
</TableCell>
<TableCell align="center">
{hasMetrics ? (
<MetricCell
value={netUp.value}
min={netUp.min}
max={netUp.max}
/>
) : (
<NoDataCell />
)}
</TableCell>
<TableCell align="center">
{hasMetrics ? (
<MetricCell
value={diskRead.value}
min={diskRead.min}
max={diskRead.max}
/>
) : (
<NoDataCell />
)}
</TableCell>
<TableCell align="center">
{hasMetrics ? (
<MetricCell
value={diskWrite.value}
min={diskWrite.min}
max={diskWrite.max}
/>
) : (
<NoDataCell />
)}
</TableCell>
<TableCell align="center">
{disk ? (
<MetricCell
value={disk.value}
min={disk.min}
max={disk.max}
/>
) : hasMetrics ? (
"-"
) : (
<NoDataCell />
)}
</TableCell>
<TableCell sx={{ width: 180 }}>
<Stack spacing={0.1} sx={{ alignItems: "center" }}>
{hasMetrics ? (
updated.map((line) => (
<Typography
key={line}
variant="caption"
color="text.secondary"
sx={{ lineHeight: 1.1 }}
>
{line}
</Typography>
))
) : (
<Typography
variant="caption"
color="text.secondary"
sx={{ lineHeight: 1.1, fontStyle: "italic" }}
>
No data yet
</Typography>
)}
</Stack>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
</Stack>
);
}
@@ -0,0 +1,698 @@
import {
Component,
useEffect,
useMemo,
useRef,
useState,
type ElementType,
type ErrorInfo,
type ReactNode,
} from "react";
import { Link } from "react-router-dom";
import {
Activity,
AlertTriangle,
Bell,
CheckCircle2,
ChevronDown,
ExternalLink,
Inbox,
PanelTop,
Radio,
RefreshCw,
Server,
ServerOff,
XCircle,
} from "lucide-react";
import {
useAlertmanagerAlerts,
useAlertmanagerStatus,
usePrometheusTargets,
useMonitoringMachines,
} from "../hooks/useObservability";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type {
AlertmanagerAlert,
MonitoringMachine,
PrometheusTarget,
} from "../types";
function severityVariant(
severity: string,
): "default" | "secondary" | "destructive" | "outline" {
switch (severity.toLowerCase()) {
case "critical":
return "destructive";
case "warning":
return "default";
case "info":
return "secondary";
default:
return "outline";
}
}
function HealthCard({
title,
status,
detail,
icon: Icon,
isLoading,
}: {
title: string;
status: "ok" | "warning" | "error" | "unknown";
detail: string;
icon: ElementType;
isLoading?: boolean;
}) {
const statusIcon =
status === "ok" ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : status === "warning" ? (
<AlertTriangle className="h-5 w-5 text-amber-500" />
) : status === "error" ? (
<XCircle className="h-5 w-5 text-red-500" />
) : (
<Radio className="h-5 w-5 text-muted-foreground" />
);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
{isLoading ? <Skeleton className="h-5 w-5" /> : statusIcon}
<span className="text-2xl font-bold capitalize">{status}</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">{detail}</p>
</CardContent>
</Card>
);
}
function EmptyState({
icon: Icon,
title,
description,
action,
}: {
icon: ElementType;
title: string;
description: string;
action?: ReactNode;
}) {
return (
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
<Icon className="h-8 w-8 text-muted-foreground" />
<div className="font-medium">{title}</div>
<div className="max-w-md text-sm text-muted-foreground">
{description}
</div>
{action ? <div className="mt-2">{action}</div> : null}
</div>
);
}
function QueryError({
label,
error,
refetch,
}: {
label: string;
error: Error | null;
refetch: () => void;
}) {
if (!error) return null;
return (
<Alert variant="destructive">
<AlertTitle>{label} failed</AlertTitle>
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span className="break-words">{error.message}</span>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="mr-1 h-3 w-3" />
Retry
</Button>
</AlertDescription>
</Alert>
);
}
function AlertItem({ alert }: { alert: AlertmanagerAlert }) {
return (
<Collapsible>
<CollapsibleTrigger asChild>
<div className="group cursor-pointer rounded-lg border p-3 transition-colors hover:bg-muted/50">
<div className="flex items-start justify-between gap-2">
<div className="font-medium text-sm">{alert.name}</div>
<div className="flex items-center gap-1">
<Badge variant={severityVariant(alert.severity)}>
{alert.severity}
</Badge>
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</div>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{alert.summary || alert.description}
</div>
{alert.active_since && (
<div className="mt-1 text-[10px] text-muted-foreground">
Since {new Date(alert.active_since).toLocaleString()}
</div>
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="space-y-2 rounded-b-lg border-x border-b p-3 text-sm">
{alert.description && (
<div>
<span className="font-medium">Description:</span>{" "}
{alert.description}
</div>
)}
<div className="grid grid-cols-2 gap-2 text-xs">
{alert.job_name && (
<div>
<span className="font-medium">Job:</span> {alert.job_name}
</div>
)}
{alert.category && (
<div>
<span className="font-medium">Category:</span> {alert.category}
</div>
)}
<div>
<span className="font-medium">State:</span> {alert.state}
</div>
<div>
<span className="font-medium">Since:</span>{" "}
{alert.active_since
? new Date(alert.active_since).toLocaleString()
: "unknown"}
</div>
</div>
{alert.labels && Object.keys(alert.labels).length > 0 && (
<div className="flex flex-wrap gap-1 pt-1">
{Object.entries(alert.labels).map(([key, value]) => (
<Badge key={key} variant="secondary" className="text-[10px]">
{key}={value}
</Badge>
))}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}
function TargetsTable({ targets }: { targets: PrometheusTarget[] }) {
return (
<div className="space-y-3">
{targets.map((target, idx) => (
<div key={idx} className="rounded-lg border p-3">
<div className="font-mono text-sm">{target.targets.join(", ")}</div>
{target.labels && Object.keys(target.labels).length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{Object.entries(target.labels).map(([key, value]) => (
<Badge key={key} variant="outline" className="text-[10px]">
{key}: {value}
</Badge>
))}
</div>
)}
</div>
))}
</div>
);
}
class GrafanaErrorBoundary extends Component<
{ children: ReactNode; fallback: ReactNode },
{ hasError: boolean }
> {
constructor(props: { children: ReactNode; fallback: ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Grafana panel error:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
function GrafanaPanel({ src, title }: { src: string; title: string }) {
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);
const [iframeKey, setIframeKey] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
timerRef.current = setTimeout(() => setFailed(true), 10_000);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [iframeKey]);
const handleLoad = () => {
if (timerRef.current) clearTimeout(timerRef.current);
setLoaded(true);
setFailed(false);
};
const handleError = () => {
if (timerRef.current) clearTimeout(timerRef.current);
setFailed(true);
};
const reload = () => {
setLoaded(false);
setFailed(false);
setIframeKey((k) => k + 1);
};
if (!src) {
return (
<EmptyState
icon={PanelTop}
title="No Grafana URL"
description="Select a machine to load a Grafana panel."
/>
);
}
const fallback = (
<EmptyState
icon={PanelTop}
title="Grafana panel unavailable"
description="The panel did not load in time or Grafana is unreachable."
action={
<div className="flex flex-wrap justify-center gap-2">
<Button variant="outline" size="sm" onClick={reload}>
<RefreshCw className="mr-1 h-3 w-3" />
Reload
</Button>
<Button variant="outline" size="sm" asChild>
<a href={src} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-1 h-3 w-3" />
Open in Grafana
</a>
</Button>
</div>
}
/>
);
return (
<GrafanaErrorBoundary key={iframeKey} fallback={fallback}>
<div className="relative h-full min-h-[320px] w-full overflow-hidden rounded-md border">
{!loaded && !failed && (
<div className="absolute inset-0 z-10 p-4">
<Skeleton className="h-full w-full" />
</div>
)}
{failed ? (
<div className="absolute inset-0 z-10 bg-background p-2">
{fallback}
</div>
) : (
<iframe
key={iframeKey}
title={title}
src={src}
className="h-full min-h-[320px] w-full"
allow="fullscreen"
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
onLoad={handleLoad}
onError={handleError}
/>
)}
</div>
</GrafanaErrorBoundary>
);
}
export function ObservabilityPage() {
const {
data: alertsSummary,
isLoading: alertsLoading,
error: alertsError,
refetch: refetchAlerts,
} = useAlertmanagerAlerts();
const {
data: alertmanagerStatus,
isLoading: statusLoading,
error: statusError,
refetch: refetchStatus,
} = useAlertmanagerStatus();
const {
data: prometheusTargets,
isLoading: targetsLoading,
error: targetsError,
refetch: refetchTargets,
} = usePrometheusTargets();
const {
data: machines = [],
isLoading: machinesLoading,
error: machinesError,
refetch: refetchMachines,
} = useMonitoringMachines();
const [selectedMachineId, setSelectedMachineId] = useState<string>("");
const selectedMachine = useMemo<MonitoringMachine | null>(
() =>
machines.find((m) => m.id === selectedMachineId) ?? machines[0] ?? null,
[machines, selectedMachineId],
);
const grafanaBase = "/grafana";
const nodeExporterDashboardUrl = useMemo(() => {
if (!selectedMachine) return "";
const instance = `${selectedMachine.host || "localhost"}:9100`;
return `${grafanaBase}/d/node-exporter-overview/node-exporter-overview?kiosk&var-instance=${encodeURIComponent(instance)}`;
}, [selectedMachine]);
const logsUrl = useMemo(() => {
if (!selectedMachine) return "";
const container =
selectedMachine.mode === "local" ? "backend" : selectedMachine.name;
return `${grafanaBase}/explore?orgId=1&left=${encodeURIComponent(
JSON.stringify({
datasource: "Loki",
queries: [{ refId: "A", expr: `{container="${container}"}` }],
range: { from: "now-1h", to: "now" },
}),
)}`;
}, [selectedMachine]);
const alertmanagerStatusDetail = alertmanagerStatus?.up
? alertmanagerStatus.version
? `version ${alertmanagerStatus.version}`
: "reachable"
: "unreachable";
const targetsCount = prometheusTargets?.length ?? 0;
const targetsStatus: "ok" | "warning" | "error" | "unknown" = targetsLoading
? "unknown"
: targetsError
? "error"
: targetsCount > 0
? "ok"
: "warning";
const alertStatus: "ok" | "warning" | "error" | "unknown" = alertsLoading
? "unknown"
: alertsError
? "error"
: (alertsSummary?.total ?? 0) > 0
? alertsSummary?.alerts.some((a) => a.severity === "critical")
? "error"
: "warning"
: "ok";
const machinesStatus: "ok" | "warning" | "error" | "unknown" = machinesLoading
? "unknown"
: machinesError
? "error"
: machines.length > 0
? "ok"
: "warning";
return (
<div className="space-y-6">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">Observability</h1>
<p className="text-sm text-muted-foreground">
Unified view of metrics, logs, and alerts from Prometheus, Grafana,
Loki, and Alertmanager.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<HealthCard
title="Alertmanager"
status={
statusError
? "error"
: alertmanagerStatus?.up
? "ok"
: statusLoading
? "unknown"
: "error"
}
detail={alertmanagerStatusDetail}
icon={Bell}
isLoading={statusLoading}
/>
<HealthCard
title="Active Alerts"
status={alertStatus}
detail={`${alertsSummary?.total ?? 0} firing alert${(alertsSummary?.total ?? 0) === 1 ? "" : "s"}`}
icon={AlertTriangle}
isLoading={alertsLoading}
/>
<HealthCard
title="Prometheus Targets"
status={targetsStatus}
detail={`${targetsCount} remote Node Exporter target${targetsCount === 1 ? "" : "s"}`}
icon={Radio}
isLoading={targetsLoading}
/>
<HealthCard
title="Machines"
status={machinesStatus}
detail={`${machines.length} monitoring machine${machines.length === 1 ? "" : "s"}`}
icon={Server}
isLoading={machinesLoading}
/>
</div>
<div className="space-y-3">
{statusError && (
<QueryError
label="Alertmanager status"
error={statusError}
refetch={refetchStatus}
/>
)}
{alertsError && (
<QueryError
label="Active alerts"
error={alertsError}
refetch={refetchAlerts}
/>
)}
{targetsError && (
<QueryError
label="Prometheus targets"
error={targetsError}
refetch={refetchTargets}
/>
)}
{machinesError && (
<QueryError
label="Monitoring machines"
error={machinesError}
refetch={refetchMachines}
/>
)}
</div>
{alertsSummary?.error && (
<Alert variant="destructive">
<AlertTitle>Alertmanager unreachable</AlertTitle>
<AlertDescription>
The UI cannot reach Alertmanager right now. Alerts shown here may be
stale.
</AlertDescription>
</Alert>
)}
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-4 w-4" />
Recent Alerts
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{alertsLoading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : !alertsSummary || alertsSummary.total === 0 ? (
<EmptyState
icon={Inbox}
title="No active alerts"
description="Everything looks quiet. Alertmanager will list firing alerts here when they occur."
/>
) : (
<>
{alertsSummary.alerts.map((alert, idx) => (
<AlertItem key={`${alert.name}-${idx}`} alert={alert} />
))}
{alertsSummary.total > alertsSummary.alerts.length && (
<div className="text-center text-xs text-muted-foreground">
{alertsSummary.total - alertsSummary.alerts.length} more
alert
{alertsSummary.total - alertsSummary.alerts.length === 1
? ""
: "s"}{" "}
in Alertmanager
</div>
)}
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Radio className="h-4 w-4" />
Prometheus Targets
</CardTitle>
</CardHeader>
<CardContent>
{targetsLoading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : !prometheusTargets || prometheusTargets.length === 0 ? (
<EmptyState
icon={Radio}
title="No Node Exporter targets"
description="Enable Node Exporter on an SSH machine in Settings to populate Prometheus scrape targets."
action={
<Button variant="outline" size="sm" asChild>
<Link to="/settings">Open Settings</Link>
</Button>
}
/>
) : (
<TargetsTable targets={prometheusTargets} />
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="flex items-center gap-2">
<Activity className="h-4 w-4" />
Machine Dashboard
</CardTitle>
<Select
value={selectedMachine?.id ?? ""}
onValueChange={setSelectedMachineId}
disabled={machines.length === 0}
>
<SelectTrigger className="w-full sm:w-[240px]">
<SelectValue placeholder="Select machine" />
</SelectTrigger>
<SelectContent>
{machines.map((machine) => (
<SelectItem key={machine.id} value={machine.id}>
{machine.name}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent className="space-y-4">
{selectedMachine ? (
<>
<div className="flex items-center justify-between">
<div className="text-sm font-medium">
{selectedMachine.name} metrics
</div>
<Button variant="outline" size="sm" asChild>
<a
href={nodeExporterDashboardUrl}
target="_blank"
rel="noopener noreferrer"
className="gap-1"
>
Open in Grafana
<ExternalLink className="h-3 w-3" />
</a>
</Button>
</div>
<GrafanaPanel
key={nodeExporterDashboardUrl}
src={nodeExporterDashboardUrl}
title={`${selectedMachine.name} metrics`}
/>
<div className="flex items-center justify-between pt-2">
<div className="text-sm font-medium">Recent logs</div>
<Button variant="outline" size="sm" asChild>
<a
href={logsUrl}
target="_blank"
rel="noopener noreferrer"
className="gap-1"
>
Explore in Grafana
<ExternalLink className="h-3 w-3" />
</a>
</Button>
</div>
<GrafanaPanel
key={logsUrl}
src={logsUrl}
title={`${selectedMachine.name} logs`}
/>
</>
) : (
<EmptyState
icon={ServerOff}
title="No machine selected"
description="Add monitoring machines in Settings to embed Grafana dashboards."
action={
<Button variant="outline" size="sm" asChild>
<Link to="/settings">Open Settings</Link>
</Button>
}
/>
)}
</CardContent>
</Card>
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+49
View File
@@ -0,0 +1,49 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
export function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
Badge.displayName = "Badge";
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+190
View File
@@ -0,0 +1,190 @@
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
-9
View File
@@ -5,7 +5,6 @@ import {
fetchCounts,
fetchDashboardShortcuts,
fetchLibraries,
fetchMonitoringOverview,
saveDashboardShortcut,
} from "../api/client";
import type { DashboardShortcutInput } from "../types";
@@ -34,14 +33,6 @@ export function useActivity(machineId?: string) {
});
}
export function useMonitoringOverview() {
return useQuery({
queryKey: ["dashboard", "monitoring"],
queryFn: fetchMonitoringOverview,
refetchInterval: 30_000,
});
}
// Backward-compatible alias used by older code.
export const useNowPlaying = useActivity;
-85
View File
@@ -1,85 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
fetchMonitoringStatus,
fetchMonitoringMetrics,
fetchDiskSpace,
fetchMonitoringMachines,
fetchMonitoringMachineActions,
fetchMonitoringPoller,
startCollector,
stopCollector,
restartCollector,
} from "../api/client";
export function useMonitoringMachines() {
return useQuery({
queryKey: ["monitoring", "machines"],
queryFn: fetchMonitoringMachines,
refetchInterval: 30_000,
});
}
export function useMonitoringPoller() {
return useQuery({
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(machineId?: string, enabled = true) {
return useQuery({
queryKey: ["monitoring", "disk", machineId ?? "default"],
queryFn: () => fetchDiskSpace(machineId),
staleTime: 60_000,
enabled,
});
}
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(machineId),
onSuccess: invalidate,
});
const stop = useMutation({
mutationFn: () => stopCollector(machineId),
onSuccess: invalidate,
});
const restart = useMutation({
mutationFn: () => restartCollector(machineId),
onSuccess: invalidate,
});
return { start, stop, restart };
}
+47
View File
@@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import {
fetchAlertmanagerAlerts,
fetchAlertmanagerStatus,
fetchPrometheusTargets,
fetchMonitoringMachines,
} from "../api/client";
export function useAlertmanagerAlerts() {
return useQuery({
queryKey: ["observability", "alerts"],
queryFn: fetchAlertmanagerAlerts,
retry: 2,
staleTime: 10_000,
refetchInterval: 15_000,
});
}
export function useAlertmanagerStatus() {
return useQuery({
queryKey: ["observability", "alertmanager-status"],
queryFn: fetchAlertmanagerStatus,
retry: 2,
staleTime: 10_000,
refetchInterval: 30_000,
});
}
export function usePrometheusTargets() {
return useQuery({
queryKey: ["observability", "prometheus-targets"],
queryFn: fetchPrometheusTargets,
retry: 2,
staleTime: 10_000,
refetchInterval: 30_000,
});
}
export function useMonitoringMachines() {
return useQuery({
queryKey: ["monitoring", "machines"],
queryFn: fetchMonitoringMachines,
retry: 2,
staleTime: 10_000,
refetchInterval: 30_000,
});
}
-10
View File
@@ -26,13 +26,11 @@ import {
useActivity,
useDashboardShortcuts,
useDeleteDashboardShortcut,
useMonitoringOverview,
useSaveDashboardShortcut,
} from "../hooks/useDashboard";
import { useMonitoringSettings } from "../hooks/useSettings";
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
import { NowPlaying } from "../components/NowPlaying";
import { MonitoringOverviewTable } from "../components/MonitoringOverviewTable";
import { SectionCard } from "../components/SectionCard";
import { DialogFooter } from "../components/DialogFooter";
import BackupDashboardWidget from "../components/BackupDashboardWidget";
@@ -325,7 +323,6 @@ export function Dashboard() {
const selectedJellyfinId =
activeJellyfinMachineId || jellyfinMachines[0]?.id || "";
const { data: activity } = useActivity(selectedJellyfinId || undefined);
const { data: monitoringOverview } = useMonitoringOverview();
const { data: shortcuts = [] } = useDashboardShortcuts();
const saveShortcut = useSaveDashboardShortcut();
const deleteShortcut = useDeleteDashboardShortcut();
@@ -438,13 +435,6 @@ export function Dashboard() {
) : null}
</SectionCard>
<SectionCard
title="Monitoring overview"
description="10-minute averages and fleet status across all configured machines."
>
<MonitoringOverviewTable overview={monitoringOverview} embedded />
</SectionCard>
<BackupDashboardWidget />
<ShortcutDialog
-106
View File
@@ -1,106 +0,0 @@
import { useMemo, useState } from "react";
import { Alert, Button, Stack, Tab, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
import {
useMonitoringMachines,
useMonitoringPoller,
} from "../hooks/useMonitoring";
import { useMonitoringOverview } from "../hooks/useDashboard";
import { MachineMonitoringSection } from "../components/MachineMonitoringSection";
import { MonitoringOverviewTable } from "../components/MonitoringOverviewTable";
import { SectionCard } from "../components/SectionCard";
import { TabbedCard } from "../components/TabbedCard";
type MonitoringTab = "overview" | string;
export function Monitoring() {
const { data: machines = [], isLoading, error } = useMonitoringMachines();
const { data: poller } = useMonitoringPoller();
const { data: overview } = useMonitoringOverview();
const navigate = useNavigate();
const [tab, setTab] = useState<MonitoringTab>("overview");
const selectedMachine = useMemo(
() => machines.find((machine) => machine.id === tab) ?? null,
[machines, tab],
);
return (
<Stack spacing={2.25}>
<Stack spacing={0.5}>
<Typography variant="h5" sx={{ fontWeight: 800 }}>
Monitoring
</Typography>
<Typography variant="body2" color="text.secondary">
Review fleet health first, then switch to a machine-specific tab.
</Typography>
</Stack>
{error ? <Alert severity="error">{String(error)}</Alert> : null}
{isLoading ? (
<Alert severity="info">Loading monitoring machines...</Alert>
) : null}
<TabbedCard
value={tab}
onChange={setTab}
tabs={[
<Tab key="overview" value="overview" label="Overview" />,
...machines.map((machine) => (
<Tab key={machine.id} value={machine.id} label={machine.name} />
)),
]}
contentSx={{ p: 1.5 }}
>
{tab === "overview" ? (
<Stack spacing={1.5}>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Fleet overview
</Typography>
<Typography variant="caption" color="text.secondary">
{poller?.worker_running ? "running" : "stopped"}
</Typography>
</Stack>
{!isLoading && machines.length === 0 ? (
<Alert
severity="warning"
action={
<Button
color="inherit"
size="small"
onClick={() => navigate("/settings")}
>
Open Settings
</Button>
}
>
No monitoring machines are configured yet.
</Alert>
) : overview ? (
<MonitoringOverviewTable overview={overview} embedded />
) : (
<Alert severity="info">Loading fleet overview...</Alert>
)}
</Stack>
) : selectedMachine ? (
<SectionCard
title={selectedMachine.name}
description={
selectedMachine.mode === "local"
? "Local API host"
: `${selectedMachine.username || "user"}@${selectedMachine.host || "host"}:${selectedMachine.port}`
}
>
<MachineMonitoringSection machine={selectedMachine} />
</SectionCard>
) : null}
</TabbedCard>
</Stack>
);
}
+32 -83
View File
@@ -246,28 +246,6 @@ export interface SavedTaskRun {
error: 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 ResetLocalDatabaseInput {
confirm_phrase: string;
acknowledge_settings_loss: boolean;
@@ -291,20 +269,6 @@ export interface SSHValidationResult {
known_hosts_updated: boolean;
}
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 AppVersionInfo {
app: string;
backend_version: string;
@@ -312,53 +276,6 @@ export interface AppVersionInfo {
backend_label: string;
}
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;
}
export interface MonitoringSample {
ts: number;
cpu_pct: number;
iowait_pct?: number;
mem_pct: number;
net_rx_bytes_per_sec: number;
net_tx_bytes_per_sec: number;
disk_read_bps: number;
disk_write_bps: number;
}
export interface MonitoringMetrics {
samples: MonitoringSample[];
total_samples: number;
filtered_samples: number;
cutoff_ts: number;
}
export interface DiskSpace {
filesystem: string;
size: number;
@@ -503,3 +420,35 @@ export interface DashboardShortcutInput {
user_id: string;
notes: string;
}
export interface AlertmanagerAlert {
name: string;
severity: string;
category: string;
job_name: string;
summary: string;
description: string;
active_since: string;
state: string;
labels: Record<string, string>;
}
export interface AlertmanagerAlertSummary {
total: number;
by_severity: Record<string, number>;
alerts: AlertmanagerAlert[];
error?: string;
}
export interface AlertmanagerStatus {
up: boolean;
version: string;
uptime: string;
name: string;
peers: string[];
}
export interface PrometheusTarget {
labels: Record<string, string>;
targets: string[];
}
+5
View File
@@ -30,6 +30,11 @@ export default defineConfig(({ mode }) => {
changeOrigin: true,
secure: false,
},
"/grafana": {
target: "http://localhost:3000",
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/grafana/, ""),
},
},
},
resolve: {