mirror of
https://github.com/hhftechnology/vps-monitor.git
synced 2026-04-28 03:29:55 +00:00
UI-Container-Data-Fixes
This commit is contained in:
parent
21a5f9e01f
commit
fd455dabec
17 changed files with 1403 additions and 377 deletions
169
frontend/src/components/ui/chart.tsx
Normal file
169
frontend/src/components/ui/chart.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode;
|
||||
color?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
type ChartContextValue = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextValue | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Chart components must be used within a ChartContainer.");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
interface ChartContainerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
config: ChartConfig;
|
||||
children: React.ReactElement;
|
||||
}
|
||||
|
||||
export function ChartContainer({
|
||||
config,
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: ChartContainerProps) {
|
||||
const chartStyle = React.useMemo(() => {
|
||||
const entries = Object.entries(config).map(([key, value]) => [
|
||||
`--color-${key}`,
|
||||
value.color ?? "currentColor",
|
||||
]);
|
||||
|
||||
return Object.fromEntries(entries) as React.CSSProperties;
|
||||
}, [config]);
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/60 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none",
|
||||
className,
|
||||
)}
|
||||
style={{ ...chartStyle, ...style }}
|
||||
{...props}
|
||||
>
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
type TooltipPayloadItem = {
|
||||
color?: string;
|
||||
dataKey?: string | number;
|
||||
name?: string | number;
|
||||
payload?: Record<string, unknown>;
|
||||
value?: number | string;
|
||||
};
|
||||
|
||||
interface ChartTooltipContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
active?: boolean;
|
||||
payload?: TooltipPayloadItem[];
|
||||
label?: string | number;
|
||||
hideLabel?: boolean;
|
||||
formatter?: (
|
||||
value: number | string,
|
||||
name: string,
|
||||
item: TooltipPayloadItem,
|
||||
) => React.ReactNode | [React.ReactNode, React.ReactNode];
|
||||
}
|
||||
|
||||
export const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ChartTooltipContentProps
|
||||
>(function ChartTooltipContent(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
className,
|
||||
hideLabel = false,
|
||||
formatter,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[11rem] gap-2 rounded-lg border bg-card px-3 py-2 text-xs shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{!hideLabel && label !== undefined && (
|
||||
<div className="font-medium text-foreground">{label}</div>
|
||||
)}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item) => {
|
||||
const dataKey = String(item.dataKey ?? item.name ?? "");
|
||||
const itemConfig = config[dataKey];
|
||||
const itemLabel = String(itemConfig?.label ?? item.name ?? dataKey);
|
||||
const formatted = formatter?.(
|
||||
item.value ?? "",
|
||||
itemLabel,
|
||||
item,
|
||||
);
|
||||
|
||||
let valueNode: React.ReactNode = item.value ?? "—";
|
||||
let labelNode: React.ReactNode = itemLabel;
|
||||
|
||||
if (Array.isArray(formatted)) {
|
||||
valueNode = formatted[0];
|
||||
labelNode = formatted[1];
|
||||
} else if (formatted !== undefined) {
|
||||
valueNode = formatted;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${dataKey}-${itemLabel}`}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
item.color ?? itemConfig?.color ?? `var(--color-${dataKey})`,
|
||||
}}
|
||||
/>
|
||||
<span className="truncate text-muted-foreground">{labelNode}</span>
|
||||
</div>
|
||||
<span className="font-medium text-foreground">{valueNode}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -2,10 +2,12 @@ import { authenticatedFetch } from "@/lib/api-client";
|
|||
import { API_BASE_URL } from "@/types/api";
|
||||
|
||||
import type { ContainerInfo } from "../types";
|
||||
import type { ContainerStats } from "../types/stats";
|
||||
|
||||
export interface ContainerHistoryStats
|
||||
extends NonNullable<ContainerInfo["historical_stats"]> {
|
||||
has_data: boolean;
|
||||
samples: ContainerStats[];
|
||||
}
|
||||
|
||||
export async function getContainerHistory(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ContainerDetailsSheet } from "./container-details-sheet";
|
||||
|
||||
const mockUseContainerStats = vi.fn();
|
||||
const mockUseContainerHistory = vi.fn();
|
||||
|
||||
vi.mock("../hooks/use-container-stats", () => ({
|
||||
useContainerStats: (...args: unknown[]) => mockUseContainerStats(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-container-history", () => ({
|
||||
useContainerHistory: (...args: unknown[]) => mockUseContainerHistory(...args),
|
||||
}));
|
||||
|
||||
vi.mock("recharts", async () => {
|
||||
const actual = await vi.importActual<typeof import("recharts")>("recharts");
|
||||
|
||||
return {
|
||||
...actual,
|
||||
ResponsiveContainer: ({ children }: { children?: ReactNode }) => (
|
||||
<div style={{ width: 960, height: 320 }}>{children}</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
class ResizeObserverMock {
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
|
||||
Object.defineProperty(HTMLElement.prototype, "clientWidth", {
|
||||
configurable: true,
|
||||
value: 960,
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 320,
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContainerDetailsSheet", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockUseContainerStats.mockReturnValue({
|
||||
stats: null,
|
||||
history: [],
|
||||
isConnected: false,
|
||||
error: null,
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
clearHistory: vi.fn(),
|
||||
});
|
||||
|
||||
mockUseContainerHistory.mockReturnValue({
|
||||
data: {
|
||||
cpu_1h: 12,
|
||||
memory_1h: 34,
|
||||
cpu_12h: 18,
|
||||
memory_12h: 40,
|
||||
has_data: true,
|
||||
samples: [
|
||||
{
|
||||
container_id: "container-1",
|
||||
host: "local",
|
||||
cpu_percent: 10,
|
||||
memory_usage: 512,
|
||||
memory_limit: 1024,
|
||||
memory_percent: 50,
|
||||
network_rx: 100,
|
||||
network_tx: 80,
|
||||
block_read: 20,
|
||||
block_write: 10,
|
||||
pids: 4,
|
||||
timestamp: 1_700_000_000,
|
||||
},
|
||||
{
|
||||
container_id: "container-1",
|
||||
host: "local",
|
||||
cpu_percent: 14,
|
||||
memory_usage: 600,
|
||||
memory_limit: 1024,
|
||||
memory_percent: 58,
|
||||
network_rx: 120,
|
||||
network_tx: 95,
|
||||
block_read: 25,
|
||||
block_write: 12,
|
||||
pids: 5,
|
||||
timestamp: 1_700_000_030,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders stats charts from persisted samples before live history arrives", () => {
|
||||
render(
|
||||
<ContainerDetailsSheet
|
||||
container={{
|
||||
id: "container-1",
|
||||
names: ["/api"],
|
||||
image: "ghcr.io/example/api:latest",
|
||||
image_id: "sha256:123",
|
||||
command: "node server.js",
|
||||
created: 1_700_000_000,
|
||||
state: "running",
|
||||
status: "Up 2 hours",
|
||||
host: "local",
|
||||
historical_stats: {
|
||||
cpu_1h: 12,
|
||||
memory_1h: 34,
|
||||
cpu_12h: 18,
|
||||
memory_12h: 40,
|
||||
},
|
||||
}}
|
||||
host="local"
|
||||
isOpen
|
||||
onOpenChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("CPU Usage (%)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Memory Usage (%)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Network I/O")).toBeInTheDocument();
|
||||
expect(screen.getByText("Block I/O")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -29,6 +29,7 @@ import {
|
|||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
import { useContainerHistory } from "../hooks/use-container-history";
|
||||
import { useContainerStats } from "../hooks/use-container-stats";
|
||||
import type { ContainerInfo } from "../types";
|
||||
import { ContainerStatsCharts } from "./container-stats-charts";
|
||||
|
|
@ -85,6 +86,11 @@ export function ContainerDetailsSheet({
|
|||
host,
|
||||
enabled: isOpen && activeTab === "stats",
|
||||
});
|
||||
const { data: persistedHistory } = useContainerHistory(
|
||||
effectiveContainerId,
|
||||
host,
|
||||
isOpen && activeTab === "stats",
|
||||
);
|
||||
|
||||
const handleContainerIdChange = useCallback((newId: string) => {
|
||||
setContainerId(newId);
|
||||
|
|
@ -145,10 +151,28 @@ export function ContainerDetailsSheet({
|
|||
container.names?.[0]?.replace(/^\//, "") || container.id.slice(0, 12);
|
||||
const isRunning = container.state.toLowerCase() === "running";
|
||||
const historyStats = container.historical_stats;
|
||||
const chartHistory = useMemo(() => {
|
||||
const persistedSamples = persistedHistory?.samples ?? [];
|
||||
if (persistedSamples.length === 0) {
|
||||
return history;
|
||||
}
|
||||
|
||||
const merged = new Map<number, (typeof persistedSamples)[number]>();
|
||||
for (const sample of persistedSamples) {
|
||||
merged.set(sample.timestamp, sample);
|
||||
}
|
||||
for (const sample of history) {
|
||||
merged.set(sample.timestamp, sample);
|
||||
}
|
||||
|
||||
return Array.from(merged.values())
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
.slice(-60);
|
||||
}, [history, persistedHistory?.samples]);
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="sm:max-w-2xl w-full overflow-y-auto p-0">
|
||||
<SheetContent className="w-full overflow-y-auto p-0 sm:max-w-4xl">
|
||||
<SheetHeader className="px-6 pt-6">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
{containerName}
|
||||
|
|
@ -167,7 +191,7 @@ export function ContainerDetailsSheet({
|
|||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex-1 flex flex-col px-6 pb-6"
|
||||
className="flex flex-1 flex-col px-6 pb-6"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="stats" className="flex items-center gap-2">
|
||||
|
|
@ -188,7 +212,7 @@ export function ContainerDetailsSheet({
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="stats" className="space-y-6 mt-4">
|
||||
<TabsContent value="stats" className="mt-4 flex flex-col gap-6">
|
||||
{historyStats && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Card>
|
||||
|
|
@ -226,7 +250,7 @@ export function ContainerDetailsSheet({
|
|||
</div>
|
||||
)}
|
||||
{/* Stats Controls */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={isConnected ? "default" : "secondary"}>
|
||||
{isConnected ? "Live" : "Disconnected"}
|
||||
|
|
@ -295,23 +319,23 @@ export function ContainerDetailsSheet({
|
|||
|
||||
{/* Live Stats Cards */}
|
||||
{statsCards && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{statsCards.map((card) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<Card key={card.label} className="overflow-hidden">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex flex-col items-center text-center gap-2">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<div
|
||||
className={`p-2 rounded-lg bg-muted ${card.color}`}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground font-medium">
|
||||
{card.label}
|
||||
</p>
|
||||
<p className="text-sm font-semibold truncate max-w-full">
|
||||
<p className="break-words text-sm font-semibold leading-tight">
|
||||
{card.value}
|
||||
</p>
|
||||
{card.subValue && (
|
||||
|
|
@ -334,7 +358,7 @@ export function ContainerDetailsSheet({
|
|||
)}
|
||||
|
||||
{/* Stats Charts */}
|
||||
<ContainerStatsCharts history={history} />
|
||||
<ContainerStatsCharts history={chartHistory} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="terminal" className="min-h-[400px] mt-4">
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import { useMemo } from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
Line,
|
||||
LineChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
|
||||
import type { ContainerStats } from "../types/stats";
|
||||
|
||||
|
|
@ -17,24 +21,7 @@ interface ContainerStatsChartsProps {
|
|||
history: ContainerStats[];
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
interface ChartData {
|
||||
type ChartData = {
|
||||
time: string;
|
||||
timestamp: number;
|
||||
cpu: number;
|
||||
|
|
@ -45,6 +32,106 @@ interface ChartData {
|
|||
networkTx: number;
|
||||
blockRead: number;
|
||||
blockWrite: number;
|
||||
};
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
return new Date(timestamp * 1000).toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
const cpuChartConfig = {
|
||||
cpu: {
|
||||
label: "CPU",
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const memoryChartConfig = {
|
||||
memory: {
|
||||
label: "Memory",
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const networkChartConfig = {
|
||||
networkRx: {
|
||||
label: "RX (Received)",
|
||||
color: "var(--chart-3)",
|
||||
},
|
||||
networkTx: {
|
||||
label: "TX (Transmitted)",
|
||||
color: "var(--chart-4)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const blockChartConfig = {
|
||||
blockRead: {
|
||||
label: "Read",
|
||||
color: "var(--chart-5)",
|
||||
},
|
||||
blockWrite: {
|
||||
label: "Write",
|
||||
color: "var(--destructive)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
interface StatsChartCardProps {
|
||||
title: string;
|
||||
data: ChartData[];
|
||||
config: ChartConfig;
|
||||
children: React.ReactElement;
|
||||
legend?: React.ReactNode;
|
||||
}
|
||||
|
||||
function StatsChartCard({
|
||||
title,
|
||||
data,
|
||||
config,
|
||||
children,
|
||||
legend,
|
||||
}: StatsChartCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<ChartContainer config={config} className="h-[220px] w-full">
|
||||
{children}
|
||||
</ChartContainer>
|
||||
{data.length > 0 ? legend : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesLegend({ config }: { config: ChartConfig }) {
|
||||
return (
|
||||
<div className="flex flex-wrap justify-center gap-4 text-xs text-muted-foreground">
|
||||
{Object.entries(config).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: value.color }}
|
||||
/>
|
||||
<span>{value.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContainerStatsCharts({ history }: ContainerStatsChartsProps) {
|
||||
|
|
@ -63,283 +150,176 @@ export function ContainerStatsCharts({ history }: ContainerStatsChartsProps) {
|
|||
}));
|
||||
}, [history]);
|
||||
|
||||
if (history.length === 0) {
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
No data yet. Stats will appear once streaming begins.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* CPU Chart */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">CPU Usage (%)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="cpuGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "var(--radius)",
|
||||
}}
|
||||
labelStyle={{ color: "hsl(var(--foreground))" }}
|
||||
formatter={(value) => [`${(value as number).toFixed(2)}%`, "CPU"]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cpu"
|
||||
stroke="hsl(var(--primary))"
|
||||
fill="url(#cpuGradient)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<StatsChartCard title="CPU Usage (%)" data={chartData} config={cpuChartConfig}>
|
||||
<LineChart accessibilityLayer data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="time"
|
||||
minTickGap={32}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
tickLine={false}
|
||||
width={44}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value) => `${Number(value).toFixed(2)}%`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
dataKey="cpu"
|
||||
dot={false}
|
||||
stroke="var(--color-cpu)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
</LineChart>
|
||||
</StatsChartCard>
|
||||
|
||||
{/* Memory Chart */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Memory Usage (%)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="memoryGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(var(--chart-2))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--chart-2))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "var(--radius)",
|
||||
}}
|
||||
labelStyle={{ color: "hsl(var(--foreground))" }}
|
||||
formatter={(value, _name, props) => {
|
||||
const payload = (props as { payload: ChartData }).payload;
|
||||
return [
|
||||
`${(value as number).toFixed(2)}% (${formatBytes(payload.memoryUsage)} / ${formatBytes(payload.memoryLimit)})`,
|
||||
"Memory",
|
||||
];
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memory"
|
||||
stroke="hsl(var(--chart-2))"
|
||||
fill="url(#memoryGradient)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatsChartCard
|
||||
title="Memory Usage (%)"
|
||||
data={chartData}
|
||||
config={memoryChartConfig}
|
||||
>
|
||||
<LineChart accessibilityLayer data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="time"
|
||||
minTickGap={32}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
tickLine={false}
|
||||
width={44}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value, _name, item) => {
|
||||
const payload = item.payload as ChartData | undefined;
|
||||
return `${Number(value).toFixed(2)}% (${formatBytes(payload?.memoryUsage ?? 0)} / ${formatBytes(payload?.memoryLimit ?? 0)})`;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
dataKey="memory"
|
||||
dot={false}
|
||||
stroke="var(--color-memory)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
</LineChart>
|
||||
</StatsChartCard>
|
||||
|
||||
{/* Network I/O Chart */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Network I/O</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="rxGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(var(--chart-3))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--chart-3))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="txGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(var(--chart-4))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--chart-4))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => formatBytes(value)}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "var(--radius)",
|
||||
}}
|
||||
labelStyle={{ color: "hsl(var(--foreground))" }}
|
||||
formatter={(value, name) => [
|
||||
formatBytes(value as number),
|
||||
name === "networkRx" ? "RX (Received)" : "TX (Transmitted)",
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="networkRx"
|
||||
stroke="hsl(var(--chart-3))"
|
||||
fill="url(#rxGradient)"
|
||||
strokeWidth={2}
|
||||
name="networkRx"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="networkTx"
|
||||
stroke="hsl(var(--chart-4))"
|
||||
fill="url(#txGradient)"
|
||||
strokeWidth={2}
|
||||
name="networkTx"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="size-2 rounded-full" style={{ backgroundColor: "hsl(var(--chart-3))" }} />
|
||||
<span>RX (Received)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="size-2 rounded-full" style={{ backgroundColor: "hsl(var(--chart-4))" }} />
|
||||
<span>TX (Transmitted)</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatsChartCard
|
||||
title="Network I/O"
|
||||
data={chartData}
|
||||
config={networkChartConfig}
|
||||
legend={<SeriesLegend config={networkChartConfig} />}
|
||||
>
|
||||
<LineChart accessibilityLayer data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="time"
|
||||
minTickGap={32}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => formatBytes(Number(value))}
|
||||
tickLine={false}
|
||||
width={64}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value) => formatBytes(Number(value))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
dataKey="networkRx"
|
||||
dot={false}
|
||||
stroke="var(--color-networkRx)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
<Line
|
||||
dataKey="networkTx"
|
||||
dot={false}
|
||||
stroke="var(--color-networkTx)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
</LineChart>
|
||||
</StatsChartCard>
|
||||
|
||||
{/* Block I/O Chart */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Block I/O</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="readGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(var(--chart-5))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--chart-5))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="writeGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(var(--destructive))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--destructive))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => formatBytes(value)}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "var(--radius)",
|
||||
}}
|
||||
labelStyle={{ color: "hsl(var(--foreground))" }}
|
||||
formatter={(value, name) => [
|
||||
formatBytes(value as number),
|
||||
name === "blockRead" ? "Read" : "Write",
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="blockRead"
|
||||
stroke="hsl(var(--chart-5))"
|
||||
fill="url(#readGradient)"
|
||||
strokeWidth={2}
|
||||
name="blockRead"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="blockWrite"
|
||||
stroke="hsl(var(--destructive))"
|
||||
fill="url(#writeGradient)"
|
||||
strokeWidth={2}
|
||||
name="blockWrite"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="size-2 rounded-full" style={{ backgroundColor: "hsl(var(--chart-5))" }} />
|
||||
<span>Read</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="size-2 rounded-full" style={{ backgroundColor: "hsl(var(--destructive))" }} />
|
||||
<span>Write</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatsChartCard
|
||||
title="Block I/O"
|
||||
data={chartData}
|
||||
config={blockChartConfig}
|
||||
legend={<SeriesLegend config={blockChartConfig} />}
|
||||
>
|
||||
<LineChart accessibilityLayer data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="time"
|
||||
minTickGap={32}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => formatBytes(Number(value))}
|
||||
tickLine={false}
|
||||
width={64}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value) => formatBytes(Number(value))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
dataKey="blockRead"
|
||||
dot={false}
|
||||
stroke="var(--color-blockRead)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
<Line
|
||||
dataKey="blockWrite"
|
||||
dot={false}
|
||||
stroke="var(--color-blockWrite)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
</LineChart>
|
||||
</StatsChartCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ContainersTable } from "./containers-table";
|
||||
|
||||
const baseContainer = {
|
||||
id: "container-1",
|
||||
names: ["/api"],
|
||||
image: "ghcr.io/example/api:latest",
|
||||
image_id: "sha256:1",
|
||||
command: "node server.js",
|
||||
created: 1_700_000_000,
|
||||
state: "running",
|
||||
status: "Up 2 hours",
|
||||
host: "local",
|
||||
labels: {
|
||||
"com.docker.compose.project": "project-alpha",
|
||||
},
|
||||
};
|
||||
|
||||
describe("ContainersTable", () => {
|
||||
it('shows "Collecting" when historical stats are not available yet', () => {
|
||||
render(
|
||||
<ContainersTable
|
||||
error={null}
|
||||
expandedGroups={[]}
|
||||
filteredContainers={[baseContainer]}
|
||||
groupBy="none"
|
||||
groupedItems={null}
|
||||
isError={false}
|
||||
isLoading={false}
|
||||
isReadOnly={false}
|
||||
onDelete={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onSelectAll={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
onToggleGroup={vi.fn()}
|
||||
onToggleSelect={vi.fn()}
|
||||
onViewLogs={vi.fn()}
|
||||
onViewStats={vi.fn()}
|
||||
pageItems={[baseContainer]}
|
||||
pendingAction={null}
|
||||
selectedIds={[]}
|
||||
statsInterval="1h"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText("Collecting")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps the compose group label together", () => {
|
||||
render(
|
||||
<ContainersTable
|
||||
error={null}
|
||||
expandedGroups={["project-alpha"]}
|
||||
filteredContainers={[baseContainer, { ...baseContainer, id: "container-2", names: ["/worker"] }]}
|
||||
groupBy="compose"
|
||||
groupedItems={[
|
||||
{
|
||||
project: "project-alpha",
|
||||
items: [baseContainer, { ...baseContainer, id: "container-2", names: ["/worker"] }],
|
||||
},
|
||||
]}
|
||||
isError={false}
|
||||
isLoading={false}
|
||||
isReadOnly={false}
|
||||
onDelete={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onSelectAll={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
onToggleGroup={vi.fn()}
|
||||
onToggleSelect={vi.fn()}
|
||||
onViewLogs={vi.fn()}
|
||||
onViewStats={vi.fn()}
|
||||
pageItems={[baseContainer, { ...baseContainer, id: "container-2", names: ["/worker"] }]}
|
||||
pendingAction={null}
|
||||
selectedIds={[]}
|
||||
statsInterval="1h"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /project-alpha/i }).textContent).toContain(
|
||||
"project-alpha · 2 containers",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -100,6 +100,9 @@ export function ContainersTable({
|
|||
const isContainerBusy = (containerId: string) =>
|
||||
pendingAction?.id === containerId;
|
||||
|
||||
const formatHistoricalMetric = (value: number | null) =>
|
||||
value === null ? "Collecting" : `${value.toFixed(1)}%`;
|
||||
|
||||
const renderContainerRow = (container: ContainerInfo) => {
|
||||
const state = container.state.toLowerCase();
|
||||
const busy = isContainerBusy(container.id);
|
||||
|
|
@ -127,14 +130,25 @@ export function ContainersTable({
|
|||
<TableCell className="h-16 px-4 font-medium">
|
||||
{formatContainerName(container.names)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="h-16 px-4 text-sm text-muted-foreground cursor-pointer"
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(container.image);
|
||||
}}
|
||||
title="Click to copy image name"
|
||||
>
|
||||
{container.image}
|
||||
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="block max-w-[260px] cursor-pointer truncate"
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(container.image);
|
||||
}}
|
||||
title="Click to copy image name"
|
||||
>
|
||||
{container.image}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-md break-all">
|
||||
{container.image}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-4">
|
||||
<Badge className={`${getStateBadgeClass(container.state)} border-0`}>
|
||||
|
|
@ -148,20 +162,20 @@ export function ContainersTable({
|
|||
{formatCreatedDate(container.created)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
|
||||
{cpuAverage === null ? "—" : `${cpuAverage.toFixed(1)}%`}
|
||||
{formatHistoricalMetric(cpuAverage)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
|
||||
{memoryAverage === null ? "—" : `${memoryAverage.toFixed(1)}%`}
|
||||
{formatHistoricalMetric(memoryAverage)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-4 max-w-[300px] text-sm text-muted-foreground">
|
||||
<TableCell className="h-16 max-w-[300px] px-4 text-sm text-muted-foreground">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block cursor-help truncate">
|
||||
<span className="block max-w-[280px] cursor-help truncate">
|
||||
{container.command}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-md">
|
||||
<TooltipContent className="max-w-md break-all">
|
||||
{container.command}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -298,8 +312,8 @@ export function ContainersTable({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Table>
|
||||
<div className="overflow-x-auto rounded-lg border bg-card">
|
||||
<Table className="min-w-[1180px]">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-b">
|
||||
<TableHead className="h-12 w-10 px-4">
|
||||
|
|
@ -373,7 +387,7 @@ export function ContainersTable({
|
|||
>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2"
|
||||
className="inline-flex max-w-full items-center gap-2 truncate"
|
||||
onClick={() => onToggleGroup(group.project)}
|
||||
>
|
||||
{expandedGroups.includes(group.project) ? (
|
||||
|
|
@ -381,9 +395,11 @@ export function ContainersTable({
|
|||
) : (
|
||||
<ChevronRightIcon className="size-4" />
|
||||
)}
|
||||
{group.project} · {group.items.length} container
|
||||
<span className="truncate">
|
||||
{group.project} · {group.items.length}{" "}
|
||||
{group.items.length === 1 ? "container" : "containers"}
|
||||
</span>
|
||||
</button>
|
||||
{group.items.length === 1 ? "" : "s"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expandedGroups.includes(group.project)
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ import (
|
|||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/hhftechnology/vps-monitor/internal/alerts"
|
||||
"github.com/hhftechnology/vps-monitor/internal/api"
|
||||
"github.com/hhftechnology/vps-monitor/internal/auth"
|
||||
"github.com/hhftechnology/vps-monitor/internal/bot"
|
||||
"github.com/hhftechnology/vps-monitor/internal/config"
|
||||
"github.com/hhftechnology/vps-monitor/internal/containerstats"
|
||||
"github.com/hhftechnology/vps-monitor/internal/coolify"
|
||||
"github.com/hhftechnology/vps-monitor/internal/docker"
|
||||
"github.com/hhftechnology/vps-monitor/internal/models"
|
||||
|
|
@ -21,6 +23,8 @@ import (
|
|||
func main() {
|
||||
system.Init()
|
||||
|
||||
const containerStatsRetention = 30 * 24 * time.Hour
|
||||
|
||||
manager := config.NewManager()
|
||||
cfg := manager.Config()
|
||||
|
||||
|
|
@ -66,29 +70,6 @@ func main() {
|
|||
log.Println("Coolify integration is DISABLED")
|
||||
}
|
||||
|
||||
// Alert monitor (vps-monitor specific)
|
||||
var alertMonitor *alerts.Monitor
|
||||
if cfg.Alerts.Enabled {
|
||||
alertMonitor = alerts.NewMonitor(multiHostClient, &cfg.Alerts)
|
||||
alertMonitor.Start()
|
||||
defer alertMonitor.Stop()
|
||||
log.Println("Alert monitoring is ENABLED")
|
||||
log.Printf(" CPU threshold: %.1f%%, Memory threshold: %.1f%%, Check interval: %s",
|
||||
cfg.Alerts.CPUThreshold, cfg.Alerts.MemoryThreshold, cfg.Alerts.CheckInterval)
|
||||
if cfg.Alerts.WebhookURL != "" {
|
||||
log.Println(" Webhook notifications are ENABLED")
|
||||
}
|
||||
} else {
|
||||
log.Println("Alert monitoring is DISABLED")
|
||||
log.Println(" To enable alerts, set: ALERTS_ENABLED=true")
|
||||
}
|
||||
|
||||
registry := services.NewRegistry(multiHostClient, coolifyClient, authService, cfg, alertMonitor)
|
||||
|
||||
telegramBot := bot.NewService(registry, cfg.Bot)
|
||||
telegramBot.Start()
|
||||
defer telegramBot.Stop()
|
||||
|
||||
// Scanner database
|
||||
dbPath := "/data/scanner.db"
|
||||
if v := os.Getenv("SCANNER_DB_PATH"); v != "" {
|
||||
|
|
@ -101,6 +82,35 @@ func main() {
|
|||
defer scanDB.Close()
|
||||
log.Printf("Scan database opened at %s", dbPath)
|
||||
|
||||
// Alert monitor / stats collection
|
||||
var alertMonitor *alerts.Monitor
|
||||
registry := services.NewRegistry(multiHostClient, coolifyClient, authService, cfg, alertMonitor)
|
||||
|
||||
var statsCollector *containerstats.Collector
|
||||
if cfg.Alerts.Enabled {
|
||||
alertMonitor = alerts.NewMonitor(multiHostClient, &cfg.Alerts, scanDB, containerStatsRetention)
|
||||
registry.SwapAlerts(alertMonitor)
|
||||
alertMonitor.Start()
|
||||
defer alertMonitor.Stop()
|
||||
log.Println("Alert monitoring is ENABLED")
|
||||
log.Printf(" CPU threshold: %.1f%%, Memory threshold: %.1f%%, Check interval: %s",
|
||||
cfg.Alerts.CPUThreshold, cfg.Alerts.MemoryThreshold, cfg.Alerts.CheckInterval)
|
||||
if cfg.Alerts.WebhookURL != "" {
|
||||
log.Println(" Webhook notifications are ENABLED")
|
||||
}
|
||||
} else {
|
||||
statsCollector = containerstats.NewCollector(registry, scanDB, cfg.Alerts.CheckInterval, containerStatsRetention)
|
||||
statsCollector.Start()
|
||||
defer statsCollector.Stop()
|
||||
log.Println("Alert monitoring is DISABLED")
|
||||
log.Println(" Background container stats collection remains ENABLED")
|
||||
log.Println(" To enable alerts, set: ALERTS_ENABLED=true")
|
||||
}
|
||||
|
||||
telegramBot := bot.NewService(registry, cfg.Bot)
|
||||
telegramBot.Start()
|
||||
defer telegramBot.Stop()
|
||||
|
||||
// Build initial scanner config from env, then load/merge with DB settings
|
||||
envScannerCfg := configToScannerConfig(cfg.Scanner)
|
||||
if err := scanDB.MigrateFromFileConfig(envScannerCfg); err != nil {
|
||||
|
|
@ -175,6 +185,7 @@ func main() {
|
|||
routerOpts := &api.RouterOptions{
|
||||
AlertMonitor: alertMonitor,
|
||||
BotService: telegramBot,
|
||||
ScanDB: scanDB,
|
||||
ScannerService: scannerService,
|
||||
AutoScanner: autoScanner,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,23 +22,33 @@ type Monitor struct {
|
|||
config *config.AlertConfig
|
||||
history *AlertHistory
|
||||
stats *stats.HistoryManager
|
||||
store statsStore
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
// Track container states for detecting changes
|
||||
containerStates map[string]string // key: host:containerID, value: state
|
||||
statesMu sync.RWMutex
|
||||
statsRetention time.Duration
|
||||
lastPrune time.Time
|
||||
}
|
||||
|
||||
type statsStore interface {
|
||||
InsertContainerStat(stat models.ContainerStats) error
|
||||
PruneContainerStatsOlderThan(cutoff time.Time) error
|
||||
}
|
||||
|
||||
// NewMonitor creates a new alert monitor
|
||||
func NewMonitor(dockerClient *docker.MultiHostClient, alertConfig *config.AlertConfig) *Monitor {
|
||||
func NewMonitor(dockerClient *docker.MultiHostClient, alertConfig *config.AlertConfig, store statsStore, statsRetention time.Duration) *Monitor {
|
||||
return &Monitor{
|
||||
docker: dockerClient,
|
||||
config: alertConfig,
|
||||
history: NewAlertHistory(100), // Keep last 100 alerts
|
||||
stats: stats.NewHistoryManager(),
|
||||
store: store,
|
||||
stopCh: make(chan struct{}),
|
||||
containerStates: make(map[string]string),
|
||||
statsRetention: statsRetention,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -56,11 +66,6 @@ func (m *Monitor) getDockerClient() *docker.MultiHostClient {
|
|||
|
||||
// Start begins the background monitoring
|
||||
func (m *Monitor) Start() {
|
||||
if !m.config.Enabled {
|
||||
log.Println("Alert monitoring is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Starting alert monitor (interval: %s, CPU threshold: %.1f%%, Memory threshold: %.1f%%)",
|
||||
m.config.CheckInterval, m.config.CPUThreshold, m.config.MemoryThreshold)
|
||||
|
||||
|
|
@ -209,6 +214,11 @@ func (m *Monitor) checkResourceThresholds(ctx context.Context) {
|
|||
continue
|
||||
}
|
||||
m.stats.RecordStats(hostName, ctr.ID, *stats)
|
||||
if m.store != nil {
|
||||
if err := m.store.InsertContainerStat(*stats); err != nil {
|
||||
log.Printf("Alert monitor: failed to persist stats for %s on %s: %v", ctr.ID, hostName, err)
|
||||
}
|
||||
}
|
||||
|
||||
containerName := ctr.ID[:12]
|
||||
if len(ctr.Names) > 0 {
|
||||
|
|
@ -246,10 +256,22 @@ func (m *Monitor) checkResourceThresholds(ctx context.Context) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.store != nil && m.statsRetention > 0 && (m.lastPrune.IsZero() || time.Since(m.lastPrune) >= time.Hour) {
|
||||
if err := m.store.PruneContainerStatsOlderThan(time.Now().Add(-m.statsRetention)); err != nil {
|
||||
log.Printf("Alert monitor: failed to prune persisted stats: %v", err)
|
||||
} else {
|
||||
m.lastPrune = time.Now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// triggerAlert handles a new alert
|
||||
func (m *Monitor) triggerAlert(alert models.Alert) {
|
||||
if !m.config.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Alert: %s - %s", alert.Type, alert.Message)
|
||||
|
||||
// Add to history
|
||||
|
|
|
|||
126
home/internal/api/container_handlers_test.go
Normal file
126
home/internal/api/container_handlers_test.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/hhftechnology/vps-monitor/internal/config"
|
||||
"github.com/hhftechnology/vps-monitor/internal/models"
|
||||
"github.com/hhftechnology/vps-monitor/internal/scanner"
|
||||
"github.com/hhftechnology/vps-monitor/internal/services"
|
||||
)
|
||||
|
||||
func TestGetContainerHistoricalStatsReturnsPersistedDataWithoutAlerts(t *testing.T) {
|
||||
db := newTestAPIScanDB(t)
|
||||
now := time.Now().UTC()
|
||||
|
||||
for _, sample := range []models.ContainerStats{
|
||||
{
|
||||
ContainerID: "container-1",
|
||||
Host: "host-a",
|
||||
CPUPercent: 12,
|
||||
MemoryPercent: 34,
|
||||
MemoryUsage: 512,
|
||||
MemoryLimit: 1024,
|
||||
Timestamp: now.Add(-30 * time.Minute).Unix(),
|
||||
},
|
||||
{
|
||||
ContainerID: "container-1",
|
||||
Host: "host-a",
|
||||
CPUPercent: 20,
|
||||
MemoryPercent: 40,
|
||||
MemoryUsage: 768,
|
||||
MemoryLimit: 1024,
|
||||
Timestamp: now.Add(-15 * time.Minute).Unix(),
|
||||
},
|
||||
} {
|
||||
if err := db.InsertContainerStat(sample); err != nil {
|
||||
t.Fatalf("InsertContainerStat() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
router := &APIRouter{
|
||||
registry: services.NewRegistry(nil, nil, nil, &config.Config{}, nil),
|
||||
statsDB: db,
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/containers/container-1/stats/history?host=host-a", nil)
|
||||
req = withURLParam(req, "id", "container-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.GetContainerHistoricalStats(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var history models.HistoricalAverages
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &history); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
if !history.HasData {
|
||||
t.Fatal("expected historical data")
|
||||
}
|
||||
if len(history.Samples) != 2 {
|
||||
t.Fatalf("expected 2 bootstrap samples, got %d", len(history.Samples))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichContainersWithHistoricalStatsUsesDatabase(t *testing.T) {
|
||||
db := newTestAPIScanDB(t)
|
||||
now := time.Now().UTC()
|
||||
|
||||
if err := db.InsertContainerStat(models.ContainerStats{
|
||||
ContainerID: "container-1",
|
||||
Host: "host-a",
|
||||
CPUPercent: 18,
|
||||
MemoryPercent: 42,
|
||||
Timestamp: now.Add(-20 * time.Minute).Unix(),
|
||||
}); err != nil {
|
||||
t.Fatalf("InsertContainerStat() error = %v", err)
|
||||
}
|
||||
|
||||
router := &APIRouter{statsDB: db}
|
||||
containers := []models.ContainerInfo{
|
||||
{
|
||||
ID: "container-1",
|
||||
Host: "host-a",
|
||||
Names: []string{"/api"},
|
||||
},
|
||||
}
|
||||
|
||||
router.enrichContainersWithHistoricalStats(containers)
|
||||
|
||||
if containers[0].HistoricalStats == nil {
|
||||
t.Fatal("expected historical stats to be attached")
|
||||
}
|
||||
if containers[0].HistoricalStats.CPU1h != 18 || containers[0].HistoricalStats.Memory1h != 42 {
|
||||
t.Fatalf("unexpected historical stats: %+v", containers[0].HistoricalStats)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestAPIScanDB(t *testing.T) *scanner.ScanDB {
|
||||
t.Helper()
|
||||
|
||||
db, err := scanner.NewScanDB(t.TempDir() + "/scan.db")
|
||||
if err != nil {
|
||||
t.Fatalf("NewScanDB() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = db.Close()
|
||||
})
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func withURLParam(req *http.Request, key, value string) *http.Request {
|
||||
routeContext := chi.NewRouteContext()
|
||||
routeContext.URLParams.Add(key, value)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeContext))
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ import (
|
|||
// Pre-compiled regex for validating environment variable keys (performance optimization)
|
||||
var envKeyRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
|
||||
|
||||
const containerStatsBootstrapLimit = 60
|
||||
|
||||
type coolifyEnvSyncer interface {
|
||||
SyncEnvVars(ctx context.Context, resource *coolify.ResourceInfo, envVars map[string]string) error
|
||||
}
|
||||
|
|
@ -38,6 +40,47 @@ func isNilCoolifySyncer(syncer coolifyEnvSyncer) bool {
|
|||
}
|
||||
}
|
||||
|
||||
func (ar *APIRouter) getContainerHistoricalAverages(host, containerID string) (models.HistoricalAverages, error) {
|
||||
if ar.statsDB == nil {
|
||||
return models.HistoricalAverages{}, fmt.Errorf("stats database not available")
|
||||
}
|
||||
return ar.statsDB.GetContainerHistoricalAverages(host, containerID, time.Now())
|
||||
}
|
||||
|
||||
func (ar *APIRouter) getContainerHistoricalSamples(host, containerID string) ([]models.ContainerStats, error) {
|
||||
if ar.statsDB == nil {
|
||||
return nil, fmt.Errorf("stats database not available")
|
||||
}
|
||||
return ar.statsDB.GetRecentContainerStats(
|
||||
host,
|
||||
containerID,
|
||||
time.Now().Add(-12*time.Hour),
|
||||
containerStatsBootstrapLimit,
|
||||
)
|
||||
}
|
||||
|
||||
func (ar *APIRouter) enrichContainersWithHistoricalStats(containers []models.ContainerInfo) {
|
||||
if ar.statsDB == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for i := range containers {
|
||||
history, err := ar.getContainerHistoricalAverages(containers[i].Host, containers[i].ID)
|
||||
if err != nil {
|
||||
log.Printf("failed to load container history for %s on %s: %v", containers[i].ID, containers[i].Host, err)
|
||||
continue
|
||||
}
|
||||
if history.HasData {
|
||||
containers[i].HistoricalStats = &models.HistoricalStats{
|
||||
CPU1h: history.CPU1h,
|
||||
Memory1h: history.Memory1h,
|
||||
CPU12h: history.CPU12h,
|
||||
Memory12h: history.Memory12h,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ar *APIRouter) GetSystemStats(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
stats, err := system.GetStats(ctx)
|
||||
|
|
@ -77,21 +120,7 @@ func (ar *APIRouter) GetContainers(w http.ResponseWriter, r *http.Request) {
|
|||
allContainers = append(allContainers, containers...)
|
||||
}
|
||||
|
||||
if alertMonitor := ar.registry.Alerts(); alertMonitor != nil {
|
||||
history := alertMonitor.GetStatsHistory()
|
||||
for i := range allContainers {
|
||||
cpu1h, memory1h, has1h := history.Get1hAverages(allContainers[i].Host, allContainers[i].ID)
|
||||
cpu12h, memory12h, has12h := history.Get12hAverages(allContainers[i].Host, allContainers[i].ID)
|
||||
if has1h || has12h {
|
||||
allContainers[i].HistoricalStats = &models.HistoricalStats{
|
||||
CPU1h: cpu1h,
|
||||
Memory1h: memory1h,
|
||||
CPU12h: cpu12h,
|
||||
Memory12h: memory12h,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ar.enrichContainersWithHistoricalStats(allContainers)
|
||||
|
||||
// Build host errors list for the frontend (graceful partial results)
|
||||
hostErrorMessages := make([]map[string]string, 0, len(hostErrors))
|
||||
|
|
@ -296,8 +325,7 @@ func (ar *APIRouter) GetContainerHistoricalStats(w http.ResponseWriter, r *http.
|
|||
id := chi.URLParam(r, "id")
|
||||
host := r.URL.Query().Get("host")
|
||||
|
||||
alertMonitor := ar.registry.Alerts()
|
||||
if alertMonitor == nil {
|
||||
if ar.statsDB == nil {
|
||||
http.Error(w, "stats history not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
|
@ -307,17 +335,20 @@ func (ar *APIRouter) GetContainerHistoricalStats(w http.ResponseWriter, r *http.
|
|||
return
|
||||
}
|
||||
|
||||
history := alertMonitor.GetStatsHistory()
|
||||
cpu1h, memory1h, has1h := history.Get1hAverages(host, id)
|
||||
cpu12h, memory12h, has12h := history.Get12hAverages(host, id)
|
||||
history, err := ar.getContainerHistoricalAverages(host, id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
WriteJsonResponse(w, http.StatusOK, models.HistoricalAverages{
|
||||
CPU1h: cpu1h,
|
||||
Memory1h: memory1h,
|
||||
CPU12h: cpu12h,
|
||||
Memory12h: memory12h,
|
||||
HasData: has1h || has12h,
|
||||
})
|
||||
samples, err := ar.getContainerHistoricalSamples(host, id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
history.Samples = samples
|
||||
WriteJsonResponse(w, http.StatusOK, history)
|
||||
}
|
||||
|
||||
func (ar *APIRouter) runAsyncContainerAction(host, id, action string, fn func(context.Context) error) {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ type APIRouter struct {
|
|||
alertHandlers *AlertHandlers
|
||||
scanHandlers *ScanHandlers
|
||||
botService botRelayService
|
||||
statsDB *scanner.ScanDB
|
||||
}
|
||||
|
||||
// RouterOptions contains optional dependencies for the router
|
||||
|
|
@ -46,6 +47,7 @@ type RouterOptions struct {
|
|||
ScannerService *scanner.ScannerService
|
||||
AutoScanner *scanner.AutoScanner
|
||||
BotService botRelayService
|
||||
ScanDB *scanner.ScanDB
|
||||
}
|
||||
|
||||
func NewRouter(registry *services.Registry, manager *config.Manager, opts *RouterOptions) *chi.Mux {
|
||||
|
|
@ -58,6 +60,10 @@ func NewRouter(registry *services.Registry, manager *config.Manager, opts *Route
|
|||
}
|
||||
if opts != nil {
|
||||
r.botService = opts.BotService
|
||||
r.statsDB = opts.ScanDB
|
||||
if r.statsDB == nil && opts.ScannerService != nil {
|
||||
r.statsDB = opts.ScannerService.Store().DB()
|
||||
}
|
||||
}
|
||||
|
||||
// Set up scan handlers
|
||||
|
|
|
|||
108
home/internal/containerstats/collector.go
Normal file
108
home/internal/containerstats/collector.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package containerstats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hhftechnology/vps-monitor/internal/models"
|
||||
"github.com/hhftechnology/vps-monitor/internal/services"
|
||||
)
|
||||
|
||||
type statsStore interface {
|
||||
InsertContainerStat(stat models.ContainerStats) error
|
||||
PruneContainerStatsOlderThan(cutoff time.Time) error
|
||||
}
|
||||
|
||||
// Collector periodically samples running container stats and stores them in SQLite.
|
||||
type Collector struct {
|
||||
registry *services.Registry
|
||||
store statsStore
|
||||
interval time.Duration
|
||||
retention time.Duration
|
||||
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
lastPrune time.Time
|
||||
}
|
||||
|
||||
func NewCollector(registry *services.Registry, store statsStore, interval, retention time.Duration) *Collector {
|
||||
return &Collector{
|
||||
registry: registry,
|
||||
store: store,
|
||||
interval: interval,
|
||||
retention: retention,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) Start() {
|
||||
if c.registry == nil || c.store == nil || c.interval <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
c.wg.Add(1)
|
||||
go c.loop()
|
||||
}
|
||||
|
||||
func (c *Collector) Stop() {
|
||||
select {
|
||||
case <-c.stopCh:
|
||||
return
|
||||
default:
|
||||
close(c.stopCh)
|
||||
}
|
||||
c.wg.Wait()
|
||||
}
|
||||
|
||||
func (c *Collector) loop() {
|
||||
defer c.wg.Done()
|
||||
|
||||
c.collectOnce()
|
||||
|
||||
ticker := time.NewTicker(c.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
c.collectOnce()
|
||||
case <-c.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) collectOnce() {
|
||||
dockerClient, releaseDocker := c.registry.AcquireDocker()
|
||||
if dockerClient == nil {
|
||||
releaseDocker()
|
||||
return
|
||||
}
|
||||
defer releaseDocker()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for _, host := range dockerClient.GetHosts() {
|
||||
stats, err := dockerClient.GetAllContainersStats(ctx, host.Name)
|
||||
if err != nil {
|
||||
log.Printf("container stats collector: failed to sample host %s: %v", host.Name, err)
|
||||
continue
|
||||
}
|
||||
for _, stat := range stats {
|
||||
if err := c.store.InsertContainerStat(stat); err != nil {
|
||||
log.Printf("container stats collector: failed to persist sample for %s on %s: %v", stat.ContainerID, stat.Host, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if c.retention > 0 && (c.lastPrune.IsZero() || time.Since(c.lastPrune) >= time.Hour) {
|
||||
if err := c.store.PruneContainerStatsOlderThan(time.Now().Add(-c.retention)); err != nil {
|
||||
log.Printf("container stats collector: failed to prune old samples: %v", err)
|
||||
} else {
|
||||
c.lastPrune = time.Now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,9 +17,10 @@ type ContainerStats struct {
|
|||
}
|
||||
|
||||
type HistoricalAverages struct {
|
||||
CPU1h float64 `json:"cpu_1h"`
|
||||
Memory1h float64 `json:"memory_1h"`
|
||||
CPU12h float64 `json:"cpu_12h"`
|
||||
Memory12h float64 `json:"memory_12h"`
|
||||
HasData bool `json:"has_data"`
|
||||
CPU1h float64 `json:"cpu_1h"`
|
||||
Memory1h float64 `json:"memory_1h"`
|
||||
CPU12h float64 `json:"cpu_12h"`
|
||||
Memory12h float64 `json:"memory_12h"`
|
||||
HasData bool `json:"has_data"`
|
||||
Samples []ContainerStats `json:"samples,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,6 +184,25 @@ CREATE TABLE IF NOT EXISTS image_sbom_state (
|
|||
PRIMARY KEY (host, image_ref, format)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS container_stats (
|
||||
host TEXT NOT NULL,
|
||||
container_id TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
cpu_percent REAL NOT NULL DEFAULT 0,
|
||||
memory_percent REAL NOT NULL DEFAULT 0,
|
||||
memory_usage INTEGER NOT NULL DEFAULT 0,
|
||||
memory_limit INTEGER NOT NULL DEFAULT 0,
|
||||
network_rx INTEGER NOT NULL DEFAULT 0,
|
||||
network_tx INTEGER NOT NULL DEFAULT 0,
|
||||
block_read INTEGER NOT NULL DEFAULT 0,
|
||||
block_write INTEGER NOT NULL DEFAULT 0,
|
||||
pids INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (host, container_id, timestamp)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cs_host_container_time ON container_stats(host, container_id, timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_cs_timestamp ON container_stats(timestamp);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
|
|
@ -860,6 +879,142 @@ func (s *ScanDB) CanRegenerateSBOM(host, imageRef, format, currentImageID string
|
|||
return state.ImageID != currentImageID, nil
|
||||
}
|
||||
|
||||
// InsertContainerStat stores a single container stats sample.
|
||||
func (s *ScanDB) InsertContainerStat(stat models.ContainerStats) error {
|
||||
_, err := s.db.Exec(`INSERT OR REPLACE INTO container_stats (
|
||||
host, container_id, timestamp, cpu_percent, memory_percent,
|
||||
memory_usage, memory_limit, network_rx, network_tx,
|
||||
block_read, block_write, pids
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
stat.Host,
|
||||
stat.ContainerID,
|
||||
stat.Timestamp,
|
||||
stat.CPUPercent,
|
||||
stat.MemoryPercent,
|
||||
stat.MemoryUsage,
|
||||
stat.MemoryLimit,
|
||||
stat.NetworkRx,
|
||||
stat.NetworkTx,
|
||||
stat.BlockRead,
|
||||
stat.BlockWrite,
|
||||
stat.PIDs,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetContainerHistoricalAverages returns 1h and 12h averages for a container.
|
||||
func (s *ScanDB) GetContainerHistoricalAverages(host, containerID string, now time.Time) (models.HistoricalAverages, error) {
|
||||
var result models.HistoricalAverages
|
||||
|
||||
since1h := now.Add(-time.Hour).Unix()
|
||||
since12h := now.Add(-12 * time.Hour).Unix()
|
||||
var count1h int
|
||||
var count12h int
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
SELECT
|
||||
COALESCE(AVG(CASE WHEN timestamp >= ? THEN cpu_percent END), 0),
|
||||
COALESCE(AVG(CASE WHEN timestamp >= ? THEN memory_percent END), 0),
|
||||
COALESCE(AVG(cpu_percent), 0),
|
||||
COALESCE(AVG(memory_percent), 0),
|
||||
COUNT(CASE WHEN timestamp >= ? THEN 1 END),
|
||||
COUNT(*)
|
||||
FROM container_stats
|
||||
WHERE host = ? AND container_id = ? AND timestamp >= ?`,
|
||||
since1h,
|
||||
since1h,
|
||||
since1h,
|
||||
host,
|
||||
containerID,
|
||||
since12h,
|
||||
).Scan(
|
||||
&result.CPU1h,
|
||||
&result.Memory1h,
|
||||
&result.CPU12h,
|
||||
&result.Memory12h,
|
||||
&count1h,
|
||||
&count12h,
|
||||
)
|
||||
if err != nil {
|
||||
return models.HistoricalAverages{}, err
|
||||
}
|
||||
|
||||
if count1h == 0 {
|
||||
result.CPU1h = 0
|
||||
result.Memory1h = 0
|
||||
}
|
||||
if count12h == 0 {
|
||||
result.CPU12h = 0
|
||||
result.Memory12h = 0
|
||||
}
|
||||
result.HasData = count1h > 0 || count12h > 0
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetRecentContainerStats returns recent samples in ascending timestamp order.
|
||||
func (s *ScanDB) GetRecentContainerStats(host, containerID string, since time.Time, limit int) ([]models.ContainerStats, error) {
|
||||
if limit <= 0 {
|
||||
return []models.ContainerStats{}, nil
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(`
|
||||
SELECT host, container_id, cpu_percent, memory_usage, memory_limit,
|
||||
memory_percent, network_rx, network_tx, block_read, block_write, pids, timestamp
|
||||
FROM (
|
||||
SELECT host, container_id, cpu_percent, memory_usage, memory_limit,
|
||||
memory_percent, network_rx, network_tx, block_read, block_write, pids, timestamp
|
||||
FROM container_stats
|
||||
WHERE host = ? AND container_id = ? AND timestamp >= ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
)
|
||||
ORDER BY timestamp ASC`,
|
||||
host,
|
||||
containerID,
|
||||
since.Unix(),
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
samples := make([]models.ContainerStats, 0, limit)
|
||||
for rows.Next() {
|
||||
var stat models.ContainerStats
|
||||
if err := rows.Scan(
|
||||
&stat.Host,
|
||||
&stat.ContainerID,
|
||||
&stat.CPUPercent,
|
||||
&stat.MemoryUsage,
|
||||
&stat.MemoryLimit,
|
||||
&stat.MemoryPercent,
|
||||
&stat.NetworkRx,
|
||||
&stat.NetworkTx,
|
||||
&stat.BlockRead,
|
||||
&stat.BlockWrite,
|
||||
&stat.PIDs,
|
||||
&stat.Timestamp,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
samples = append(samples, stat)
|
||||
}
|
||||
|
||||
if samples == nil {
|
||||
samples = []models.ContainerStats{}
|
||||
}
|
||||
|
||||
return samples, rows.Err()
|
||||
}
|
||||
|
||||
// PruneContainerStatsOlderThan removes raw samples older than the cutoff.
|
||||
func (s *ScanDB) PruneContainerStatsOlderThan(cutoff time.Time) error {
|
||||
_, err := s.db.Exec(`DELETE FROM container_stats WHERE timestamp < ?`, cutoff.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
|
||||
// GetSetting returns a setting value by key.
|
||||
|
|
|
|||
146
home/internal/scanner/db_container_stats_test.go
Normal file
146
home/internal/scanner/db_container_stats_test.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package scanner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hhftechnology/vps-monitor/internal/models"
|
||||
)
|
||||
|
||||
func TestNewScanDBCreatesContainerStatsTable(t *testing.T) {
|
||||
db := newTestScanDB(t)
|
||||
|
||||
var tableName string
|
||||
if err := db.db.QueryRow(
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'container_stats'`,
|
||||
).Scan(&tableName); err != nil {
|
||||
t.Fatalf("expected container_stats table to exist: %v", err)
|
||||
}
|
||||
|
||||
if tableName != "container_stats" {
|
||||
t.Fatalf("unexpected table name: %q", tableName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerStatsAveragesUseConfiguredWindows(t *testing.T) {
|
||||
db := newTestScanDB(t)
|
||||
now := time.Unix(1_700_000_000, 0).UTC()
|
||||
|
||||
samples := []models.ContainerStats{
|
||||
{
|
||||
ContainerID: "container-1",
|
||||
Host: "host-a",
|
||||
CPUPercent: 90,
|
||||
MemoryPercent: 95,
|
||||
Timestamp: now.Add(-13 * time.Hour).Unix(),
|
||||
},
|
||||
{
|
||||
ContainerID: "container-1",
|
||||
Host: "host-a",
|
||||
CPUPercent: 40,
|
||||
MemoryPercent: 60,
|
||||
Timestamp: now.Add(-2 * time.Hour).Unix(),
|
||||
},
|
||||
{
|
||||
ContainerID: "container-1",
|
||||
Host: "host-a",
|
||||
CPUPercent: 10,
|
||||
MemoryPercent: 20,
|
||||
Timestamp: now.Add(-30 * time.Minute).Unix(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, sample := range samples {
|
||||
if err := db.InsertContainerStat(sample); err != nil {
|
||||
t.Fatalf("InsertContainerStat() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
history, err := db.GetContainerHistoricalAverages("host-a", "container-1", now)
|
||||
if err != nil {
|
||||
t.Fatalf("GetContainerHistoricalAverages() error = %v", err)
|
||||
}
|
||||
|
||||
if !history.HasData {
|
||||
t.Fatal("expected historical data")
|
||||
}
|
||||
if history.CPU1h != 10 || history.Memory1h != 20 {
|
||||
t.Fatalf("unexpected 1h averages: cpu=%v memory=%v", history.CPU1h, history.Memory1h)
|
||||
}
|
||||
if history.CPU12h != 25 || history.Memory12h != 40 {
|
||||
t.Fatalf("unexpected 12h averages: cpu=%v memory=%v", history.CPU12h, history.Memory12h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRecentContainerStatsReturnsAscendingSeries(t *testing.T) {
|
||||
db := newTestScanDB(t)
|
||||
now := time.Unix(1_700_000_000, 0).UTC()
|
||||
|
||||
for _, sample := range []models.ContainerStats{
|
||||
{ContainerID: "container-1", Host: "host-a", CPUPercent: 10, Timestamp: now.Add(-3 * time.Minute).Unix()},
|
||||
{ContainerID: "container-1", Host: "host-a", CPUPercent: 20, Timestamp: now.Add(-2 * time.Minute).Unix()},
|
||||
{ContainerID: "container-1", Host: "host-a", CPUPercent: 30, Timestamp: now.Add(-1 * time.Minute).Unix()},
|
||||
} {
|
||||
if err := db.InsertContainerStat(sample); err != nil {
|
||||
t.Fatalf("InsertContainerStat() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
series, err := db.GetRecentContainerStats("host-a", "container-1", now.Add(-12*time.Hour), 2)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecentContainerStats() error = %v", err)
|
||||
}
|
||||
|
||||
if len(series) != 2 {
|
||||
t.Fatalf("expected 2 samples, got %d", len(series))
|
||||
}
|
||||
if series[0].CPUPercent != 20 || series[1].CPUPercent != 30 {
|
||||
t.Fatalf("expected ascending latest samples, got %+v", series)
|
||||
}
|
||||
if series[0].Timestamp >= series[1].Timestamp {
|
||||
t.Fatalf("expected ascending timestamps, got %d then %d", series[0].Timestamp, series[1].Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneContainerStatsOlderThanRemovesExpiredRows(t *testing.T) {
|
||||
db := newTestScanDB(t)
|
||||
now := time.Unix(1_700_000_000, 0).UTC()
|
||||
|
||||
oldSample := models.ContainerStats{
|
||||
ContainerID: "container-1",
|
||||
Host: "host-a",
|
||||
CPUPercent: 10,
|
||||
MemoryPercent: 10,
|
||||
Timestamp: now.Add(-31 * 24 * time.Hour).Unix(),
|
||||
}
|
||||
newSample := models.ContainerStats{
|
||||
ContainerID: "container-1",
|
||||
Host: "host-a",
|
||||
CPUPercent: 20,
|
||||
MemoryPercent: 20,
|
||||
Timestamp: now.Add(-1 * time.Hour).Unix(),
|
||||
}
|
||||
|
||||
if err := db.InsertContainerStat(oldSample); err != nil {
|
||||
t.Fatalf("InsertContainerStat(oldSample) error = %v", err)
|
||||
}
|
||||
if err := db.InsertContainerStat(newSample); err != nil {
|
||||
t.Fatalf("InsertContainerStat(newSample) error = %v", err)
|
||||
}
|
||||
|
||||
if err := db.PruneContainerStatsOlderThan(now.Add(-30 * 24 * time.Hour)); err != nil {
|
||||
t.Fatalf("PruneContainerStatsOlderThan() error = %v", err)
|
||||
}
|
||||
|
||||
series, err := db.GetRecentContainerStats("host-a", "container-1", now.Add(-90*24*time.Hour), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecentContainerStats() error = %v", err)
|
||||
}
|
||||
|
||||
if len(series) != 1 {
|
||||
t.Fatalf("expected 1 sample after pruning, got %d", len(series))
|
||||
}
|
||||
if series[0].Timestamp != newSample.Timestamp {
|
||||
t.Fatalf("expected recent sample to remain, got %+v", series[0])
|
||||
}
|
||||
}
|
||||
|
|
@ -208,3 +208,9 @@ func (r *Registry) UpdateConfig(cfg *config.Config) {
|
|||
defer r.mu.Unlock()
|
||||
r.config = cfg
|
||||
}
|
||||
|
||||
func (r *Registry) SwapAlerts(monitor *alerts.Monitor) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.alerts = monitor
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue