UI-Container-Data-Fixes
Some checks are pending
Docker Publish / build-and-push (push) Waiting to run
Docker Publish / commit-readme-image-size (push) Blocked by required conditions

This commit is contained in:
hhftechnologies 2026-04-23 12:10:01 +05:30
parent 21a5f9e01f
commit fd455dabec
17 changed files with 1403 additions and 377 deletions

View 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>
);
});

View file

@ -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(

View file

@ -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();
});
});

View file

@ -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">

View file

@ -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>
);
}

View file

@ -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",
);
});
});

View file

@ -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)

View file

@ -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,
}

View file

@ -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

View 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))
}

View file

@ -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) {

View file

@ -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

View 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()
}
}
}

View file

@ -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"`
}

View file

@ -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.

View 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])
}
}

View file

@ -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
}