feat(observability): add Prometheus/Grafana/Loki/Alertmanager/Alloy stack and remove legacy Monitoring UI
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user