Add FastAPI backend and React frontend subprojects
Backend: - FastAPI app with 17 REST endpoints covering dashboard, monitoring, media index, file browser, and jobs - Reuses existing clients/domain/services unchanged - pydantic-settings config, dependency injection, CORS setup - Auto-generated OpenAPI docs at /docs Frontend: - Vite + React + TypeScript SPA - @tanstack/react-query for data fetching with polling - ag-grid-react for media table and file browser - recharts for monitoring charts - Tailwind CSS styling - 4 pages: Dashboard, Monitoring, Media, File Browser - Typed API client matching all backend endpoints Also: - docs/MIGRATION_PLAN.md with full architecture plan - Updated .gitignore for both subprojects - Streamlit app preserved for now (can coexist)
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { useCounts, useLibraries, useNowPlaying } from "../hooks/useDashboard";
|
||||
import { useMonitoringMetrics, useDiskSpace } from "../hooks/useMonitoring";
|
||||
import { NowPlaying } from "../components/NowPlaying";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import { LibraryOverview } from "../components/LibraryOverview";
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let value = bytes;
|
||||
let unitIdx = 0;
|
||||
while (value >= 1000 && unitIdx < units.length - 1) {
|
||||
value /= 1000;
|
||||
unitIdx++;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[unitIdx]}`;
|
||||
}
|
||||
|
||||
function formatRate(bytes: number): string {
|
||||
return `${formatBytes(bytes)}/s`;
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const { data: counts } = useCounts();
|
||||
const { data: libraries } = useLibraries();
|
||||
const { data: nowPlaying } = useNowPlaying();
|
||||
const { data: metrics } = useMonitoringMetrics();
|
||||
const { data: disk } = useDiskSpace();
|
||||
|
||||
const latest = metrics?.samples?.at(-1);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Now Playing */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-3">Now playing</h2>
|
||||
{nowPlaying && <NowPlaying sessions={nowPlaying} />}
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
{/* Server Overview */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-3">Server overview</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
|
||||
<MetricCard
|
||||
label="CPU"
|
||||
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
|
||||
/>
|
||||
<MetricCard
|
||||
label="IO Wait"
|
||||
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
|
||||
/>
|
||||
<MetricCard
|
||||
label="RAM"
|
||||
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Net down"
|
||||
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Net up"
|
||||
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Disk read"
|
||||
value={latest ? formatRate(latest.disk_read_bps) : "-"}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Disk write"
|
||||
value={latest ? formatRate(latest.disk_write_bps) : "-"}
|
||||
/>
|
||||
</div>
|
||||
{disk && (
|
||||
<div className="mt-3 grid grid-cols-4 gap-3">
|
||||
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
|
||||
<MetricCard
|
||||
label="Disk available"
|
||||
value={formatBytes(disk.available)}
|
||||
/>
|
||||
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
|
||||
<MetricCard label="Used %" value={disk.used_pct} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
{/* Media Library Overview */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-3">Media library overview</h2>
|
||||
{counts && (
|
||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||
<MetricCard
|
||||
label="Total"
|
||||
value={(
|
||||
counts.movies +
|
||||
counts.series +
|
||||
counts.episodes
|
||||
).toLocaleString()}
|
||||
/>
|
||||
<MetricCard label="Movies" value={counts.movies.toLocaleString()} />
|
||||
<MetricCard label="Series" value={counts.series.toLocaleString()} />
|
||||
<MetricCard
|
||||
label="Episodes"
|
||||
value={counts.episodes.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{libraries && <LibraryOverview libraries={libraries} />}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import {
|
||||
useDirectoryListing,
|
||||
useFfprobe,
|
||||
useJobTemplates,
|
||||
useRunJob,
|
||||
} from "../hooks/useFiles";
|
||||
|
||||
interface DisplayRow {
|
||||
type: string;
|
||||
name: string;
|
||||
ext: string;
|
||||
size: string;
|
||||
modified: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return "-";
|
||||
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 formatTime(epoch: number): string {
|
||||
if (!epoch) return "";
|
||||
return new Date(epoch * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function isVideoFile(name: string): boolean {
|
||||
const exts = [
|
||||
".mkv",
|
||||
".mp4",
|
||||
".avi",
|
||||
".m4v",
|
||||
".ts",
|
||||
".wmv",
|
||||
".mov",
|
||||
".flv",
|
||||
".webm",
|
||||
];
|
||||
return exts.some((ext) => name.toLowerCase().endsWith(ext));
|
||||
}
|
||||
|
||||
export function FileBrowser() {
|
||||
const [currentDir, setCurrentDir] = useState("/");
|
||||
const [pathInput, setPathInput] = useState("/");
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
|
||||
const { data: listing, isLoading, error } = useDirectoryListing(currentDir);
|
||||
const { data: ffprobeData } = useFfprobe(
|
||||
selectedPath ?? "",
|
||||
!!selectedPath && isVideoFile(selectedPath),
|
||||
);
|
||||
const { data: templates } = useJobTemplates();
|
||||
const runJob = useRunJob();
|
||||
|
||||
const gridRef = useRef<AgGridReact<DisplayRow>>(null);
|
||||
|
||||
const navigate = useCallback((path: string) => {
|
||||
setCurrentDir(path);
|
||||
setPathInput(path);
|
||||
setSelectedPath(null);
|
||||
}, []);
|
||||
|
||||
const handlePathSubmit = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
navigate(pathInput || "/");
|
||||
}
|
||||
};
|
||||
|
||||
// Build display rows
|
||||
const rows: DisplayRow[] = [];
|
||||
if (currentDir !== "/") {
|
||||
const parent = currentDir.replace(/\/[^/]+\/?$/, "") || "/";
|
||||
rows.push({
|
||||
type: "up",
|
||||
name: "..",
|
||||
ext: "",
|
||||
size: "-",
|
||||
modified: "",
|
||||
path: parent,
|
||||
});
|
||||
}
|
||||
if (listing) {
|
||||
for (const entry of listing.entries) {
|
||||
const kind = entry.type === "d" ? "dir" : "file";
|
||||
const ext = kind === "file" ? (entry.name.split(".").pop() ?? "") : "";
|
||||
rows.push({
|
||||
type: kind,
|
||||
name: entry.name,
|
||||
ext,
|
||||
size: kind === "dir" ? "-" : formatSize(entry.size),
|
||||
modified: formatTime(entry.mtime),
|
||||
path: `${currentDir === "/" ? "" : currentDir}/${entry.name}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const columnDefs = [
|
||||
{ field: "type" as const, headerName: "Type", width: 80 },
|
||||
{ field: "name" as const, headerName: "Name", flex: 2 },
|
||||
{ field: "ext" as const, headerName: "Ext", width: 80 },
|
||||
{ field: "size" as const, headerName: "Size", width: 110 },
|
||||
{ field: "modified" as const, headerName: "Modified", width: 180 },
|
||||
];
|
||||
|
||||
const onRowClicked = useCallback(
|
||||
(event: { data?: DisplayRow }) => {
|
||||
const row = event.data;
|
||||
if (!row) return;
|
||||
if (row.type === "dir" || row.type === "up") {
|
||||
navigate(row.path);
|
||||
} else {
|
||||
setSelectedPath(row.path);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Path input */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={pathInput}
|
||||
onChange={(e) => setPathInput(e.target.value)}
|
||||
onKeyDown={handlePathSubmit}
|
||||
className="border rounded px-3 py-1 text-sm flex-1"
|
||||
placeholder="Remote path (press Enter to navigate)"
|
||||
/>
|
||||
<button
|
||||
onClick={() => navigate(pathInput || "/")}
|
||||
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex gap-6 text-xs text-gray-500">
|
||||
<span>
|
||||
Current: <code>{currentDir}</code>
|
||||
</span>
|
||||
{selectedPath && (
|
||||
<span>
|
||||
Selected: <code>{selectedPath}</code>
|
||||
</span>
|
||||
)}
|
||||
{listing && <span>Entries: {listing.count}</span>}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">Error: {String(error)}</p>}
|
||||
|
||||
{/* File listing grid */}
|
||||
<div className="ag-theme-alpine" style={{ height: 400, width: "100%" }}>
|
||||
<AgGridReact<DisplayRow>
|
||||
ref={gridRef}
|
||||
rowData={rows}
|
||||
columnDefs={columnDefs}
|
||||
rowSelection="single"
|
||||
onRowClicked={onRowClicked}
|
||||
loading={isLoading}
|
||||
suppressCellFocus
|
||||
animateRows={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ffprobe preview */}
|
||||
{selectedPath && isVideoFile(selectedPath) && (
|
||||
<section className="border rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold mb-2">
|
||||
ffprobe preview: <code className="text-xs">{selectedPath}</code>
|
||||
</h3>
|
||||
{ffprobeData ? (
|
||||
<pre className="text-xs bg-gray-50 p-3 rounded overflow-auto max-h-96">
|
||||
{JSON.stringify(ffprobeData, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Loading ffprobe data...</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Jobs */}
|
||||
{selectedPath && templates && templates.length > 0 && (
|
||||
<section className="border rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold mb-2">Jobs</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{templates.map((tpl) => (
|
||||
<button
|
||||
key={tpl.key}
|
||||
onClick={() =>
|
||||
runJob.mutate({ jobKey: tpl.key, path: selectedPath })
|
||||
}
|
||||
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
|
||||
title={tpl.description}
|
||||
>
|
||||
{tpl.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{runJob.data && (
|
||||
<pre className="text-xs bg-gray-50 p-3 rounded mt-3 overflow-auto max-h-48">
|
||||
Exit: {runJob.data.exit_status}
|
||||
{"\n"}
|
||||
{runJob.data.stdout}
|
||||
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
|
||||
</pre>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import {
|
||||
useMediaStatus,
|
||||
useMediaQuery,
|
||||
useBuildIndex,
|
||||
} from "../hooks/useMedia";
|
||||
import type { MediaItem } from "../types";
|
||||
|
||||
export function Media() {
|
||||
const { data: status } = useMediaStatus();
|
||||
const buildIndex = useBuildIndex();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [types, setTypes] = useState("Movie,Episode");
|
||||
const [hdrFilter, setHdrFilter] = useState("All");
|
||||
const [sortKey, setSortKey] = useState("title");
|
||||
const [sortOrder, setSortOrder] = useState("Ascending");
|
||||
const [limit] = useState(100);
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
const { data: queryResult, isLoading } = useMediaQuery({
|
||||
types,
|
||||
search,
|
||||
hdr_filter: hdrFilter,
|
||||
sort_key: sortKey,
|
||||
sort_order: sortOrder,
|
||||
limit,
|
||||
offset,
|
||||
enabled: status?.exists ?? false,
|
||||
});
|
||||
|
||||
const gridRef = useRef<AgGridReact<MediaItem>>(null);
|
||||
|
||||
const columnDefs = [
|
||||
{ field: "title" as const, headerName: "Title", minWidth: 150 },
|
||||
{ field: "series" as const, headerName: "Series", minWidth: 120 },
|
||||
{ field: "season" as const, headerName: "Season", maxWidth: 95 },
|
||||
{ field: "episode" as const, headerName: "Episode", maxWidth: 105 },
|
||||
{ field: "type" as const, headerName: "Type", maxWidth: 100 },
|
||||
{ field: "year" as const, headerName: "Year", maxWidth: 90 },
|
||||
{
|
||||
field: "runtime_min" as const,
|
||||
headerName: "Runtime (min)",
|
||||
maxWidth: 125,
|
||||
},
|
||||
{ field: "size" as const, headerName: "Size", maxWidth: 120 },
|
||||
{ field: "bitrate" as const, headerName: "Bitrate", maxWidth: 125 },
|
||||
{ field: "hdr" as const, headerName: "HDR", maxWidth: 80 },
|
||||
{ field: "video" as const, headerName: "Video codec", maxWidth: 120 },
|
||||
{ field: "resolution" as const, headerName: "Resolution", maxWidth: 120 },
|
||||
{ field: "date_added" as const, headerName: "Date added", maxWidth: 120 },
|
||||
{ field: "library" as const, headerName: "Library", maxWidth: 140 },
|
||||
{ field: "path" as const, headerName: "Path", minWidth: 200 },
|
||||
];
|
||||
|
||||
const onGridReady = useCallback(() => {
|
||||
gridRef.current?.api?.sizeColumnsToFit();
|
||||
}, []);
|
||||
|
||||
const page = Math.floor(offset / limit) + 1;
|
||||
const totalPages = queryResult ? Math.ceil(queryResult.total / limit) : 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Status and controls */}
|
||||
<div className="flex items-center gap-4">
|
||||
{status?.exists ? (
|
||||
<span className="text-sm text-gray-600">
|
||||
Index: {status.item_count.toLocaleString()} items
|
||||
{status.updated_at_label && ` | updated ${status.updated_at_label}`}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-amber-600">No index built yet.</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => buildIndex.mutate()}
|
||||
disabled={buildIndex.isPending}
|
||||
className="px-3 py-1 text-sm rounded border hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{buildIndex.isPending ? "Building..." : "Build index"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setOffset(0);
|
||||
}}
|
||||
className="border rounded px-2 py-1 text-sm w-48"
|
||||
placeholder="Search title, series, path..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block">Types</label>
|
||||
<select
|
||||
value={types}
|
||||
onChange={(e) => {
|
||||
setTypes(e.target.value);
|
||||
setOffset(0);
|
||||
}}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="Movie,Episode">Movies + Episodes</option>
|
||||
<option value="Movie">Movies only</option>
|
||||
<option value="Episode">Episodes only</option>
|
||||
<option value="Movie,Episode,Video">All video</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block">HDR</label>
|
||||
<select
|
||||
value={hdrFilter}
|
||||
onChange={(e) => {
|
||||
setHdrFilter(e.target.value);
|
||||
setOffset(0);
|
||||
}}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="All">All</option>
|
||||
<option value="HDR only">HDR only</option>
|
||||
<option value="SDR/unknown only">SDR/unknown only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block">Sort</label>
|
||||
<select
|
||||
value={sortKey}
|
||||
onChange={(e) => setSortKey(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="title">Title</option>
|
||||
<option value="series">Series</option>
|
||||
<option value="size">Size</option>
|
||||
<option value="bitrate">Bitrate</option>
|
||||
<option value="runtime">Runtime</option>
|
||||
<option value="year">Year</option>
|
||||
<option value="date_added">Date added</option>
|
||||
<option value="resolution">Resolution</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block">Order</label>
|
||||
<select
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="Ascending">Ascending</option>
|
||||
<option value="Descending">Descending</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results info */}
|
||||
{queryResult && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Showing {queryResult.items.length} of{" "}
|
||||
{queryResult.total.toLocaleString()} items | Page {page} of{" "}
|
||||
{totalPages}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* AG Grid table */}
|
||||
{status?.exists && (
|
||||
<div className="ag-theme-alpine" style={{ height: 600, width: "100%" }}>
|
||||
<AgGridReact<MediaItem>
|
||||
ref={gridRef}
|
||||
rowData={queryResult?.items ?? []}
|
||||
columnDefs={columnDefs}
|
||||
rowSelection="single"
|
||||
onGridReady={onGridReady}
|
||||
loading={isLoading}
|
||||
suppressCellFocus
|
||||
animateRows={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{queryResult && totalPages > 1 && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<button
|
||||
onClick={() => setOffset(Math.max(0, offset - limit))}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1 text-sm rounded border disabled:opacity-50"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span className="text-sm">
|
||||
Page {page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setOffset(offset + limit)}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-1 text-sm rounded border disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
useMonitoringStatus,
|
||||
useMonitoringMetrics,
|
||||
useDiskSpace,
|
||||
useCollectorControls,
|
||||
} from "../hooks/useMonitoring";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import { MonitoringCharts } from "../components/MonitoringCharts";
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let value = bytes;
|
||||
let unitIdx = 0;
|
||||
while (value >= 1000 && unitIdx < units.length - 1) {
|
||||
value /= 1000;
|
||||
unitIdx++;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[unitIdx]}`;
|
||||
}
|
||||
|
||||
function formatRate(bytes: number): string {
|
||||
return `${formatBytes(bytes)}/s`;
|
||||
}
|
||||
|
||||
export function Monitoring() {
|
||||
const { data: status } = useMonitoringStatus();
|
||||
const { data: metrics } = useMonitoringMetrics();
|
||||
const { data: disk } = useDiskSpace();
|
||||
const { start, stop, restart } = useCollectorControls();
|
||||
|
||||
const samples = metrics?.samples ?? [];
|
||||
const latest = samples.at(-1);
|
||||
|
||||
// Compute averages and peaks
|
||||
const avg = (arr: number[]) =>
|
||||
arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
|
||||
const max = (arr: number[]) => (arr.length ? Math.max(...arr) : 0);
|
||||
|
||||
const cpuArr = samples.map((s) => s.cpu_pct);
|
||||
const iowArr = samples.map((s) => s.iowait_pct ?? 0);
|
||||
const memArr = samples.map((s) => s.mem_pct);
|
||||
const netDownArr = samples.map((s) => s.net_rx_bytes_per_sec);
|
||||
const netUpArr = samples.map((s) => s.net_tx_bytes_per_sec);
|
||||
const diskReadArr = samples.map((s) => s.disk_read_bps);
|
||||
const diskWriteArr = samples.map((s) => s.disk_write_bps);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Controls */}
|
||||
<section className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
Collector:{" "}
|
||||
<code className="bg-gray-100 px-1 rounded">
|
||||
{status?.status ?? "unknown"}
|
||||
</code>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => start.mutate()}
|
||||
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
onClick={() => restart.mutate()}
|
||||
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
onClick={() => stop.mutate()}
|
||||
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Metrics summary */}
|
||||
<section>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
|
||||
<MetricCard
|
||||
label="CPU now"
|
||||
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
|
||||
subtext={`avg ${avg(cpuArr).toFixed(1)}%\npeak ${max(cpuArr).toFixed(1)}%`}
|
||||
/>
|
||||
<MetricCard
|
||||
label="IO Wait"
|
||||
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
|
||||
subtext={`avg ${avg(iowArr).toFixed(1)}%\npeak ${max(iowArr).toFixed(1)}%`}
|
||||
/>
|
||||
<MetricCard
|
||||
label="RAM now"
|
||||
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
|
||||
subtext={`avg ${avg(memArr).toFixed(1)}%\npeak ${max(memArr).toFixed(1)}%`}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Net down"
|
||||
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
|
||||
subtext={`avg ${formatRate(avg(netDownArr))}\npeak ${formatRate(max(netDownArr))}`}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Net up"
|
||||
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
|
||||
subtext={`avg ${formatRate(avg(netUpArr))}\npeak ${formatRate(max(netUpArr))}`}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Disk read"
|
||||
value={latest ? formatRate(latest.disk_read_bps) : "-"}
|
||||
subtext={`avg ${formatRate(avg(diskReadArr))}\npeak ${formatRate(max(diskReadArr))}`}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Disk write"
|
||||
value={latest ? formatRate(latest.disk_write_bps) : "-"}
|
||||
subtext={`avg ${formatRate(avg(diskWriteArr))}\npeak ${formatRate(max(diskWriteArr))}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Disk space */}
|
||||
{disk && (
|
||||
<section>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
|
||||
<MetricCard
|
||||
label="Disk available"
|
||||
value={formatBytes(disk.available)}
|
||||
/>
|
||||
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
|
||||
<MetricCard label="Used %" value={disk.used_pct} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Charts */}
|
||||
<section>
|
||||
<MonitoringCharts samples={samples} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user