Merge pull request #18 from hhftechnology/dev

Add bot service, persistent container stats, and enhanced charts UI
This commit is contained in:
HHF Technology 2026-04-24 22:04:22 +05:30 committed by GitHub
commit bc16708f96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 7391 additions and 2077 deletions

View file

@ -1,6 +1,6 @@
{
"name": "vps-monitor",
"version": "2.0.0",
"version": "2.3.1",
"private": true,
"description": "VPS Monitor",
"author": "HHF Technology <https://github.com/hhftechnology>",

View file

@ -13,18 +13,6 @@ export function Footer() {
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground transition-colors hover:text-foreground"
>
<svg className="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 330 330" fill="none">
<path
fill="currentColor"
d="M68 290.485c-5.5 9.6-17.8 12.8-27.3 7.3-9.6-5.5-12.8-17.8-7.3-27.3l14.3-24.7q24.15-7.35 39.6 11.4zm138.9-53.9H25c-11 0-20-9-20-20s9-20 20-20h51l65.4-113.2-20.5-35.4c-5.5-9.6-2.2-21.8 7.3-27.3 9.6-5.5 21.8-2.2 27.3 7.3l8.9 15.4 8.9-15.4c5.5-9.6 17.8-12.8 27.3-7.3 9.6 5.5 12.8 17.8 7.3 27.3l-85.8 148.6h62.1c20.2 0 31.5 23.7 22.7 40m98.1 0h-29l19.6 33.9c5.5 9.6 2.2 21.8-7.3 27.3-9.6 5.5-21.8 2.2-27.3-7.3-32.9-56.9-57.5-99.7-74-128.1-16.7-29-4.8-58 7.1-67.8 13.1 22.7 32.7 56.7 58.9 102h52c11 0 20 9 20 20 0 11.1-9 20-20 20"
/>
</svg>
<span className="sr-only">Google Play</span>
</a>
<a
href="#"
className="text-muted-foreground transition-colors hover:text-foreground"
>
<svg className="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
<path
@ -33,7 +21,7 @@ export function Footer() {
d="M3.739.505c.519-.038 1.024.15 1.55.422.53.276 1.179.692 1.996 1.216l5.433 3.483c.47.301.86.55 1.148.774.289.225.55.48.696.822.21.497.21 1.058 0 1.555-.145.343-.407.598-.696.823s-.678.473-1.148.774l-5.433 3.483c-.817.524-1.466.94-1.996 1.216-.526.272-1.031.46-1.55.422a2.68 2.68 0 0 1-1.955-1.069 2 2 0 0 1-.276-.515c-.143-.387-.202-.85-.23-1.376-.029-.53-.028-1.186-.028-1.978V5.443c0-1.336-.005-2.322.156-3.011.074-.315.189-.605.378-.858A2.68 2.68 0 0 1 3.74.504M2.879 13.8c.247.265.585.428.95.454.165.012.415-.04.887-.286.468-.242 1.06-.622 1.898-1.158l3.13-2.007L7.883 9.03zm5.904-5.63 2.038 1.942 1.226-.785c.49-.315.822-.528 1.056-.709.232-.181.294-.277.314-.325a.75.75 0 0 0 0-.588c-.02-.049-.081-.144-.314-.325-.234-.181-.565-.395-1.056-.71l-1.011-.648zM3.83 1.745c-.417.03-.8.239-1.05.573a1 1 0 0 0-.05.08l5.154 4.915 2.076-1.98L6.614 3.19c-.838-.536-1.43-.916-1.898-1.158-.472-.245-.722-.298-.887-.286m-1.336 8.812c0 .783 0 1.388.025 1.871l4.465-4.257-4.477-4.269c-.011.413-.013.918-.013 1.54z"
/>
</svg>
<span className="sr-only">Apple App Store</span>
<span className="sr-only">Google Play</span>
</a>
<a
href="https://github.com/hhftechnology/vps-monitor"

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

@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { restartContainer } from "./container-actions";
const storage = new Map<string, string>();
describe("container-actions", () => {
beforeEach(() => {
vi.restoreAllMocks();
storage.clear();
vi.stubGlobal("localStorage", {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
});
});
it("marks 202 responses as pending", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
message: "Container restart initiated",
status: "pending",
}),
{
status: 202,
headers: { "Content-Type": "application/json" },
},
),
),
);
const result = await restartContainer("abc123", "host-a");
expect(result).toEqual({
message: "Container restart initiated",
isPending: true,
});
});
});

View file

@ -6,48 +6,54 @@ const BASE_URL = `${API_BASE_URL}/api/v1/containers`;
type ContainerAction = "start" | "stop" | "restart" | "remove";
interface ActionResponse {
message?: string;
message?: string;
status?: string;
}
export interface ActionResult {
message: string;
isPending: boolean;
}
async function performContainerAction(
id: string,
action: ContainerAction,
host: string
): Promise<string> {
const endpoint = `${BASE_URL}/${encodeURIComponent(id)}/${action}?host=${encodeURIComponent(host)}`;
const response = await authenticatedFetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
id: string,
action: ContainerAction,
host: string,
): Promise<ActionResult> {
const endpoint = `${BASE_URL}/${encodeURIComponent(id)}/${action}?host=${encodeURIComponent(host)}`;
const response = await authenticatedFetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Failed to ${action} container`);
}
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Failed to ${action} container`);
}
const data = (await response.json()) as ActionResponse | undefined;
const isPending = response.status === 202;
const data = (await response.json()) as ActionResponse | undefined;
if (data && typeof data.message === "string") {
return data.message;
}
return "Action completed successfully";
return {
message: data?.message || "Action completed successfully",
isPending,
};
}
export function startContainer(id: string, host: string) {
return performContainerAction(id, "start", host);
return performContainerAction(id, "start", host);
}
export function stopContainer(id: string, host: string) {
return performContainerAction(id, "stop", host);
return performContainerAction(id, "stop", host);
}
export function restartContainer(id: string, host: string) {
return performContainerAction(id, "restart", host);
return performContainerAction(id, "restart", host);
}
export function removeContainer(id: string, host: string) {
return performContainerAction(id, "remove", host);
return performContainerAction(id, "remove", host);
}

View file

@ -0,0 +1,32 @@
import { authenticatedFetch } from "@/lib/api-client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getContainerHistory } from "./get-container-history";
vi.mock("@/lib/api-client", () => ({
authenticatedFetch: vi.fn(),
}));
const mockFetch = authenticatedFetch as ReturnType<typeof vi.fn>;
describe("getContainerHistory", () => {
afterEach(() => vi.clearAllMocks());
it("normalizes missing samples to an empty array", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
cpu_1h: 1,
memory_1h: 2,
cpu_12h: 3,
memory_12h: 4,
has_data: true,
}),
} as unknown as Response);
const history = await getContainerHistory("container-1", "local");
expect(history.samples).toEqual([]);
});
});

View file

@ -0,0 +1,30 @@
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(
id: string,
host: string,
): Promise<ContainerHistoryStats> {
const endpoint = `${API_BASE_URL}/api/v1/containers/${encodeURIComponent(id)}/stats/history?host=${encodeURIComponent(host)}`;
const response = await authenticatedFetch(endpoint);
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
const data = (await response.json()) as ContainerHistoryStats;
return {
...data,
samples: data.samples ?? [],
};
}

View file

@ -0,0 +1,214 @@
import type { ReactNode } from "react";
import { fireEvent, 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("@/components/ui/tabs", async () => {
const React = await import("react");
const TabsContext = React.createContext<{ value: string; onValueChange: (v: string) => void }>({ value: "", onValueChange: () => {} });
return {
Tabs: ({ value, defaultValue, onValueChange, children }: any) => {
const [v, setV] = React.useState(value || defaultValue);
const handleChange = (newV: string) => {
setV(newV);
if (onValueChange) onValueChange(newV);
};
React.useEffect(() => { if (value !== undefined) setV(value); }, [value]);
return <TabsContext.Provider value={{ value: v, onValueChange: handleChange }}><div>{children}</div></TabsContext.Provider>;
},
TabsContent: ({ value, children }: any) => {
const context = React.useContext(TabsContext);
if (context.value !== value) return null;
return <div>{children}</div>;
},
TabsList: ({ children }: any) => <div>{children}</div>,
TabsTrigger: ({ value, children, disabled, ...props }: any) => {
const context = React.useContext(TabsContext);
return <button type="button" disabled={disabled} onClick={() => context.onValueChange(value)} {...props}>{children}</button>;
},
};
});
vi.mock("./environment-variables", () => ({
EnvironmentVariables: ({
onContainerIdChange,
}: {
onContainerIdChange: (containerId: string) => void;
}) => (
<button type="button" onClick={() => onContainerIdChange("container-2")}>
Update container id
</button>
),
}));
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();
});
it("keeps hooks stable with a null container and respects child container id updates", async () => {
const { rerender } = render(
<ContainerDetailsSheet
container={null}
host="local"
isOpen
onOpenChange={vi.fn()}
/>,
);
rerender(
<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",
}}
host="local"
isOpen
onOpenChange={vi.fn()}
/>,
);
fireEvent.click(
await screen.findByRole("button", { name: /env vars/i }),
);
fireEvent.click(
await screen.findByRole("button", { name: /update container id/i }),
);
expect(mockUseContainerStats).toHaveBeenLastCalledWith(
expect.objectContaining({ containerId: "container-2" }),
);
});
});

View file

@ -1,336 +1,409 @@
import {
ActivityIcon,
CpuIcon,
HardDriveIcon,
MemoryStickIcon,
NetworkIcon,
PlayIcon,
SettingsIcon,
SquareIcon,
TerminalIcon,
ActivityIcon,
CpuIcon,
HardDriveIcon,
MemoryStickIcon,
NetworkIcon,
PlayIcon,
SettingsIcon,
SquareIcon,
TerminalIcon,
} from "lucide-react";
import { lazy, Suspense, useCallback, useMemo, useState } from "react";
import {
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
Tooltip,
TooltipContent,
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";
import { EnvironmentVariables } from "./environment-variables";
import type { ContainerInfo } from "../types";
// Lazy load terminal to reduce initial bundle size
const Terminal = lazy(() =>
import("./terminal").then((module) => ({ default: module.Terminal }))
import("./terminal").then((module) => ({ default: module.Terminal })),
);
interface ContainerDetailsSheetProps {
container: ContainerInfo | null;
host: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
isReadOnly?: boolean;
container: ContainerInfo | null;
host: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
isReadOnly?: boolean;
}
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]}`;
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 formatPercent(value: number): string {
return `${value.toFixed(1)}%`;
return `${value.toFixed(1)}%`;
}
export function ContainerDetailsSheet({
container,
host,
isOpen,
onOpenChange,
isReadOnly = false,
container,
host,
isOpen,
onOpenChange,
isReadOnly = false,
}: ContainerDetailsSheetProps) {
const [activeTab, setActiveTab] = useState("stats");
const [containerId, setContainerId] = useState(container?.id ?? "");
const [activeTab, setActiveTab] = useState("stats");
const [containerId, setContainerId] = useState(container?.id ?? "");
// Update containerId when container changes
const effectiveContainerId = container?.id ?? containerId;
useEffect(() => {
setContainerId(container?.id ?? "");
}, [container?.id]);
const {
stats,
history,
isConnected,
error,
connect,
disconnect,
clearHistory,
} = useContainerStats({
containerId: effectiveContainerId,
host,
enabled: isOpen && activeTab === "stats",
});
const effectiveContainerId = containerId || container?.id || "";
const handleContainerIdChange = useCallback((newId: string) => {
setContainerId(newId);
}, []);
const {
stats,
history,
isConnected,
error,
connect,
disconnect,
clearHistory,
} = useContainerStats({
containerId: effectiveContainerId,
host,
enabled: isOpen && activeTab === "stats",
});
const { data: persistedHistory } = useContainerHistory(
effectiveContainerId,
host,
isOpen && activeTab === "stats",
);
const handleToggleStats = useCallback(() => {
if (isConnected) {
disconnect();
} else {
clearHistory();
connect();
}
}, [isConnected, disconnect, clearHistory, connect]);
const handleContainerIdChange = useCallback((newId: string) => {
setContainerId(newId);
}, []);
const statsCards = useMemo(() => {
if (!stats) return null;
const handleToggleStats = useCallback(() => {
if (isConnected) {
disconnect();
} else {
clearHistory();
connect();
}
}, [isConnected, disconnect, clearHistory, connect]);
return [
{
label: "CPU",
value: formatPercent(stats.cpu_percent),
icon: CpuIcon,
color: stats.cpu_percent > 80 ? "text-red-500" : "text-primary",
},
{
label: "Memory",
value: `${formatBytes(stats.memory_usage)} / ${formatBytes(stats.memory_limit)}`,
subValue: formatPercent(stats.memory_percent),
icon: MemoryStickIcon,
color: stats.memory_percent > 80 ? "text-red-500" : "text-primary",
},
{
label: "Network I/O",
value: `${formatBytes(stats.network_rx)} / ${formatBytes(stats.network_tx)}`,
subLabel: "RX / TX",
icon: NetworkIcon,
color: "text-primary",
},
{
label: "Block I/O",
value: `${formatBytes(stats.block_read)} / ${formatBytes(stats.block_write)}`,
subLabel: "Read / Write",
icon: HardDriveIcon,
color: "text-primary",
},
{
label: "PIDs",
value: stats.pids.toString(),
icon: ActivityIcon,
color: "text-primary",
},
];
}, [stats]);
const statsCards = useMemo(() => {
if (!stats) return null;
if (!container) return null;
return [
{
label: "CPU",
value: formatPercent(stats.cpu_percent),
icon: CpuIcon,
color: stats.cpu_percent > 80 ? "text-red-500" : "text-primary",
},
{
label: "Memory",
value: `${formatBytes(stats.memory_usage)} / ${formatBytes(stats.memory_limit)}`,
subValue: formatPercent(stats.memory_percent),
icon: MemoryStickIcon,
color: stats.memory_percent > 80 ? "text-red-500" : "text-primary",
},
{
label: "Network I/O",
value: `${formatBytes(stats.network_rx)} / ${formatBytes(stats.network_tx)}`,
subLabel: "RX / TX",
icon: NetworkIcon,
color: "text-primary",
},
{
label: "Block I/O",
value: `${formatBytes(stats.block_read)} / ${formatBytes(stats.block_write)}`,
subLabel: "Read / Write",
icon: HardDriveIcon,
color: "text-primary",
},
{
label: "PIDs",
value: stats.pids.toString(),
icon: ActivityIcon,
color: "text-primary",
},
];
}, [stats]);
const containerName = container.names?.[0]?.replace(/^\//, "") || container.id.slice(0, 12);
const isRunning = container.state.toLowerCase() === "running";
const chartHistory = useMemo(() => {
const persistedSamples = persistedHistory?.samples ?? [];
if (persistedSamples.length === 0) {
return history;
}
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-2xl w-full overflow-y-auto p-0">
<SheetHeader className="px-6 pt-6">
<SheetTitle className="flex items-center gap-2">
{containerName}
<Badge
variant={isRunning ? "default" : "secondary"}
className="text-xs"
>
{container.state}
</Badge>
</SheetTitle>
<SheetDescription className="text-xs font-mono truncate">
{container.image}
</SheetDescription>
</SheetHeader>
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);
}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 flex flex-col px-6 pb-6"
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="stats" className="flex items-center gap-2">
<ActivityIcon className="size-4" />
Stats
</TabsTrigger>
<TabsTrigger
value="terminal"
className="flex items-center gap-2"
disabled={!isRunning}
>
<TerminalIcon className="size-4" />
Terminal
</TabsTrigger>
<TabsTrigger value="env" className="flex items-center gap-2">
<SettingsIcon className="size-4" />
Env Vars
</TabsTrigger>
</TabsList>
return Array.from(merged.values())
.sort((a, b) => a.timestamp - b.timestamp)
.slice(-60);
}, [history, persistedHistory?.samples]);
<TabsContent value="stats" className="space-y-6 mt-4">
{/* Stats Controls */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Badge variant={isConnected ? "default" : "secondary"}>
{isConnected ? "Live" : "Disconnected"}
</Badge>
{history.length > 0 && (
<span className="text-xs text-muted-foreground">
{history.length} data points
</span>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isConnected ? "default" : "outline"}
size="sm"
onClick={handleToggleStats}
disabled={!isRunning}
>
{isConnected ? (
<>
<SquareIcon className="mr-2 size-4" />
Stop
</>
) : (
<>
<PlayIcon className="mr-2 size-4" />
Start
</>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{!isRunning
? "Container must be running"
: isConnected
? "Stop streaming stats"
: "Start streaming stats"}
</TooltipContent>
</Tooltip>
</div>
if (!container) return null;
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
const containerName =
container.names?.[0]?.replace(/^\//, "") || container.id.slice(0, 12);
const isRunning = container.state.toLowerCase() === "running";
const historyStats = container.historical_stats;
{!isRunning && (
<Card>
<CardContent className="py-8 text-center text-muted-foreground text-sm">
Container is not running. Start the container to view stats.
</CardContent>
</Card>
)}
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<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}
<Badge
variant={isRunning ? "default" : "secondary"}
className="text-xs"
>
{container.state}
</Badge>
</SheetTitle>
<SheetDescription className="text-xs font-mono truncate">
{container.image}
</SheetDescription>
</SheetHeader>
{isRunning && isConnected && !stats && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Spinner className="mr-2 size-4" />
Connecting...
</div>
)}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
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">
<ActivityIcon className="size-4" />
Stats
</TabsTrigger>
<TabsTrigger
value="terminal"
className="flex items-center gap-2"
disabled={!isRunning}
>
<TerminalIcon className="size-4" />
Terminal
</TabsTrigger>
<TabsTrigger value="env" className="flex items-center gap-2">
<SettingsIcon className="size-4" />
Env Vars
</TabsTrigger>
</TabsList>
{isRunning && !isConnected && !stats && (
<Card>
<CardContent className="py-8 text-center text-muted-foreground text-sm">
Click "Start" to stream real-time container stats
</CardContent>
</Card>
)}
<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>
<CardContent className="p-3 text-sm">
1h CPU
<div className="font-semibold">
{historyStats.cpu_1h != null ? `${historyStats.cpu_1h.toFixed(1)}%` : "N/A"}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-3 text-sm">
1h RAM
<div className="font-semibold">
{historyStats.memory_1h != null ? `${historyStats.memory_1h.toFixed(1)}%` : "N/A"}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-3 text-sm">
12h CPU
<div className="font-semibold">
{historyStats.cpu_12h != null ? `${historyStats.cpu_12h.toFixed(1)}%` : "N/A"}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-3 text-sm">
12h RAM
<div className="font-semibold">
{historyStats.memory_12h != null ? `${historyStats.memory_12h.toFixed(1)}%` : "N/A"}
</div>
</CardContent>
</Card>
</div>
)}
{/* Stats Controls */}
<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"}
</Badge>
{history.length > 0 && (
<span className="text-xs text-muted-foreground">
{history.length} data points
</span>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isConnected ? "default" : "outline"}
size="sm"
onClick={handleToggleStats}
disabled={!isRunning}
>
{isConnected ? (
<>
<SquareIcon className="mr-2 size-4" />
Stop
</>
) : (
<>
<PlayIcon className="mr-2 size-4" />
Start
</>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{!isRunning
? "Container must be running"
: isConnected
? "Stop streaming stats"
: "Start streaming stats"}
</TooltipContent>
</Tooltip>
</div>
{/* Live Stats Cards */}
{statsCards && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg: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={`p-2 rounded-lg bg-muted ${card.color}`}>
<Icon className="size-5" />
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground font-medium">
{card.label}
</p>
<p className="text-sm font-semibold truncate max-w-full">
{card.value}
</p>
{card.subValue && (
<p className="text-xs text-muted-foreground">
{card.subValue}
</p>
)}
{card.subLabel && (
<p className="text-[10px] text-muted-foreground">
{card.subLabel}
</p>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
{/* Stats Charts */}
<ContainerStatsCharts history={history} />
</TabsContent>
{!isRunning && (
<Card>
<CardContent className="py-8 text-center text-muted-foreground text-sm">
Container is not running. Start the container to view stats.
</CardContent>
</Card>
)}
<TabsContent value="terminal" className="min-h-[400px] mt-4">
{isRunning ? (
<Suspense
fallback={
<div className="flex items-center justify-center h-[400px] text-muted-foreground">
<Spinner className="mr-2 size-4" />
Loading terminal...
</div>
}
>
<Terminal containerId={effectiveContainerId} host={host} />
</Suspense>
) : (
<Card>
<CardContent className="py-8 text-center text-muted-foreground text-sm">
Container is not running. Start the container to access terminal.
</CardContent>
</Card>
)}
</TabsContent>
{isRunning && isConnected && !stats && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Spinner className="mr-2 size-4" />
Connecting...
</div>
)}
<TabsContent value="env" className="mt-4">
<EnvironmentVariables
containerId={effectiveContainerId}
containerHost={host}
isReadOnly={isReadOnly}
onContainerIdChange={handleContainerIdChange}
/>
</TabsContent>
</Tabs>
</SheetContent>
</Sheet>
);
{isRunning && !isConnected && !stats && (
<Card>
<CardContent className="py-8 text-center text-muted-foreground text-sm">
Click "Start" to stream real-time container stats
</CardContent>
</Card>
)}
{/* Live Stats Cards */}
{statsCards && (
<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 gap-2 text-center">
<div
className={`p-2 rounded-lg bg-muted ${card.color}`}
>
<Icon className="size-5" />
</div>
<div className="min-w-0">
<p className="text-xs text-muted-foreground font-medium">
{card.label}
</p>
<p className="break-words text-sm font-semibold leading-tight">
{card.value}
</p>
{card.subValue && (
<p className="text-xs text-muted-foreground">
{card.subValue}
</p>
)}
{card.subLabel && (
<p className="text-[10px] text-muted-foreground">
{card.subLabel}
</p>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Stats Charts */}
<ContainerStatsCharts history={chartHistory} />
</TabsContent>
<TabsContent value="terminal" className="min-h-[400px] mt-4">
{isRunning ? (
<Suspense
fallback={
<div className="flex items-center justify-center h-[400px] text-muted-foreground">
<Spinner className="mr-2 size-4" />
Loading terminal...
</div>
}
>
<Terminal containerId={effectiveContainerId} host={host} />
</Suspense>
) : (
<Card>
<CardContent className="py-8 text-center text-muted-foreground text-sm">
Container is not running. Start the container to access
terminal.
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="env" className="mt-4">
<EnvironmentVariables
containerId={effectiveContainerId}
containerHost={host}
isReadOnly={isReadOnly}
onContainerIdChange={handleContainerIdChange}
/>
</TabsContent>
</Tabs>
</SheetContent>
</Sheet>
);
}

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,66 @@
import { describe, expect, it } from "vitest";
import { getHistoricalValue, groupByCompose } from "./container-utils";
describe("groupByCompose", () => {
it("sorts compose groups by newest container when descending", () => {
const groups = groupByCompose(
[
{
id: "1",
names: ["/web-1"],
image: "img",
image_id: "sha",
command: "run",
created: 100,
state: "running",
status: "up",
host: "host-a",
labels: { "com.docker.compose.project": "project-old" },
},
{
id: "2",
names: ["/api-1"],
image: "img",
image_id: "sha",
command: "run",
created: 200,
state: "running",
status: "up",
host: "host-a",
labels: { "com.docker.compose.project": "project-new" },
},
],
"desc",
);
expect(groups.map((group) => group.project)).toEqual([
"project-new",
"project-old",
]);
});
});
describe("getHistoricalValue", () => {
it("returns null when the selected historical field is missing", () => {
const container = {
id: "1",
names: ["/api"],
image: "img",
image_id: "sha",
command: "run",
created: 100,
state: "running",
status: "up",
host: "host-a",
historical_stats: {
cpu_1h: 12,
memory_1h: undefined,
cpu_12h: 20,
memory_12h: 30,
},
} as any;
expect(getHistoricalValue(container, "1h", "memory")).toBeNull();
});
});

View file

@ -3,111 +3,148 @@ import type { ContainerInfo } from "../types";
export type SortDirection = "asc" | "desc";
export type GroupByOption = "none" | "compose";
export type ContainerActionType = "start" | "stop" | "restart" | "remove";
export type StatsInterval = "1h" | "12h";
export type SortColumn =
| "name"
| "state"
| "uptime"
| "created"
| "cpu"
| "ram";
export interface GroupedContainers {
project: string;
items: ContainerInfo[];
project: string;
items: ContainerInfo[];
}
export interface StateCounts {
running: number;
exited: number;
paused: number;
restarting: number;
dead: number;
other: number;
running: number;
exited: number;
paused: number;
restarting: number;
dead: number;
other: number;
}
const COMPOSE_PROJECT_LABEL = "com.docker.compose.project";
export function formatContainerName(names: string[]) {
if (!names.length) {
return "—";
}
const [primary] = names;
return primary.startsWith("/") ? primary.slice(1) : primary;
if (!names.length) {
return "—";
}
const [primary] = names;
return primary.startsWith("/") ? primary.slice(1) : primary;
}
export function formatCreatedDate(createdSeconds: number) {
const createdDate = new Date(createdSeconds * 1000);
return createdDate.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
const createdDate = new Date(createdSeconds * 1000);
return createdDate.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
export function formatUptime(createdSeconds: number) {
const now = Date.now();
const createdMs = createdSeconds * 1000;
const diffMs = now - createdMs;
const now = Date.now();
const createdMs = createdSeconds * 1000;
const diffMs = now - createdMs;
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const weeks = Math.floor(days / 7);
const months = Math.floor(days / 30);
const years = Math.floor(days / 365);
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const weeks = Math.floor(days / 7);
const months = Math.floor(days / 30);
const years = Math.floor(days / 365);
if (years > 0) return `${years} year${years > 1 ? "s" : ""}`;
if (months > 0) return `${months} month${months > 1 ? "s" : ""}`;
if (weeks > 0) return `${weeks} week${weeks > 1 ? "s" : ""}`;
if (days > 0) return `${days} day${days > 1 ? "s" : ""}`;
if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""}`;
if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""}`;
return `${seconds} second${seconds !== 1 ? "s" : ""}`;
if (years > 0) return `${years} year${years > 1 ? "s" : ""}`;
if (months > 0) return `${months} month${months > 1 ? "s" : ""}`;
if (weeks > 0) return `${weeks} week${weeks > 1 ? "s" : ""}`;
if (days > 0) return `${days} day${days > 1 ? "s" : ""}`;
if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""}`;
if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""}`;
return `${seconds} second${seconds !== 1 ? "s" : ""}`;
}
export function toTitleCase(value: string) {
if (!value) return value;
return value.charAt(0).toUpperCase() + value.slice(1);
if (!value) return value;
return value.charAt(0).toUpperCase() + value.slice(1);
}
export function getStateBadgeClass(state: string) {
const normalized = state.toLowerCase();
switch (normalized) {
case "running":
return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400";
case "paused":
return "bg-amber-500/10 text-amber-700 dark:text-amber-400";
case "exited":
case "dead":
return "bg-rose-500/10 text-rose-700 dark:text-rose-400";
case "restarting":
return "bg-blue-500/10 text-blue-700 dark:text-blue-400";
default:
return "bg-muted text-muted-foreground";
}
const normalized = state.toLowerCase();
switch (normalized) {
case "running":
return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400";
case "paused":
return "bg-amber-500/10 text-amber-700 dark:text-amber-400";
case "exited":
case "dead":
return "bg-rose-500/10 text-rose-700 dark:text-rose-400";
case "restarting":
return "bg-blue-500/10 text-blue-700 dark:text-blue-400";
default:
return "bg-muted text-muted-foreground";
}
}
export function groupByCompose(
containers: ContainerInfo[]
containers: ContainerInfo[],
sortDirection: SortDirection = "desc",
): GroupedContainers[] {
const groups = new Map<string, ContainerInfo[]>();
const groups = new Map<string, ContainerInfo[]>();
containers.forEach((container) => {
const key =
container.labels?.[COMPOSE_PROJECT_LABEL]?.trim() || "Standalone";
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)?.push(container)
});
containers.forEach((container) => {
const key =
container.labels?.[COMPOSE_PROJECT_LABEL]?.trim() || "Standalone";
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)?.push(container);
});
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([project, items]) => ({ project, items }));
return Array.from(groups.entries())
.sort(([, itemsA], [, itemsB]) => {
const createdA = Math.max(...itemsA.map((item) => item.created));
const createdB = Math.max(...itemsB.map((item) => item.created));
return sortDirection === "desc"
? createdB - createdA
: createdA - createdB;
})
.map(([project, items]) => ({ project, items }));
}
export function getHistoricalValue(
container: ContainerInfo,
interval: StatsInterval,
metric: "cpu" | "memory",
) {
const stats = container.historical_stats;
if (!stats) {
return null;
}
const value =
interval === "1h"
? metric === "cpu"
? stats.cpu_1h
: stats.memory_1h
: metric === "cpu"
? stats.cpu_12h
: stats.memory_12h;
return value ?? null;
}
export function getInitialStateCounts(): StateCounts {
return {
running: 0,
exited: 0,
paused: 0,
restarting: 0,
dead: 0,
other: 0,
};
return {
running: 0,
exited: 0,
paused: 0,
restarting: 0,
dead: 0,
other: 0,
};
}
/**
@ -115,10 +152,10 @@ export function getInitialStateCounts(): StateCounts {
* Falls back to container ID if no name is available
*/
export function getContainerUrlIdentifier(container: ContainerInfo): string {
if (container.names && container.names.length > 0) {
const name = container.names[0];
return name.startsWith("/") ? name.slice(1) : name;
}
// Fallback to short ID if no name
return container.id.substring(0, 12);
if (container.names && container.names.length > 0) {
const name = container.names[0];
return name.startsWith("/") ? name.slice(1) : name;
}
// Fallback to short ID if no name
return container.id.substring(0, 12);
}

View file

@ -0,0 +1,243 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ContainersDashboard } from "./containers-dashboard";
const mockUseContainersQuery = vi.fn();
const mockUseSystemStats = vi.fn();
const mockUseContainersDashboardUrlState = vi.fn();
const mockStartContainer = vi.fn();
const mockStopContainer = vi.fn();
const mockRestartContainer = vi.fn();
const mockRemoveContainer = vi.fn();
const mockContainersLogsSheet = vi.fn((_props: unknown) => (
<div data-testid="logs-sheet" />
));
const mockContainerDetailsSheet = vi.fn((_props: unknown) => (
<div data-testid="details-sheet" />
));
vi.mock("../hooks/use-containers-query", () => ({
useContainersQuery: (...args: unknown[]) => mockUseContainersQuery(...args),
}));
vi.mock("../api/container-actions", () => ({
startContainer: (...args: unknown[]) => mockStartContainer(...args),
stopContainer: (...args: unknown[]) => mockStopContainer(...args),
restartContainer: (...args: unknown[]) => mockRestartContainer(...args),
removeContainer: (...args: unknown[]) => mockRemoveContainer(...args),
}));
vi.mock("../hooks/use-system-stats", () => ({
useSystemStats: (...args: unknown[]) => mockUseSystemStats(...args),
}));
vi.mock("../hooks/use-containers-dashboard-url-state", () => ({
useContainersDashboardUrlState: (...args: unknown[]) =>
mockUseContainersDashboardUrlState(...args),
}));
vi.mock("./containers-summary-cards", () => ({
ContainersSummaryCards: () => <div>summary cards</div>,
}));
vi.mock("./containers-toolbar", () => ({
ContainersToolbar: () => <div>toolbar</div>,
}));
vi.mock("./containers-state-summary", () => ({
ContainersStateSummary: () => <div>state summary</div>,
}));
vi.mock("./containers-pagination", () => ({
ContainersPagination: () => <div>pagination</div>,
}));
vi.mock("./containers-table", () => ({
ContainersTable: ({
pageItems,
onToggleSelect,
onViewLogs,
onViewStats,
}: {
pageItems: Array<{ id: string }>;
onToggleSelect: (id: string) => void;
onViewLogs: (container: { id: string }) => void;
onViewStats: (container: { id: string }) => void;
}) => (
<div>
{pageItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onToggleSelect(item.id)}
>
Select {item.id}
</button>
))}
<button type="button" onClick={() => onViewLogs(pageItems[0])}>
Open logs
</button>
<button type="button" onClick={() => onViewStats(pageItems[0])}>
Open stats
</button>
</div>
),
}));
vi.mock("./containers-logs-sheet", () => ({
ContainersLogsSheet: (props: unknown) => mockContainersLogsSheet(props),
}));
vi.mock("./container-details-sheet", () => ({
ContainerDetailsSheet: (props: unknown) => mockContainerDetailsSheet(props),
}));
const 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: 20,
memory_12h: 40,
},
};
const secondContainer = {
...container,
id: "container-2",
names: ["/worker"],
image: "ghcr.io/example/worker:latest",
};
function renderDashboard() {
const queryClient = new QueryClient();
return render(
<QueryClientProvider client={queryClient}>
<ContainersDashboard />
</QueryClientProvider>,
);
}
describe("ContainersDashboard", () => {
beforeEach(() => {
vi.clearAllMocks();
mockStartContainer.mockResolvedValue({ message: "started" });
mockStopContainer.mockResolvedValue({ message: "stopped" });
mockRestartContainer.mockResolvedValue({ message: "restarted" });
mockRemoveContainer.mockResolvedValue({ message: "removed" });
mockUseContainersQuery.mockReturnValue({
data: {
containers: [container],
readOnly: false,
hosts: [{ Name: "local", Host: "unix:///var/run/docker.sock" }],
hostErrors: [],
},
error: null,
isError: false,
isFetching: false,
isLoading: false,
refetch: vi.fn(),
});
mockUseSystemStats.mockReturnValue({
data: {
hostInfo: {
hostname: "vps-1",
platform: "linux",
kernelVersion: "6.8.0",
},
usage: {
cpuPercent: 12,
memoryPercent: 40,
diskPercent: 55,
},
},
});
mockUseContainersDashboardUrlState.mockReturnValue({
searchTerm: "",
setSearchTerm: vi.fn(),
stateFilter: "all",
setStateFilter: vi.fn(),
hostFilter: "all",
setHostFilter: vi.fn(),
sortDirection: "desc",
setSortDirection: vi.fn(),
sortBy: "created",
setSortBy: vi.fn(),
groupBy: "none",
setGroupBy: vi.fn(),
statsInterval: "1h",
setStatsInterval: vi.fn(),
dateRange: undefined,
setDateRange: vi.fn(),
clearDateRange: vi.fn(),
pageSize: 10,
setPageSize: vi.fn(),
page: 1,
setPage: vi.fn(),
expandedGroups: [],
setExpandedGroups: vi.fn(),
});
});
it("renders the home dashboard without mounting closed overlay sheets", () => {
renderDashboard();
expect(screen.getByText("summary cards")).toBeInTheDocument();
expect(mockContainersLogsSheet).not.toHaveBeenCalled();
expect(mockContainerDetailsSheet).not.toHaveBeenCalled();
});
it("mounts the requested sheet only after the matching action is triggered", () => {
renderDashboard();
fireEvent.click(screen.getByRole("button", { name: "Open logs" }));
expect(mockContainersLogsSheet).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("logs-sheet")).toBeInTheDocument();
expect(mockContainerDetailsSheet).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Open stats" }));
expect(mockContainerDetailsSheet).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("details-sheet")).toBeInTheDocument();
});
it("confirms multi-select stop before executing actions", async () => {
mockUseContainersQuery.mockReturnValue({
data: {
containers: [container, secondContainer],
readOnly: false,
hosts: [{ Name: "local", Host: "unix:///var/run/docker.sock" }],
hostErrors: [],
},
error: null,
isError: false,
isFetching: false,
isLoading: false,
refetch: vi.fn(),
});
renderDashboard();
fireEvent.click(screen.getByRole("button", { name: "Select container-1" }));
fireEvent.click(screen.getByRole("button", { name: "Select container-2" }));
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
expect(mockStopContainer).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Stop Containers" }));
await waitFor(() => expect(mockStopContainer).toHaveBeenCalledTimes(2));
});
});

View file

@ -0,0 +1,131 @@
import { fireEvent, 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",
);
});
it("renders the image copy control as an accessible button", () => {
const writeText = vi.fn();
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
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"
/>,
);
fireEvent.click(
screen.getByRole("button", { name: `Copy ${baseContainer.image}` }),
);
expect(writeText).toHaveBeenCalledWith(baseContainer.image);
});
});

View file

@ -1,328 +1,425 @@
import { ActivityIcon, FileTextIcon, PlayIcon, RotateCwIcon, SquareIcon, Trash2Icon } from "lucide-react";
import {
ActivityIcon,
ChevronDownIcon,
ChevronRightIcon,
FileTextIcon,
PlayIcon,
RotateCwIcon,
SquareIcon,
Trash2Icon,
} from "lucide-react";
import { Fragment } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
formatContainerName,
formatCreatedDate,
formatUptime,
getStateBadgeClass,
toTitleCase,
} from "./container-utils";
import type { ContainerInfo } from "../types";
import type {
ContainerActionType,
GroupByOption,
GroupedContainers,
ContainerActionType,
GroupByOption,
GroupedContainers,
StatsInterval,
} from "./container-utils";
import {
formatContainerName,
formatCreatedDate,
formatUptime,
getHistoricalValue,
getStateBadgeClass,
toTitleCase,
} from "./container-utils";
interface ContainersTableProps {
isLoading: boolean;
isError: boolean;
error: unknown;
groupBy: GroupByOption;
filteredContainers: ContainerInfo[];
groupedItems: GroupedContainers[] | null;
pageItems: ContainerInfo[];
pendingAction: { id: string; type: ContainerActionType } | null;
isReadOnly: boolean;
onStart: (container: ContainerInfo) => void;
onStop: (container: ContainerInfo) => void;
onRestart: (container: ContainerInfo) => void;
onDelete: (container: ContainerInfo) => void;
onViewLogs: (container: ContainerInfo) => void;
onViewStats: (container: ContainerInfo) => void;
onRetry: () => void;
isLoading: boolean;
isError: boolean;
error: unknown;
groupBy: GroupByOption;
filteredContainers: ContainerInfo[];
groupedItems: GroupedContainers[] | null;
pageItems: ContainerInfo[];
pendingAction: { id: string; type: ContainerActionType } | null;
isReadOnly: boolean;
expandedGroups: string[];
selectedIds: string[];
statsInterval: StatsInterval;
onToggleSelect: (id: string) => void;
onSelectAll: () => void;
onToggleGroup: (project: string) => void;
onStart: (container: ContainerInfo) => void;
onStop: (container: ContainerInfo) => void;
onRestart: (container: ContainerInfo) => void;
onDelete: (container: ContainerInfo) => void;
onViewLogs: (container: ContainerInfo) => void;
onViewStats: (container: ContainerInfo) => void;
onRetry: () => void;
}
export function ContainersTable({
isLoading,
isError,
error,
groupBy,
filteredContainers,
groupedItems,
pageItems,
pendingAction,
isReadOnly,
onStart,
onStop,
onRestart,
onDelete,
onViewLogs,
onViewStats,
onRetry,
isLoading,
isError,
error,
groupBy,
filteredContainers,
groupedItems,
pageItems,
pendingAction,
isReadOnly,
expandedGroups,
selectedIds,
statsInterval,
onToggleSelect,
onSelectAll,
onToggleGroup,
onStart,
onStop,
onRestart,
onDelete,
onViewLogs,
onViewStats,
onRetry,
}: ContainersTableProps) {
const isContainerActionPending = (
action: ContainerActionType,
containerId: string
) =>
pendingAction?.id === containerId && pendingAction.type === action;
const isContainerActionPending = (
action: ContainerActionType,
containerId: string,
) => pendingAction?.id === containerId && pendingAction.type === action;
const isContainerBusy = (containerId: string) =>
pendingAction?.id === containerId;
const isContainerBusy = (containerId: string) =>
pendingAction?.id === containerId;
const renderContainerRow = (container: ContainerInfo) => {
const state = container.state.toLowerCase();
const busy = isContainerBusy(container.id);
const startPending = isContainerActionPending("start", container.id);
const stopPending = isContainerActionPending("stop", container.id);
const restartPending = isContainerActionPending("restart", container.id);
const removePending = isContainerActionPending("remove", container.id);
const formatHistoricalMetric = (value: number | null) =>
value === null ? "Collecting" : `${value.toFixed(1)}%`;
return (
<TableRow key={container.id} className="hover:bg-muted/50">
<TableCell className="h-16 px-4 font-medium">
{formatContainerName(container.names)}
</TableCell>
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
{container.image}
</TableCell>
<TableCell className="h-16 px-4">
<Badge
className={`${getStateBadgeClass(container.state)} border-0`}
>
{toTitleCase(container.state)}
</Badge>
</TableCell>
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
{formatUptime(container.created)}
</TableCell>
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
{formatCreatedDate(container.created)}
</TableCell>
<TableCell className="h-16 px-4 max-w-[300px] text-sm text-muted-foreground">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="block cursor-help truncate">
{container.command}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-md">
{container.command}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell className="h-16 px-4">
<TooltipProvider>
<div className="flex items-center gap-1">
{state === "exited" && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onStart(container)}
disabled={busy || isReadOnly}
>
{startPending ? (
<Spinner className="size-4" />
) : (
<PlayIcon className="size-4" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{isReadOnly ? "Start (Read-only mode)" : "Start"}
</TooltipContent>
</Tooltip>
)}
{state === "running" && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onStop(container)}
disabled={busy || isReadOnly}
>
{stopPending ? (
<Spinner className="size-4" />
) : (
<SquareIcon className="size-4" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{isReadOnly ? "Stop (Read-only mode)" : "Stop"}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onRestart(container)}
disabled={busy || isReadOnly}
>
{restartPending ? (
<Spinner className="size-4" />
) : (
<RotateCwIcon className="size-4" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{isReadOnly ? "Restart (Read-only mode)" : "Restart"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block">
<Button
variant="outline"
size="icon"
className="h-8 w-8 text-destructive hover:bg-destructive hover:text-white"
onClick={() => onDelete(container)}
disabled={busy || isReadOnly}
>
{removePending ? (
<Spinner className="size-4" />
) : (
<Trash2Icon className="size-4" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{isReadOnly ? "Delete (Read-only mode)" : "Delete"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onViewLogs(container)}
disabled={busy}
>
<FileTextIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>View Logs</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onViewStats(container)}
disabled={busy}
>
<ActivityIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>View Stats</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</TableCell>
</TableRow>
);
};
const renderContainerRow = (container: ContainerInfo) => {
const state = container.state.toLowerCase();
const busy = isContainerBusy(container.id);
const startPending = isContainerActionPending("start", container.id);
const stopPending = isContainerActionPending("stop", container.id);
const restartPending = isContainerActionPending("restart", container.id);
const removePending = isContainerActionPending("remove", container.id);
const cpuAverage = getHistoricalValue(container, statsInterval, "cpu");
const memoryAverage = getHistoricalValue(
container,
statsInterval,
"memory",
);
return (
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent border-b">
<TableHead className="h-12 px-4 font-medium">Name</TableHead>
<TableHead className="h-12 px-4 font-medium">Image</TableHead>
<TableHead className="h-12 px-4 font-medium w-[120px]">
State
</TableHead>
<TableHead className="h-12 px-4 font-medium">Uptime</TableHead>
<TableHead className="h-12 px-4 font-medium">Created</TableHead>
<TableHead className="h-12 px-4 font-medium">Command</TableHead>
<TableHead className="h-12 px-4 font-medium w-[160px]">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="h-32">
<div className="flex items-center justify-center text-sm text-muted-foreground">
<Spinner className="mr-2" />
Loading containers
</div>
</TableCell>
</TableRow>
) : isError ? (
<TableRow>
<TableCell colSpan={7} className="h-32">
<div className="flex flex-col items-center gap-3 text-center">
<p className="text-sm text-muted-foreground">
{(error as Error)?.message || "Unable to load containers."}
</p>
<Button size="sm" variant="outline" onClick={onRetry}>
Try again
</Button>
</div>
</TableCell>
</TableRow>
) : filteredContainers.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-32">
<div className="text-center text-sm text-muted-foreground">
No containers found.
</div>
</TableCell>
</TableRow>
) : groupBy === "compose" && groupedItems ? (
groupedItems.map((group) => (
<Fragment key={group.project}>
<TableRow className="bg-muted/30 hover:bg-muted/30">
<TableCell
colSpan={7}
className="h-10 px-4 text-xs font-medium text-muted-foreground"
>
{group.project} · {group.items.length} container
{group.items.length === 1 ? "" : "s"}
</TableCell>
</TableRow>
{group.items.map(renderContainerRow)}
</Fragment>
))
) : (
pageItems.map(renderContainerRow)
)}
</TableBody>
</Table>
</div>
);
return (
<TableRow key={container.id} className="hover:bg-muted/50">
<TableCell className="w-10 px-4">
<input
type="checkbox"
checked={selectedIds.includes(container.id)}
onChange={() => onToggleSelect(container.id)}
aria-label={`Select ${formatContainerName(container.names)}`}
/>
</TableCell>
<TableCell className="h-16 px-4 font-medium">
{formatContainerName(container.names)}
</TableCell>
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="block max-w-[260px] cursor-pointer truncate"
onClick={() => {
navigator.clipboard?.writeText(container.image);
}}
title="Click to copy image name"
aria-label={`Copy ${container.image}`}
>
{container.image}
</button>
</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`}>
{toTitleCase(container.state)}
</Badge>
</TableCell>
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
{formatUptime(container.created)}
</TableCell>
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
{formatCreatedDate(container.created)}
</TableCell>
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
{formatHistoricalMetric(cpuAverage)}
</TableCell>
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
{formatHistoricalMetric(memoryAverage)}
</TableCell>
<TableCell className="h-16 max-w-[300px] px-4 text-sm text-muted-foreground">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="block max-w-[280px] cursor-help truncate">
{container.command}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-md break-all">
{container.command}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell className="h-16 px-4">
<TooltipProvider>
<div className="flex items-center gap-1">
{state === "exited" && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onStart(container)}
disabled={busy || isReadOnly}
aria-label={`Start container ${formatContainerName(container.names)}`}
>
{startPending ? (
<Spinner className="size-4" />
) : (
<PlayIcon className="size-4" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{isReadOnly ? "Start (Read-only mode)" : "Start"}
</TooltipContent>
</Tooltip>
)}
{state === "running" && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onStop(container)}
disabled={busy || isReadOnly}
aria-label={`Stop container ${formatContainerName(container.names)}`}
>
{stopPending ? (
<Spinner className="size-4" />
) : (
<SquareIcon className="size-4" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{isReadOnly ? "Stop (Read-only mode)" : "Stop"}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onRestart(container)}
disabled={busy || isReadOnly}
aria-label={`Restart container ${formatContainerName(container.names)}`}
>
{restartPending ? (
<Spinner className="size-4" />
) : (
<RotateCwIcon className="size-4" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{isReadOnly ? "Restart (Read-only mode)" : "Restart"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block">
<Button
variant="outline"
size="icon"
className="h-8 w-8 text-destructive hover:bg-destructive hover:text-white"
onClick={() => onDelete(container)}
disabled={busy || isReadOnly}
aria-label={`Delete container ${formatContainerName(container.names)}`}
>
{removePending ? (
<Spinner className="size-4" />
) : (
<Trash2Icon className="size-4" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{isReadOnly ? "Delete (Read-only mode)" : "Delete"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onViewLogs(container)}
disabled={busy}
aria-label={`View logs for container ${formatContainerName(container.names)}`}
>
<FileTextIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>View Logs</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onViewStats(container)}
disabled={busy}
aria-label={`View stats for container ${formatContainerName(container.names)}`}
>
<ActivityIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>View Stats</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</TableCell>
</TableRow>
);
};
return (
<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">
<input
type="checkbox"
checked={
pageItems.length > 0 &&
selectedIds.length === pageItems.length
}
onChange={onSelectAll}
aria-label="Select all containers on this page"
/>
</TableHead>
<TableHead className="h-12 px-4 font-medium">Name</TableHead>
<TableHead className="h-12 px-4 font-medium">Image</TableHead>
<TableHead className="h-12 px-4 font-medium w-[120px]">
State
</TableHead>
<TableHead className="h-12 px-4 font-medium">Uptime</TableHead>
<TableHead className="h-12 px-4 font-medium">Created</TableHead>
<TableHead className="h-12 px-4 font-medium">
CPU {statsInterval}
</TableHead>
<TableHead className="h-12 px-4 font-medium">
RAM {statsInterval}
</TableHead>
<TableHead className="h-12 px-4 font-medium">Command</TableHead>
<TableHead className="h-12 px-4 font-medium w-[160px]">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={10} className="h-32">
<div className="flex items-center justify-center text-sm text-muted-foreground">
<Spinner className="mr-2" />
Loading containers
</div>
</TableCell>
</TableRow>
) : isError ? (
<TableRow>
<TableCell colSpan={10} className="h-32">
<div className="flex flex-col items-center gap-3 text-center">
<p className="text-sm text-muted-foreground">
{(error as Error)?.message || "Unable to load containers."}
</p>
<Button size="sm" variant="outline" onClick={onRetry}>
Try again
</Button>
</div>
</TableCell>
</TableRow>
) : filteredContainers.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="h-32">
<div className="text-center text-sm text-muted-foreground">
No containers found.
</div>
</TableCell>
</TableRow>
) : groupBy === "compose" && groupedItems ? (
groupedItems.map((group) => (
<Fragment key={group.project}>
<TableRow className="bg-muted/30 hover:bg-muted/30">
<TableCell
colSpan={10}
className="h-10 px-4 text-xs font-medium text-muted-foreground"
>
<button
type="button"
className="inline-flex max-w-full items-center gap-2 truncate"
onClick={() => onToggleGroup(group.project)}
>
{expandedGroups.includes(group.project) ? (
<ChevronDownIcon className="size-4" />
) : (
<ChevronRightIcon className="size-4" />
)}
<span className="truncate">
{group.project} · {group.items.length}{" "}
{group.items.length === 1 ? "container" : "containers"}
</span>
</button>
</TableCell>
</TableRow>
{expandedGroups.includes(group.project)
? group.items.map(renderContainerRow)
: null}
</Fragment>
))
) : (
pageItems.map(renderContainerRow)
)}
</TableBody>
</Table>
</div>
);
}

View file

@ -1,290 +1,356 @@
import { Link, useNavigate } from "@tanstack/react-router";
import {
CalendarIcon,
ChevronDownIcon,
LogOutIcon,
RefreshCcwIcon,
SettingsIcon,
XIcon
CalendarIcon,
ChevronDownIcon,
LogOutIcon,
RefreshCcwIcon,
SettingsIcon,
XIcon,
} from "lucide-react";
import type { DateRange } from "react-day-picker";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAuth } from "@/contexts/auth-context";
import type { DockerHost } from "../types";
import type {
GroupByOption,
SortColumn,
SortDirection,
StatsInterval,
} from "./container-utils";
import { toTitleCase } from "./container-utils";
import type { DateRange } from "react-day-picker";
import type { GroupByOption, SortDirection } from "./container-utils";
import type { DockerHost } from "../types";
interface ContainersToolbarProps {
searchTerm: string;
onSearchChange: (value: string) => void;
stateFilter: string;
onStateFilterChange: (value: string) => void;
availableStates: string[];
hostFilter: string;
onHostFilterChange: (value: string) => void;
availableHosts: DockerHost[];
sortDirection: SortDirection;
onSortDirectionChange: (direction: SortDirection) => void;
groupBy: GroupByOption;
onGroupByChange: (value: GroupByOption) => void;
dateRange: DateRange | undefined;
onDateRangeChange: (range: DateRange | undefined) => void;
onDateRangeClear: () => void;
onRefresh: () => void;
isFetching: boolean;
searchTerm: string;
onSearchChange: (value: string) => void;
stateFilter: string;
onStateFilterChange: (value: string) => void;
availableStates: string[];
hostFilter: string;
onHostFilterChange: (value: string) => void;
availableHosts: DockerHost[];
sortDirection: SortDirection;
onSortDirectionChange: (direction: SortDirection) => void;
sortBy: SortColumn;
onSortByChange: (value: SortColumn) => void;
groupBy: GroupByOption;
onGroupByChange: (value: GroupByOption) => void;
statsInterval: StatsInterval;
onStatsIntervalChange: (value: StatsInterval) => void;
dateRange: DateRange | undefined;
onDateRangeChange: (range: DateRange | undefined) => void;
onDateRangeClear: () => void;
onRefresh: () => void;
isFetching: boolean;
}
export function ContainersToolbar({
searchTerm,
onSearchChange,
stateFilter,
onStateFilterChange,
availableStates,
hostFilter,
onHostFilterChange,
availableHosts,
sortDirection,
onSortDirectionChange,
groupBy,
onGroupByChange,
dateRange,
onDateRangeChange,
onDateRangeClear,
onRefresh,
isFetching,
searchTerm,
onSearchChange,
stateFilter,
onStateFilterChange,
availableStates,
hostFilter,
onHostFilterChange,
availableHosts,
sortDirection,
onSortDirectionChange,
sortBy,
onSortByChange,
groupBy,
onGroupByChange,
statsInterval,
onStatsIntervalChange,
dateRange,
onDateRangeChange,
onDateRangeClear,
onRefresh,
isFetching,
}: ContainersToolbarProps) {
const { logout, user, isAuthEnabled } = useAuth();
const navigate = useNavigate();
const { logout, user, isAuthEnabled } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate({ to: "/login" });
};
const handleLogout = () => {
logout();
navigate({ to: "/login" });
};
const renderDateRange = () => {
if (!dateRange?.from) {
return <span>Date range</span>;
}
const sortLabels: Record<SortColumn, string> = {
name: "Name",
state: "State",
uptime: "Uptime",
created: "Created",
cpu: "CPU",
ram: "RAM",
};
if (dateRange.to) {
const from = dateRange.from.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
const to = dateRange.to.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
return (
<>
{from} - {to}
</>
);
}
const renderDateRange = () => {
if (!dateRange?.from) {
return <span>Date range</span>;
}
return dateRange.from.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
};
if (dateRange.to) {
const from = dateRange.from.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
const to = dateRange.to.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
return (
<>
{from} - {to}
</>
);
}
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Input
type="search"
value={searchTerm}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Search containers..."
className="sm:max-w-sm"
/>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
{hostFilter === "all" ? "All hosts" : hostFilter}
<ChevronDownIcon className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={hostFilter}
onValueChange={onHostFilterChange}
>
<DropdownMenuRadioItem value="all">
All hosts
</DropdownMenuRadioItem>
{availableHosts.map((host) => (
<DropdownMenuRadioItem key={host.Name} value={host.Name}>
{host.Name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
return dateRange.from.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
};
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
{stateFilter === "all" ? "All states" : toTitleCase(stateFilter)}
<ChevronDownIcon className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={stateFilter}
onValueChange={onStateFilterChange}
>
<DropdownMenuRadioItem value="all">
All states
</DropdownMenuRadioItem>
{availableStates.map((state) => (
<DropdownMenuRadioItem key={state} value={state}>
{toTitleCase(state)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Input
type="search"
value={searchTerm}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Search containers..."
className="sm:max-w-sm"
/>
<div className="flex flex-wrap items-center gap-2 md:flex-nowrap">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
{hostFilter === "all" ? "All hosts" : hostFilter}
<ChevronDownIcon className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={hostFilter}
onValueChange={onHostFilterChange}
>
<DropdownMenuRadioItem value="all">
All hosts
</DropdownMenuRadioItem>
{availableHosts.map((host) => (
<DropdownMenuRadioItem key={host.Name} value={host.Name}>
{host.Name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
{sortDirection === "desc" ? "Newest" : "Oldest"}
<ChevronDownIcon className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={sortDirection}
onValueChange={(value) =>
onSortDirectionChange(value as SortDirection)
}
>
<DropdownMenuRadioItem value="desc">
Newest first
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="asc">
Oldest first
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
{stateFilter === "all" ? "All states" : toTitleCase(stateFilter)}
<ChevronDownIcon className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={stateFilter}
onValueChange={onStateFilterChange}
>
<DropdownMenuRadioItem value="all">
All states
</DropdownMenuRadioItem>
{availableStates.map((state) => (
<DropdownMenuRadioItem key={state} value={state}>
{toTitleCase(state)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
{groupBy === "compose" ? "By project" : "No grouping"}
<ChevronDownIcon className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={groupBy}
onValueChange={(value) => onGroupByChange(value as GroupByOption)}
>
<DropdownMenuRadioItem value="none">
No grouping
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="compose">
By compose project
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
Sort: {sortLabels[sortBy]}
<ChevronDownIcon className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={sortBy}
onValueChange={(value) => onSortByChange(value as SortColumn)}
>
<DropdownMenuRadioItem value="name">Name</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="state">State</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="uptime">
Uptime
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="created">
Created
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="cpu">CPU</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="ram">RAM</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Popover>
<PopoverTrigger asChild>
<Button
variant={dateRange?.from ? "default" : "outline"}
size="sm"
className="h-9 justify-start text-left font-normal"
>
<CalendarIcon className="mr-2 size-4" />
{renderDateRange()}
{dateRange?.from && (
<XIcon
className="ml-2 size-4 hover:text-destructive"
onClick={(event) => {
event.stopPropagation();
onDateRangeClear();
}}
/>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={onDateRangeChange}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
{sortDirection === "desc" ? "Desc" : "Asc"}
<ChevronDownIcon className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={sortDirection}
onValueChange={(value) =>
onSortDirectionChange(value as SortDirection)
}
>
<DropdownMenuRadioItem value="desc">
Descending
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="asc">
Ascending
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
className="h-9 shrink-0"
>
<RefreshCcwIcon
className={`size-4 ${isFetching ? "animate-spin" : ""}`}
/>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
{groupBy === "compose" ? "By project" : "No grouping"}
<ChevronDownIcon className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={groupBy}
onValueChange={(value) => onGroupByChange(value as GroupByOption)}
>
<DropdownMenuRadioItem value="none">
No grouping
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="compose">
By compose project
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" className="h-9 shrink-0" asChild>
<Link to="/settings" aria-label="Settings">
<SettingsIcon className="size-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
<div className="flex items-center rounded-md border bg-background p-1">
<Button
variant={statsInterval === "1h" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2 text-xs"
onClick={() => onStatsIntervalChange("1h")}
>
1h
</Button>
<Button
variant={statsInterval === "12h" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2 text-xs"
onClick={() => onStatsIntervalChange("12h")}
>
12h
</Button>
</div>
{isAuthEnabled && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
className="h-9 shrink-0"
>
<LogOutIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Logout {user?.username ? `(${user.username})` : ""}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
);
<Popover>
<PopoverTrigger asChild>
<Button
variant={dateRange?.from ? "default" : "outline"}
size="sm"
className="h-9 justify-start text-left font-normal"
>
<CalendarIcon className="mr-2 size-4" />
{renderDateRange()}
{dateRange?.from && (
<XIcon
className="ml-2 size-4 hover:text-destructive"
onClick={(event) => {
event.stopPropagation();
onDateRangeClear();
}}
/>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={onDateRangeChange}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
className="h-9 shrink-0"
aria-label="Refresh"
>
<RefreshCcwIcon
className={`size-4 ${isFetching ? "animate-spin" : ""}`}
/>
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" className="h-9 shrink-0" asChild>
<Link to="/settings" aria-label="Settings">
<SettingsIcon className="size-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
{isAuthEnabled && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
className="h-9 shrink-0"
aria-label="Logout"
>
<LogOutIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Logout {user?.username ? `(${user.username})` : ""}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { getContainerHistory } from "../api/get-container-history";
export function useContainerHistory(id: string, host: string, enabled = true) {
return useQuery({
queryKey: ["container-history", host, id],
queryFn: () => getContainerHistory(id, host),
enabled: enabled && Boolean(id) && Boolean(host),
staleTime: 60_000,
refetchInterval: 60_000,
});
}

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { hasDashboardParamChanges } from "./use-containers-dashboard-url-state";
const baseParams = {
search: "",
state: "all",
host: "all",
sort: "desc" as const,
sortBy: "created" as const,
group: "none" as const,
interval: "1h" as const,
page: 1,
pageSize: 10,
from: null,
to: null,
expanded: ["project-a"],
};
describe("hasDashboardParamChanges", () => {
it("returns false when updates do not change the current params", () => {
expect(
hasDashboardParamChanges(baseParams, {
search: "",
page: 1,
expanded: ["project-a"],
}),
).toBe(false);
});
it("compares date values by timestamp rather than object identity", () => {
const current = {
...baseParams,
from: new Date("2026-04-22T00:00:00.000Z"),
to: new Date("2026-04-23T00:00:00.000Z"),
};
expect(
hasDashboardParamChanges(current, {
from: new Date("2026-04-22T00:00:00.000Z"),
to: new Date("2026-04-23T00:00:00.000Z"),
}),
).toBe(false);
});
it("returns true when any param value actually changes", () => {
expect(
hasDashboardParamChanges(baseParams, {
host: "prod-1",
}),
).toBe(true);
});
});

View file

@ -1,186 +1,323 @@
import {
createParser,
parseAsInteger,
parseAsIsoDateTime,
parseAsString,
useQueryStates
createParser,
parseAsArrayOf,
parseAsInteger,
parseAsIsoDateTime,
parseAsString,
useQueryStates,
} from "nuqs";
import { useCallback, useMemo } from "react";
import type { DateRange } from "react-day-picker";
import type {
GroupByOption,
SortDirection,
GroupByOption,
SortColumn,
SortDirection,
StatsInterval,
} from "../components/container-utils";
// Custom parser for SortDirection
const parseAsSortDirection = createParser({
parse: (value): SortDirection | null => {
if (value === "asc" || value === "desc") {
return value;
}
return null;
},
serialize: (value: SortDirection) => value,
parse: (value): SortDirection | null => {
if (value === "asc" || value === "desc") {
return value;
}
return null;
},
serialize: (value: SortDirection) => value,
});
// Custom parser for GroupByOption
const parseAsGroupBy = createParser({
parse: (value): GroupByOption | null => {
if (value === "none" || value === "compose") {
return value;
}
return null;
},
serialize: (value: GroupByOption) => value,
parse: (value): GroupByOption | null => {
if (value === "none" || value === "compose") {
return value;
}
return null;
},
serialize: (value: GroupByOption) => value,
});
const parseAsStatsInterval = createParser({
parse: (value): StatsInterval | null => {
if (value === "1h" || value === "12h") {
return value;
}
return null;
},
serialize: (value: StatsInterval) => value,
});
const parseAsSortColumn = createParser({
parse: (value): SortColumn | null => {
if (["name", "state", "uptime", "created", "cpu", "ram"].includes(value)) {
return value as SortColumn;
}
return null;
},
serialize: (value: SortColumn) => value,
});
// Search params configuration with defaults
const searchParamsConfig = {
search: parseAsString.withDefault(""),
state: parseAsString.withDefault("all"),
host: parseAsString.withDefault("all"),
sort: parseAsSortDirection.withDefault("desc" as SortDirection),
group: parseAsGroupBy.withDefault("none" as GroupByOption),
page: parseAsInteger.withDefault(1),
pageSize: parseAsInteger.withDefault(10),
from: parseAsIsoDateTime,
to: parseAsIsoDateTime,
search: parseAsString.withDefault(""),
state: parseAsString.withDefault("all"),
host: parseAsString.withDefault("all"),
sort: parseAsSortDirection.withDefault("desc" as SortDirection),
sortBy: parseAsSortColumn.withDefault("created" as SortColumn),
group: parseAsGroupBy.withDefault("none" as GroupByOption),
interval: parseAsStatsInterval.withDefault("1h" as StatsInterval),
page: parseAsInteger.withDefault(1),
pageSize: parseAsInteger.withDefault(10),
from: parseAsIsoDateTime,
to: parseAsIsoDateTime,
expanded: parseAsArrayOf(parseAsString).withDefault([]),
};
export function useContainersDashboardUrlState() {
const [params, setParams] = useQueryStates(searchParamsConfig, {
history: "replace",
});
type DashboardUrlParams = {
search: string;
state: string;
host: string;
sort: SortDirection;
sortBy: SortColumn;
group: GroupByOption;
interval: StatsInterval;
page: number;
pageSize: number;
from: Date | null;
to: Date | null;
expanded: string[];
};
const {
search: searchTerm,
state: stateFilter,
host: hostFilter,
sort: sortDirection,
group: groupBy,
page,
pageSize,
from,
to,
} = params;
function areStringArraysEqual(left: string[], right: string[]) {
if (left.length !== right.length) {
return false;
}
// Convert from/to into DateRange format
// Supports open-ended ranges: from without to, to without from, or both
const dateRange = useMemo((): DateRange | undefined => {
if (!from && !to) {
return undefined;
}
return { from: from ?? undefined, to: to ?? undefined };
}, [from, to]);
const setSearchTerm = useCallback(
(value: string) => {
setParams({
search: value,
page: 1,
});
},
[setParams]
);
const setStateFilter = useCallback(
(value: string) => {
const normalized = value || "all";
setParams({
state: normalized,
page: 1,
});
},
[setParams]
);
const setHostFilter = useCallback(
(value: string) => {
const normalized = value || "all";
setParams({
host: normalized,
page: 1,
});
},
[setParams]
);
const setSortDirection = useCallback(
(value: SortDirection) => {
setParams({
sort: value,
});
},
[setParams]
);
const setGroupBy = useCallback(
(value: GroupByOption) => {
setParams({
group: value,
page: 1,
});
},
[setParams]
);
const setDateRange = useCallback(
(range: DateRange | undefined) => {
setParams({
from: range?.from ?? null,
to: range?.to ?? null,
page: 1,
});
},
[setParams]
);
const clearDateRange = useCallback(() => {
setParams({
from: null,
to: null,
page: 1,
});
}, [setParams]);
const setPage = useCallback(
(value: number) => {
setParams({
page: Math.max(1, Math.floor(value)),
});
},
[setParams]
);
const setPageSize = useCallback(
(value: number) => {
setParams({
pageSize: Math.max(1, Math.floor(value)),
page: 1,
});
},
[setParams]
);
return {
searchTerm,
setSearchTerm,
stateFilter,
setStateFilter,
hostFilter,
setHostFilter,
sortDirection,
setSortDirection,
groupBy,
setGroupBy,
dateRange,
setDateRange,
clearDateRange,
page,
setPage,
pageSize,
setPageSize,
};
return left.every((value, index) => value === right[index]);
}
function areDatesEqual(left: Date | null, right: Date | null) {
if (left === right) {
return true;
}
if (!left || !right) {
return false;
}
return left.getTime() === right.getTime();
}
function isDashboardParamEqual(
currentValue: DashboardUrlParams[keyof DashboardUrlParams],
nextValue: DashboardUrlParams[keyof DashboardUrlParams],
) {
if (Array.isArray(currentValue) && Array.isArray(nextValue)) {
return areStringArraysEqual(currentValue, nextValue);
}
if (
(currentValue instanceof Date || currentValue === null) &&
(nextValue instanceof Date || nextValue === null)
) {
return areDatesEqual(currentValue, nextValue);
}
return currentValue === nextValue;
}
export function hasDashboardParamChanges(
current: DashboardUrlParams,
updates: Partial<DashboardUrlParams>,
) {
return Object.entries(updates).some(([key, value]) => {
const typedKey = key as keyof DashboardUrlParams;
return !isDashboardParamEqual(
current[typedKey],
value as DashboardUrlParams[keyof DashboardUrlParams],
);
});
}
export function useContainersDashboardUrlState() {
const [params, setParams] = useQueryStates(searchParamsConfig, {
history: "replace",
});
const {
search: searchTerm,
state: stateFilter,
host: hostFilter,
sort: sortDirection,
sortBy,
group: groupBy,
interval: statsInterval,
page,
pageSize,
from,
to,
expanded: expandedGroups,
} = params;
const updateParams = useCallback(
(updates: Partial<DashboardUrlParams>) => {
if (hasDashboardParamChanges(params as DashboardUrlParams, updates)) {
setParams(updates);
}
},
[params, setParams],
);
// Convert from/to into DateRange format
// Supports open-ended ranges: from without to, to without from, or both
const dateRange = useMemo((): DateRange | undefined => {
if (!from && !to) {
return undefined;
}
return { from: from ?? undefined, to: to ?? undefined };
}, [from, to]);
const setSearchTerm = useCallback(
(value: string) => {
updateParams({
search: value,
page: 1,
});
},
[updateParams],
);
const setStateFilter = useCallback(
(value: string) => {
const normalized = value || "all";
updateParams({
state: normalized,
page: 1,
});
},
[updateParams],
);
const setHostFilter = useCallback(
(value: string) => {
const normalized = value || "all";
updateParams({
host: normalized,
page: 1,
});
},
[updateParams],
);
const setSortDirection = useCallback(
(value: SortDirection) => {
updateParams({
sort: value,
});
},
[updateParams],
);
const setSortBy = useCallback(
(value: SortColumn) => {
updateParams({
sortBy: value,
});
},
[updateParams],
);
const setGroupBy = useCallback(
(value: GroupByOption) => {
updateParams({
group: value,
page: 1,
});
},
[updateParams],
);
const setStatsInterval = useCallback(
(value: StatsInterval) => {
updateParams({
interval: value,
});
},
[updateParams],
);
const setDateRange = useCallback(
(range: DateRange | undefined) => {
updateParams({
from: range?.from ?? null,
to: range?.to ?? null,
page: 1,
});
},
[updateParams],
);
const clearDateRange = useCallback(() => {
updateParams({
from: null,
to: null,
page: 1,
});
}, [updateParams]);
const setPage = useCallback(
(value: number) => {
updateParams({
page: Math.max(1, Math.floor(value)),
});
},
[updateParams],
);
const setPageSize = useCallback(
(value: number) => {
updateParams({
pageSize: Math.max(1, Math.floor(value)),
page: 1,
});
},
[updateParams],
);
const setExpandedGroups = useCallback(
(value: string[]) => {
updateParams({
expanded: value,
});
},
[updateParams],
);
return {
searchTerm,
setSearchTerm,
stateFilter,
setStateFilter,
hostFilter,
setHostFilter,
sortDirection,
setSortDirection,
sortBy,
setSortBy,
groupBy,
setGroupBy,
statsInterval,
setStatsInterval,
dateRange,
setDateRange,
clearDateRange,
page,
setPage,
pageSize,
setPageSize,
expandedGroups,
setExpandedGroups,
};
}

View file

@ -14,12 +14,19 @@ export interface ContainerInfo {
status: string
labels?: Record<string, string>
host: string
historical_stats?: {
cpu_1h: number
memory_1h: number
cpu_12h: number
memory_12h: number
}
}
export interface ContainersQueryParams {
search?: string
state?: string
sortCreated?: "asc" | "desc"
sortBy?: "name" | "state" | "uptime" | "created" | "cpu" | "ram"
groupBy?: "none" | "compose"
host?: string
}

View file

@ -0,0 +1,37 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
const ENDPOINT = `${API_BASE_URL}/api/v1/settings/test/bot`;
export async function testBot(telegramToken: string, allowedChatId: string) {
const response = await authenticatedFetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ telegramToken, allowedChatId }),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Failed to test bot");
}
return response.json() as Promise<{ success: boolean; message: string }>;
}
export async function testDiscordBot(botToken: string, allowedChannelId: string) {
const response = await authenticatedFetch(
`${API_BASE_URL}/api/v1/settings/test/discord-bot`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ botToken, allowedChannelId }),
},
);
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Failed to test Discord bot");
}
return response.json() as Promise<{ success: boolean; message: string }>;
}

View file

@ -0,0 +1,34 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
const ENDPOINT = `${API_BASE_URL}/api/v1/settings/bot`;
export interface UpdateBotPayload {
enabled: boolean;
mode: "polling" | "jwt-relay";
telegramToken: string;
allowedChatId: string;
discord: {
enabled: boolean;
botToken: string;
applicationId: string;
guildId: string;
allowedChannelId: string;
};
}
export async function updateBot(payload: UpdateBotPayload): Promise<string> {
const response = await authenticatedFetch(ENDPOINT, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Failed to update bot settings");
}
const data = (await response.json()) as { message?: string };
return data.message ?? "Bot settings updated";
}

View file

@ -0,0 +1,43 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { BotSection } from "./bot-section";
import type { BotConfig } from "../types";
vi.mock("../hooks/use-settings", () => ({
useUpdateBot: () => ({ isPending: false, mutate: vi.fn() }),
useTestBot: () => ({ isPending: false, mutate: vi.fn() }),
useTestDiscordBot: () => ({ isPending: false, mutate: vi.fn() }),
}));
const baseConfig: BotConfig = {
source: "file",
enabled: true,
mode: "polling",
telegramTokenConfigured: true,
allowedChatId: "123",
relayPath: "/api/v1/bot/relay/command",
relayUsesAuth: true,
discord: {
enabled: false,
botToken: "",
applicationId: "",
guildId: "",
allowedChannelId: "",
},
};
describe("BotSection", () => {
it("uses a masked token placeholder when the token is configured", () => {
render(<BotSection config={baseConfig} />);
expect((screen.getByLabelText("Telegram token") as HTMLInputElement).value).toBe("••••••••");
});
it("disables controls for mixed env-backed bot config", () => {
render(<BotSection config={{ ...baseConfig, source: "mixed" }} />);
expect((screen.getByLabelText("Telegram token") as HTMLInputElement).disabled).toBe(true);
expect(screen.queryByRole("button", { name: /save changes/i })).toBeNull();
});
});

View file

@ -0,0 +1,374 @@
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import {
useTestBot,
useTestDiscordBot,
useUpdateBot,
} from "../hooks/use-settings";
import type { BotConfig } from "../types";
import { EnvBadge } from "./env-badge";
const MASKED_TOKEN = "••••••••";
interface BotSectionProps {
config: BotConfig;
disabled?: boolean;
authEnabled?: boolean;
}
export function BotSection({
config,
disabled = false,
authEnabled = false,
}: BotSectionProps) {
const isEnvBacked = config.source === "env" || config.source === "mixed";
const displayTelegramToken = config.telegramTokenConfigured ? MASKED_TOKEN : "";
const [enabled, setEnabled] = useState(config.enabled);
const [mode, setMode] = useState(config.mode);
const [telegramToken, setTelegramToken] = useState(displayTelegramToken);
const [allowedChatId, setAllowedChatId] = useState(config.allowedChatId);
const [discordEnabled, setDiscordEnabled] = useState(config.discord.enabled);
const [discordBotToken, setDiscordBotToken] = useState(config.discord.botToken);
const [discordApplicationId, setDiscordApplicationId] = useState(
config.discord.applicationId,
);
const [discordGuildId, setDiscordGuildId] = useState(config.discord.guildId);
const [discordAllowedChannelId, setDiscordAllowedChannelId] = useState(
config.discord.allowedChannelId,
);
const updateMutation = useUpdateBot();
const testMutation = useTestBot();
const discordTestMutation = useTestDiscordBot();
useEffect(() => {
setEnabled(config.enabled);
setMode(config.mode);
setTelegramToken(config.telegramTokenConfigured ? MASKED_TOKEN : "");
setAllowedChatId(config.allowedChatId);
setDiscordEnabled(config.discord.enabled);
setDiscordBotToken(config.discord.botToken);
setDiscordApplicationId(config.discord.applicationId);
setDiscordGuildId(config.discord.guildId);
setDiscordAllowedChannelId(config.discord.allowedChannelId);
}, [config]);
const hasChanges =
enabled !== config.enabled ||
mode !== config.mode ||
telegramToken !== displayTelegramToken ||
allowedChatId !== config.allowedChatId ||
discordEnabled !== config.discord.enabled ||
discordBotToken !== config.discord.botToken ||
discordApplicationId !== config.discord.applicationId ||
discordGuildId !== config.discord.guildId ||
discordAllowedChannelId !== config.discord.allowedChannelId;
const controlsDisabled =
disabled ||
isEnvBacked ||
updateMutation.isPending ||
testMutation.isPending ||
discordTestMutation.isPending;
const handleSave = () => {
updateMutation.mutate(
{
enabled,
mode,
telegramToken,
allowedChatId,
discord: {
enabled: discordEnabled,
botToken: discordBotToken,
applicationId: discordApplicationId,
guildId: discordGuildId,
allowedChannelId: discordAllowedChannelId,
},
},
{
onSuccess: (message) => toast.success(message),
onError: (error) => toast.error(error.message),
},
);
};
const handleTest = () => {
testMutation.mutate(
{ telegramToken, allowedChatId },
{
onSuccess: (result) => {
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
},
onError: (error) => toast.error(error.message),
},
);
};
const handleDiscordTest = () => {
discordTestMutation.mutate(
{
botToken: discordBotToken,
allowedChannelId: discordAllowedChannelId,
},
{
onSuccess: (result) => {
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
},
onError: (error) => toast.error(error.message),
},
);
};
return (
<>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<CardTitle>Telegram Bot</CardTitle>
{isEnvBacked && <EnvBadge />}
</div>
<CardDescription>
Configure the Telegram bot for `/help`, `/status`, and `/critical`
commands.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
<Switch
id="bot-enabled"
checked={enabled}
onCheckedChange={setEnabled}
disabled={controlsDisabled}
/>
<Label htmlFor="bot-enabled" className="cursor-pointer">
{enabled ? "Enabled" : "Disabled"}
</Label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="bot-mode">Mode</Label>
<Select
value={mode}
onValueChange={(value) => setMode(value as BotConfig["mode"])}
disabled={controlsDisabled}
>
<SelectTrigger id="bot-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="polling">Polling</SelectItem>
<SelectItem value="jwt-relay" disabled={!authEnabled}>
JWT Relay
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="telegram-token">Telegram token</Label>
<Input
id="telegram-token"
value={telegramToken}
onChange={(event) => setTelegramToken(event.target.value)}
disabled={controlsDisabled}
type="password"
placeholder="123456:ABC..."
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="allowed-chat-id">Allowed chat ID</Label>
<Input
id="allowed-chat-id"
value={allowedChatId}
onChange={(event) => setAllowedChatId(event.target.value)}
disabled={controlsDisabled}
placeholder="123456789"
/>
</div>
</div>
{mode === "jwt-relay" && (
<div className="rounded-md border bg-muted/30 p-3 text-sm">
<p className="font-medium">JWT relay</p>
<p className="mt-1 text-muted-foreground">
Relay path:{" "}
<span className="font-mono">{config.relayPath}</span>
</p>
<p className="mt-1 text-muted-foreground">
Protected by existing auth:{" "}
{config.relayUsesAuth ? "yes" : "no"}
</p>
{!authEnabled && (
<p className="mt-2 text-destructive">
Enable dashboard auth before using JWT relay mode.
</p>
)}
</div>
)}
{!isEnvBacked && (
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || controlsDisabled}
>
{updateMutation.isPending ? (
<>
<Spinner className="size-3" />
Saving...
</>
) : (
"Save changes"
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleTest}
disabled={controlsDisabled}
>
{testMutation.isPending ? "Testing..." : "Send test"}
</Button>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<CardTitle>Discord Bot</CardTitle>
{isEnvBacked && <EnvBadge />}
</div>
<CardDescription>
Configure Discord slash commands for `/help`, `/status`, and
`/critical`.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
<Switch
id="discord-bot-enabled"
checked={discordEnabled}
onCheckedChange={setDiscordEnabled}
disabled={controlsDisabled}
/>
<Label htmlFor="discord-bot-enabled" className="cursor-pointer">
{discordEnabled ? "Enabled" : "Disabled"}
</Label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="discord-bot-token">Bot token</Label>
<Input
id="discord-bot-token"
value={discordBotToken}
onChange={(event) => setDiscordBotToken(event.target.value)}
disabled={controlsDisabled}
type="password"
placeholder="MTA..."
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="discord-application-id">Application ID</Label>
<Input
id="discord-application-id"
value={discordApplicationId}
onChange={(event) => setDiscordApplicationId(event.target.value)}
disabled={controlsDisabled}
placeholder="123456789012345678"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="discord-guild-id">Guild ID</Label>
<Input
id="discord-guild-id"
value={discordGuildId}
onChange={(event) => setDiscordGuildId(event.target.value)}
disabled={controlsDisabled}
placeholder="Optional"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="discord-channel-id">Allowed channel ID</Label>
<Input
id="discord-channel-id"
value={discordAllowedChannelId}
onChange={(event) =>
setDiscordAllowedChannelId(event.target.value)
}
disabled={controlsDisabled}
placeholder="123456789012345678"
/>
</div>
</div>
<div className="rounded-md border bg-muted/30 p-3 text-sm text-muted-foreground">
Slash command responses are ephemeral. Set a guild ID to register
commands to one server immediately; leave it blank for global
command registration.
</div>
{!isEnvBacked && (
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || controlsDisabled}
>
{updateMutation.isPending ? (
<>
<Spinner className="size-3" />
Saving...
</>
) : (
"Save changes"
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleDiscordTest}
disabled={controlsDisabled}
>
{discordTestMutation.isPending ? "Testing..." : "Send test"}
</Button>
</div>
)}
</CardContent>
</Card>
</>
);
}

View file

@ -2,49 +2,55 @@ import { Spinner } from "@/components/ui/spinner";
import { useSettings } from "../hooks/use-settings";
import { AuthSection } from "./auth-section";
import { BotSection } from "./bot-section";
import { CoolifyHostsSection } from "./coolify-hosts-section";
import { DockerHostsSection } from "./docker-hosts-section";
import { ReadOnlySection } from "./read-only-section";
import { ScannerSection } from "./scanner-section";
export function SettingsPage() {
const { data, isLoading, error } = useSettings();
const { data, isLoading, error } = useSettings();
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Spinner className="size-6" />
</div>
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Spinner className="size-6" />
</div>
);
}
if (error) {
return (
<div className="container mx-auto max-w-3xl px-4 py-8">
<p className="text-sm text-destructive">
Failed to load settings: {error.message}
</p>
</div>
);
}
if (error) {
return (
<div className="container mx-auto max-w-3xl px-4 py-8">
<p className="text-sm text-destructive">
Failed to load settings: {error.message}
</p>
</div>
);
}
if (!data) return null;
if (!data) return null;
return (
<div className="container mx-auto max-w-3xl px-4 py-8 space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage VPS Monitor configuration. Sections marked as set via environment
variable can only be changed by updating the environment and
restarting.
</p>
</div>
<DockerHostsSection config={data.dockerHosts} />
<CoolifyHostsSection config={data.coolifyHosts} />
<ReadOnlySection config={data.readOnly} />
<AuthSection config={data.auth} />
<ScannerSection disabled={data.readOnly.value} />
</div>
);
return (
<div className="container mx-auto max-w-3xl px-4 py-8 space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage VPS Monitor configuration. Sections marked as set via
environment variable can only be changed by updating the environment
and restarting.
</p>
</div>
<DockerHostsSection config={data.dockerHosts} />
<CoolifyHostsSection config={data.coolifyHosts} />
<ReadOnlySection config={data.readOnly} />
<AuthSection config={data.auth} />
<BotSection
config={data.bot}
disabled={data.readOnly.value}
authEnabled={data.auth.enabled}
/>
<ScannerSection disabled={data.readOnly.value} />
</div>
);
}

View file

@ -1,80 +1,119 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getSettings } from "../api/get-settings";
import { testBot, testDiscordBot } from "../api/test-bot";
import { testCoolifyHost } from "../api/test-coolify-host";
import { testDockerHost } from "../api/test-docker-host";
import { updateAuth, type UpdateAuthPayload } from "../api/update-auth";
import { type UpdateAuthPayload, updateAuth } from "../api/update-auth";
import { type UpdateBotPayload, updateBot } from "../api/update-bot";
import { updateCoolifyHosts } from "../api/update-coolify-hosts";
import { updateDockerHosts } from "../api/update-docker-hosts";
import { updateReadOnly } from "../api/update-read-only";
const SETTINGS_KEY = ["settings"] as const;
export function useSettings() {
return useQuery({
queryKey: SETTINGS_KEY,
queryFn: getSettings,
staleTime: 30_000,
});
return useQuery({
queryKey: SETTINGS_KEY,
queryFn: getSettings,
staleTime: 30_000,
});
}
export function useUpdateDockerHosts() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (hosts: { name: string; host: string }[]) => updateDockerHosts(hosts),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SETTINGS_KEY });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (hosts: { name: string; host: string }[]) =>
updateDockerHosts(hosts),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SETTINGS_KEY });
},
});
}
export function useUpdateCoolifyHosts() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (hosts: { hostName: string; apiURL: string; apiToken: string }[]) =>
updateCoolifyHosts(hosts),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SETTINGS_KEY });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (
hosts: { hostName: string; apiURL: string; apiToken: string }[],
) => updateCoolifyHosts(hosts),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SETTINGS_KEY });
},
});
}
export function useUpdateReadOnly() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (value: boolean) => updateReadOnly(value),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SETTINGS_KEY });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (value: boolean) => updateReadOnly(value),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SETTINGS_KEY });
},
});
}
export function useUpdateAuth() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: UpdateAuthPayload) => updateAuth(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SETTINGS_KEY });
},
});
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: UpdateAuthPayload) => updateAuth(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SETTINGS_KEY });
},
});
}
export function useUpdateBot() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: UpdateBotPayload) => updateBot(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SETTINGS_KEY });
},
});
}
export function useTestDockerHost() {
return useMutation({
mutationFn: ({ name, host }: { name: string; host: string }) =>
testDockerHost(name, host),
});
return useMutation({
mutationFn: ({ name, host }: { name: string; host: string }) =>
testDockerHost(name, host),
});
}
export function useTestCoolifyHost() {
return useMutation({
mutationFn: ({
hostName,
apiURL,
apiToken,
}: {
hostName: string;
apiURL: string;
apiToken: string;
}) => testCoolifyHost(hostName, apiURL, apiToken),
});
return useMutation({
mutationFn: ({
hostName,
apiURL,
apiToken,
}: {
hostName: string;
apiURL: string;
apiToken: string;
}) => testCoolifyHost(hostName, apiURL, apiToken),
});
}
export function useTestBot() {
return useMutation({
mutationFn: ({
telegramToken,
allowedChatId,
}: {
telegramToken: string;
allowedChatId: string;
}) => testBot(telegramToken, allowedChatId),
});
}
export function useTestDiscordBot() {
return useMutation({
mutationFn: ({
botToken,
allowedChannelId,
}: {
botToken: string;
allowedChannelId: string;
}) => testDiscordBot(botToken, allowedChannelId),
});
}

View file

@ -1,49 +1,69 @@
export type ConfigSource = "file" | "env" | "default" | "mixed";
export interface DockerHost {
name: string;
host: string;
source: ConfigSource;
name: string;
host: string;
source: ConfigSource;
}
export interface CoolifyHost {
hostName: string;
apiURL: string;
apiToken: string;
source: ConfigSource;
hostName: string;
apiURL: string;
apiToken: string;
source: ConfigSource;
}
export interface DockerHostsConfig {
source: ConfigSource;
hosts: DockerHost[];
source: ConfigSource;
hosts: DockerHost[];
}
export interface CoolifyHostsConfig {
source: ConfigSource;
hosts: CoolifyHost[];
source: ConfigSource;
hosts: CoolifyHost[];
}
export interface ReadOnlyConfig {
source: ConfigSource;
value: boolean;
source: ConfigSource;
value: boolean;
}
export interface AuthConfig {
source: ConfigSource;
enabled: boolean;
adminUsername?: string;
passwordConfigured: boolean;
source: ConfigSource;
enabled: boolean;
adminUsername?: string;
passwordConfigured: boolean;
}
export interface BotConfig {
source: ConfigSource;
enabled: boolean;
mode: "polling" | "jwt-relay";
telegramTokenConfigured: boolean;
allowedChatId: string;
relayPath: string;
relayUsesAuth: boolean;
discord: DiscordBotConfig;
}
export interface DiscordBotConfig {
enabled: boolean;
botToken: string;
applicationId: string;
guildId: string;
allowedChannelId: string;
}
export interface SettingsResponse {
dockerHosts: DockerHostsConfig;
coolifyHosts: CoolifyHostsConfig;
readOnly: ReadOnlyConfig;
auth: AuthConfig;
dockerHosts: DockerHostsConfig;
coolifyHosts: CoolifyHostsConfig;
readOnly: ReadOnlyConfig;
auth: AuthConfig;
bot: BotConfig;
}
export interface TestConnectionResult {
success: boolean;
message: string;
dockerVersion?: string;
success: boolean;
message: string;
dockerVersion?: string;
}

View file

@ -6,31 +6,37 @@ import { requireAuthIfEnabled } from "@/lib/auth-guard";
// Define search params schema for the dashboard
const dashboardSearchSchema = z
.object({
search: z.string().optional(),
state: z.string().optional(),
sort: z.enum(["asc", "desc"]).optional(),
group: z.enum(["none", "compose"]).optional(),
page: z.number().optional(),
pageSize: z.number().optional(),
from: z.string().optional(),
to: z.string().optional(),
})
.passthrough()
.catch({});
.object({
search: z.string().optional(),
state: z.string().optional(),
host: z.string().optional(),
sort: z.enum(["asc", "desc"]).optional(),
sortBy: z
.enum(["name", "state", "uptime", "created", "cpu", "ram"])
.optional(),
group: z.enum(["none", "compose"]).optional(),
interval: z.enum(["1h", "12h"]).optional(),
expanded: z.array(z.string()).optional(),
page: z.number().optional(),
pageSize: z.number().optional(),
from: z.string().optional(),
to: z.string().optional(),
})
.passthrough()
.catch({});
export const Route = createFileRoute("/")({
validateSearch: dashboardSearchSchema.parse,
beforeLoad: async () => {
await requireAuthIfEnabled();
},
component: Index,
validateSearch: dashboardSearchSchema.parse,
beforeLoad: async () => {
await requireAuthIfEnabled();
},
component: Index,
});
function Index() {
return (
<main className="container mx-auto px-4 py-8">
<ContainersDashboard />
</main>
);
return (
<main className="container mx-auto px-4 py-8">
<ContainersDashboard />
</main>
);
}

View file

@ -4,11 +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"
@ -20,6 +23,8 @@ import (
func main() {
system.Init()
const containerStatsRetention = 30 * 24 * time.Hour
manager := config.NewManager()
cfg := manager.Config()
@ -65,25 +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)
// Scanner database
dbPath := "/data/scanner.db"
if v := os.Getenv("SCANNER_DB_PATH"); v != "" {
@ -96,6 +82,36 @@ func main() {
defer scanDB.Close()
log.Printf("Scan database opened at %s", dbPath)
// Alert monitor / stats collection
// alertMonitor starts nil and is injected after creation when alerts are enabled.
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.Stats.SampleInterval, 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 {
@ -162,11 +178,15 @@ func main() {
autoScanner.Stop()
}
telegramBot.UpdateConfig(newCfg.Bot)
log.Println("Configuration reloaded successfully")
})
routerOpts := &api.RouterOptions{
AlertMonitor: alertMonitor,
BotService: telegramBot,
ScanDB: scanDB,
ScannerService: scannerService,
AutoScanner: autoScanner,
}

View file

@ -12,6 +12,7 @@ import (
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/docker"
"github.com/hhftechnology/vps-monitor/internal/models"
"github.com/hhftechnology/vps-monitor/internal/stats"
)
// Monitor handles background monitoring and alerting
@ -20,22 +21,34 @@ type Monitor struct {
dockerMu sync.RWMutex
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,
}
}
@ -53,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)
@ -77,6 +85,10 @@ func (m *Monitor) GetHistory() *AlertHistory {
return m.history
}
func (m *Monitor) GetStatsHistory() *stats.HistoryManager {
return m.stats
}
// monitorLoop is the main monitoring loop
func (m *Monitor) monitorLoop() {
defer m.wg.Done()
@ -171,6 +183,10 @@ func (m *Monitor) checkContainerStates(ctx context.Context) {
for key := range m.containerStates {
if _, exists := currentContainers[key]; !exists {
delete(m.containerStates, key)
parts := strings.SplitN(key, ":", 2)
if len(parts) == 2 {
m.stats.CleanupContainer(parts[0], parts[1])
}
}
}
}
@ -197,6 +213,12 @@ func (m *Monitor) checkResourceThresholds(ctx context.Context) {
if err != nil {
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 {
@ -234,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
@ -245,6 +279,10 @@ func (m *Monitor) triggerAlert(alert models.Alert) {
// Send webhook
if m.config.WebhookURL != "" {
if m.config.AlertsFilter == "critical" && !isCriticalAlert(alert) {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@ -255,3 +293,7 @@ func (m *Monitor) triggerAlert(alert models.Alert) {
}()
}
}
func isCriticalAlert(alert models.Alert) bool {
return alert.Type == models.AlertCPUThreshold || alert.Type == models.AlertMemoryThreshold
}

View file

@ -0,0 +1,19 @@
package alerts
import (
"testing"
"github.com/hhftechnology/vps-monitor/internal/models"
)
func TestIsCriticalAlertMatchesThresholdAlertsOnly(t *testing.T) {
if !isCriticalAlert(models.Alert{Type: models.AlertCPUThreshold}) {
t.Fatal("expected CPU threshold alerts to be critical")
}
if !isCriticalAlert(models.Alert{Type: models.AlertMemoryThreshold}) {
t.Fatal("expected memory threshold alerts to be critical")
}
if isCriticalAlert(models.Alert{Type: models.AlertContainerStopped}) {
t.Fatal("expected container stopped alerts to be excluded from critical-only filtering")
}
}

View file

@ -0,0 +1,71 @@
package api
import (
"encoding/json"
"errors"
"log"
"net/http"
"strings"
"github.com/hhftechnology/vps-monitor/internal/bot"
"github.com/hhftechnology/vps-monitor/internal/config"
)
func (ar *APIRouter) RelayBotCommand(w http.ResponseWriter, r *http.Request) {
if ar.botService == nil {
http.Error(w, "bot service unavailable", http.StatusServiceUnavailable)
return
}
if svc := ar.registry.Auth(); svc == nil || svc.IsDisabled() {
http.Error(w, "auth must be enabled before using bot relay mode", http.StatusConflict)
return
}
if ar.registry.Config().Bot.Mode != config.BotModeJWTRelay {
http.Error(w, "bot relay mode is disabled", http.StatusConflict)
return
}
var req struct {
Text string `json:"text"`
ChatID string `json:"chatId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
req.Text = strings.TrimSpace(req.Text)
if req.Text == "" {
http.Error(w, "text is required", http.StatusBadRequest)
return
}
reply, err := ar.botService.RelayCommand(r.Context(), req.ChatID, req.Text)
if err != nil {
status, message := relayBotCommandErrorResponse(err)
log.Printf("bot relay command failed: %v", err)
http.Error(w, message, status)
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"message": "Command relayed",
"reply": reply,
})
}
func relayBotCommandErrorResponse(err error) (int, string) {
switch {
case errors.Is(err, bot.ErrRelayDisabled):
return http.StatusConflict, "bot relay mode is disabled"
case errors.Is(err, bot.ErrRelayNotConfigured):
return http.StatusConflict, "bot relay is not configured"
case errors.Is(err, bot.ErrRelayChatNotAllowed):
return http.StatusForbidden, "chat id is not allowed"
case errors.Is(err, bot.ErrTelegramSendFailed):
return http.StatusBadGateway, "failed to send Telegram message"
default:
return http.StatusBadRequest, "invalid bot relay command"
}
}

View file

@ -0,0 +1,180 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"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/services"
)
type fakeBotRelayService struct {
reply string
err error
gotText string
gotChat string
callCount int
}
func (f *fakeBotRelayService) RelayCommand(_ context.Context, chatID, text string) (string, error) {
f.callCount++
f.gotChat = chatID
f.gotText = text
return f.reply, f.err
}
func TestRelayBotCommandRejectsWhenAuthDisabled(t *testing.T) {
router := &APIRouter{
registry: services.NewRegistry(nil, nil, auth.NewDisabledService(), &config.Config{
Bot: config.BotConfig{Enabled: true, Mode: config.BotModeJWTRelay},
}, nil),
botService: &fakeBotRelayService{},
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/bot/relay/command", strings.NewReader(`{"text":"/help"}`))
rec := httptest.NewRecorder()
router.RelayBotCommand(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("expected %d, got %d", http.StatusConflict, rec.Code)
}
}
func TestRelayBotCommandReturnsReply(t *testing.T) {
relay := &fakeBotRelayService{reply: "ok"}
router := &APIRouter{
registry: services.NewRegistry(nil, nil, newUsableAuthService(t), &config.Config{
Bot: config.BotConfig{Enabled: true, Mode: config.BotModeJWTRelay},
}, nil),
botService: relay,
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/bot/relay/command", strings.NewReader(`{"text":" /help ","chatId":"123"}`))
rec := httptest.NewRecorder()
router.RelayBotCommand(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected %d, got %d", http.StatusOK, rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("expected application/json content type, got %q", ct)
}
var body struct {
Message string `json:"message"`
Reply string `json:"reply"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Message != "Command relayed" || body.Reply != "ok" {
t.Fatalf("unexpected response body: %+v", body)
}
if relay.gotText != "/help" || relay.gotChat != "123" {
t.Fatalf("expected trimmed command and chat id, got text=%q chat=%q", relay.gotText, relay.gotChat)
}
}
func TestRelayBotCommandRejectsNilBotService(t *testing.T) {
router := &APIRouter{}
req := httptest.NewRequest(http.MethodPost, "/api/v1/bot/relay/command", strings.NewReader(`{"text":"/help"}`))
rec := httptest.NewRecorder()
router.RelayBotCommand(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected %d, got %d", http.StatusServiceUnavailable, rec.Code)
}
}
func TestRelayBotCommandRejectsWhenModeIsNotRelay(t *testing.T) {
router := &APIRouter{
registry: services.NewRegistry(nil, nil, newUsableAuthService(t), &config.Config{
Bot: config.BotConfig{Enabled: true, Mode: config.BotModePolling},
}, nil),
botService: &fakeBotRelayService{},
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/bot/relay/command", strings.NewReader(`{"text":"/help"}`))
rec := httptest.NewRecorder()
router.RelayBotCommand(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("expected %d, got %d", http.StatusConflict, rec.Code)
}
}
func TestRelayBotCommandRejectsInvalidRequestBody(t *testing.T) {
router := newRelayBotCommandTestRouter(t, &fakeBotRelayService{})
for _, body := range []string{`{`, `{"text":""}`, `{"text":" "}`} {
req := httptest.NewRequest(http.MethodPost, "/api/v1/bot/relay/command", strings.NewReader(body))
rec := httptest.NewRecorder()
router.RelayBotCommand(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected %d for body %q, got %d", http.StatusBadRequest, body, rec.Code)
}
}
}
func TestRelayBotCommandMapsRelayErrors(t *testing.T) {
tests := []struct {
name string
err error
want int
body string
}{
{name: "unknown", err: errors.New("internal detail"), want: http.StatusBadRequest, body: "invalid bot relay command"},
{name: "disabled", err: bot.ErrRelayDisabled, want: http.StatusConflict, body: "bot relay mode is disabled"},
{name: "not configured", err: bot.ErrRelayNotConfigured, want: http.StatusConflict, body: "bot relay is not configured"},
{name: "chat not allowed", err: bot.ErrRelayChatNotAllowed, want: http.StatusForbidden, body: "chat id is not allowed"},
{name: "send failed", err: bot.ErrTelegramSendFailed, want: http.StatusBadGateway, body: "failed to send Telegram message"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router := newRelayBotCommandTestRouter(t, &fakeBotRelayService{err: tt.err})
req := httptest.NewRequest(http.MethodPost, "/api/v1/bot/relay/command", strings.NewReader(`{"text":"/help"}`))
rec := httptest.NewRecorder()
router.RelayBotCommand(rec, req)
if rec.Code != tt.want {
t.Fatalf("expected %d, got %d", tt.want, rec.Code)
}
if !strings.Contains(rec.Body.String(), tt.body) {
t.Fatalf("expected sanitized body %q, got %q", tt.body, rec.Body.String())
}
})
}
}
func newRelayBotCommandTestRouter(t *testing.T, relay *fakeBotRelayService) *APIRouter {
t.Helper()
return &APIRouter{
registry: services.NewRegistry(nil, nil, newUsableAuthService(t), &config.Config{
Bot: config.BotConfig{Enabled: true, Mode: config.BotModeJWTRelay},
}, nil),
botService: relay,
}
}
func newUsableAuthService(t *testing.T) *auth.Service {
t.Helper()
hash, err := auth.HashPassword("secret")
if err != nil {
t.Fatalf("HashPassword() error = %v", err)
}
return auth.NewServiceFromFileConfig(&config.FileAuthConfig{
Enabled: true,
JWTSecret: "jwt-secret",
AdminUsername: "admin",
AdminPasswordHash: hash,
})
}

View file

@ -0,0 +1,145 @@
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 TestGetContainerHistoricalStatsValidatesHostBeforeStatsDB(t *testing.T) {
router := &APIRouter{
registry: services.NewRegistry(nil, nil, nil, &config.Config{}, nil),
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/containers/container-1/stats/history", nil)
req = withURLParam(req, "id", "container-1")
rec := httptest.NewRecorder()
router.GetContainerHistoricalStats(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected %d, got %d: %s", http.StatusBadRequest, rec.Code, rec.Body.String())
}
if rec.Body.String() != "host parameter is required\n" {
t.Fatalf("unexpected body: %q", rec.Body.String())
}
}
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

@ -16,11 +16,54 @@ import (
"github.com/hhftechnology/vps-monitor/internal/coolify"
"github.com/hhftechnology/vps-monitor/internal/models"
"github.com/hhftechnology/vps-monitor/internal/system"
"github.com/hhftechnology/vps-monitor/internal/docker"
"sync"
)
// 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 ContainerActionJob struct {
Host string
ID string
Action string
Status string
Error string
ExpiresAt time.Time
}
var (
actionJobsMu sync.RWMutex
actionJobs = make(map[string]*ContainerActionJob)
)
func RecordActionJob(host, id, action, status, errStr string) {
actionJobsMu.Lock()
defer actionJobsMu.Unlock()
key := host + ":" + id + ":" + action
actionJobs[key] = &ContainerActionJob{
Host: host,
ID: id,
Action: action,
Status: status,
Error: errStr,
ExpiresAt: time.Now().Add(5 * time.Minute),
}
for k, v := range actionJobs {
if time.Now().After(v.ExpiresAt) {
delete(actionJobs, k)
}
}
}
func GetActionJob(host, id, action string) *ContainerActionJob {
actionJobsMu.RLock()
defer actionJobsMu.RUnlock()
return actionJobs[host+":"+id+":"+action]
}
type coolifyEnvSyncer interface {
SyncEnvVars(ctx context.Context, resource *coolify.ResourceInfo, envVars map[string]string) error
}
@ -38,6 +81,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)
@ -59,8 +143,8 @@ func (ar *APIRouter) GetContainers(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
dockerClient, releaseDocker := ar.registry.AcquireDocker()
defer releaseDocker()
if dockerClient == nil {
releaseDocker()
http.Error(w, "docker client unavailable", http.StatusServiceUnavailable)
return
}
@ -77,6 +161,8 @@ func (ar *APIRouter) GetContainers(w http.ResponseWriter, r *http.Request) {
allContainers = append(allContainers, containers...)
}
ar.enrichContainersWithHistoricalStats(allContainers)
// Build host errors list for the frontend (graceful partial results)
hostErrorMessages := make([]map[string]string, 0, len(hostErrors))
for _, he := range hostErrors {
@ -214,13 +300,13 @@ func (ar *APIRouter) StopContainer(w http.ResponseWriter, r *http.Request) {
return
}
err := dockerClient.StopContainer(r.Context(), host, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"message": "Container stopped",
WriteJsonResponse(w, http.StatusAccepted, map[string]any{
"message": "Container stop initiated",
"status": "pending",
})
ar.runAsyncContainerAction(host, id, "stop", func(ctx context.Context, client *docker.MultiHostClient) error {
return client.StopContainer(ctx, host, id)
})
}
@ -234,19 +320,19 @@ func (ar *APIRouter) RestartContainer(w http.ResponseWriter, r *http.Request) {
}
dockerClient, releaseDocker := ar.registry.AcquireDocker()
defer releaseDocker()
if dockerClient == nil {
releaseDocker()
http.Error(w, "docker client unavailable", http.StatusServiceUnavailable)
return
}
err := dockerClient.RestartContainer(r.Context(), host, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"message": "Container restarted",
WriteJsonResponse(w, http.StatusAccepted, map[string]any{
"message": "Container restart initiated",
"status": "pending",
})
ar.runAsyncContainerAction(host, id, "restart", func(ctx context.Context, client *docker.MultiHostClient) error {
return client.RestartContainer(ctx, host, id)
})
}
@ -260,20 +346,74 @@ func (ar *APIRouter) RemoveContainer(w http.ResponseWriter, r *http.Request) {
}
dockerClient, releaseDocker := ar.registry.AcquireDocker()
defer releaseDocker()
if dockerClient == nil {
releaseDocker()
http.Error(w, "docker client unavailable", http.StatusServiceUnavailable)
return
}
err := dockerClient.RemoveContainer(r.Context(), host, id)
WriteJsonResponse(w, http.StatusAccepted, map[string]any{
"message": "Container remove initiated",
"status": "pending",
})
ar.runAsyncContainerAction(host, id, "remove", func(ctx context.Context, client *docker.MultiHostClient) error {
return client.RemoveContainer(ctx, host, id)
})
}
func (ar *APIRouter) GetContainerHistoricalStats(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
host := r.URL.Query().Get("host")
if host == "" {
http.Error(w, "host parameter is required", http.StatusBadRequest)
return
}
if ar.statsDB == nil {
http.Error(w, "stats history not available", http.StatusServiceUnavailable)
return
}
history, err := ar.getContainerHistoricalAverages(host, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"message": "Container removed",
})
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, *docker.MultiHostClient) error) {
RecordActionJob(host, id, action, "pending", "")
go func() {
dockerClient, release := ar.registry.AcquireDocker()
defer release()
if dockerClient == nil {
log.Printf("failed to %s container %s on host %s: docker client unavailable", action, id, host)
RecordActionJob(host, id, action, "failed", "docker client unavailable")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := fn(ctx, dockerClient); err != nil {
log.Printf("failed to %s container %s on host %s: %v", action, id, host, err)
RecordActionJob(host, id, action, "failed", err.Error())
} else {
RecordActionJob(host, id, action, "success", "")
}
}()
}
func (ar *APIRouter) GetContainerLogsParsed(w http.ResponseWriter, r *http.Request) {

View file

@ -2,6 +2,7 @@ package api
import (
"bytes"
"context"
"encoding/json"
"log"
"net/http"
@ -19,6 +20,10 @@ import (
"github.com/hhftechnology/vps-monitor/internal/static"
)
type botRelayService interface {
RelayCommand(ctx context.Context, chatID, text string) (string, error)
}
// Buffer pool for JSON encoding to reduce allocations
var jsonBufferPool = sync.Pool{
New: func() interface{} {
@ -32,6 +37,8 @@ type APIRouter struct {
manager *config.Manager
alertHandlers *AlertHandlers
scanHandlers *ScanHandlers
botService botRelayService
statsDB *scanner.ScanDB
}
// RouterOptions contains optional dependencies for the router
@ -39,6 +46,8 @@ type RouterOptions struct {
AlertMonitor *alerts.Monitor
ScannerService *scanner.ScannerService
AutoScanner *scanner.AutoScanner
BotService botRelayService
ScanDB *scanner.ScanDB
}
func NewRouter(registry *services.Registry, manager *config.Manager, opts *RouterOptions) *chi.Mux {
@ -49,6 +58,13 @@ func NewRouter(registry *services.Registry, manager *config.Manager, opts *Route
registry: registry,
manager: manager,
}
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
if opts != nil && opts.ScannerService != nil {
@ -68,6 +84,7 @@ func NewRouter(registry *services.Registry, manager *config.Manager, opts *Route
MemoryThreshold: cfg.Alerts.MemoryThreshold,
CheckInterval: cfg.Alerts.CheckInterval.String(),
WebhookEnabled: cfg.Alerts.WebhookURL != "",
AlertsFilter: cfg.Alerts.AlertsFilter,
})
} else {
r.alertHandlers = NewAlertHandlers(nil, &models.AlertConfigResponse{
@ -76,6 +93,7 @@ func NewRouter(registry *services.Registry, manager *config.Manager, opts *Route
MemoryThreshold: cfg.Alerts.MemoryThreshold,
CheckInterval: cfg.Alerts.CheckInterval.String(),
WebhookEnabled: cfg.Alerts.WebhookURL != "",
AlertsFilter: cfg.Alerts.AlertsFilter,
})
}
@ -128,6 +146,7 @@ func (ar *APIRouter) Routes() *chi.Mux {
ar.registerImageRoutes(protected)
ar.registerNetworkRoutes(protected)
ar.registerAlertRoutes(protected)
ar.registerBotRoutes(protected)
ar.registerScanRoutes(protected)
})
})
@ -154,6 +173,7 @@ func (ar *APIRouter) registerContainerRoutes(r chi.Router) {
r.Get("/env", ar.GetEnvVariables)
r.Get("/stats", ar.HandleContainerStats)
r.Get("/stats/once", ar.GetContainerStatsOnce)
r.Get("/stats/history", ar.GetContainerHistoricalStats)
// Mutating routes (blocked in read-only mode)
r.Group(func(mutating chi.Router) {
@ -205,6 +225,14 @@ func (ar *APIRouter) registerAlertRoutes(r chi.Router) {
r.Post("/alerts/acknowledge-all", ar.alertHandlers.AcknowledgeAllAlerts)
}
func (ar *APIRouter) registerBotRoutes(r chi.Router) {
if ar.botService == nil {
return
}
r.Post("/bot/relay/command", ar.RelayBotCommand)
}
func (ar *APIRouter) registerScanRoutes(r chi.Router) {
if ar.scanHandlers == nil {
return
@ -249,12 +277,20 @@ func (ar *APIRouter) registerSettingsRoutes(r chi.Router) {
r.Use(auth.DynamicMiddleware(ar.registry.Auth))
r.Get("/", ar.GetSettings)
r.Put("/docker-hosts", ar.UpdateDockerHosts)
r.Put("/coolify-hosts", ar.UpdateCoolifyHosts)
r.Put("/read-only", ar.UpdateReadOnly)
r.Put("/auth", ar.UpdateAuth)
r.Post("/test/docker-host", ar.TestDockerHost)
r.Post("/test/coolify-host", ar.TestCoolifyHost)
r.Group(func(mutating chi.Router) {
mutating.Use(middleware.ReadOnly(func() bool {
return ar.registry.Config().ReadOnly
}))
mutating.Post("/test/bot", ar.TestBot)
mutating.Post("/test/discord-bot", ar.TestDiscordBot)
mutating.Put("/docker-hosts", ar.UpdateDockerHosts)
mutating.Put("/coolify-hosts", ar.UpdateCoolifyHosts)
mutating.Put("/auth", ar.UpdateAuth)
mutating.Put("/bot", ar.UpdateBot)
})
if ar.scanHandlers != nil {
r.Get("/scan", ar.scanHandlers.GetScannerConfig)
r.Group(func(mutating chi.Router) {

View file

@ -12,6 +12,7 @@ import (
"time"
"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/coolify"
"github.com/hhftechnology/vps-monitor/internal/docker"
@ -74,6 +75,33 @@ func (ar *APIRouter) GetSettings(w http.ResponseWriter, r *http.Request) {
authResp["passwordConfigured"] = fc.Auth.AdminPasswordHash != ""
}
botResp := map[string]any{
"source": sources.Bot,
"enabled": cfg.Bot.Enabled,
"mode": cfg.Bot.Mode,
"telegramTokenConfigured": false,
"allowedChatId": cfg.Bot.AllowedChatID,
"relayPath": "/api/v1/bot/relay/command",
"relayUsesAuth": true,
"discord": map[string]any{
"enabled": cfg.Bot.Discord.Enabled,
"botToken": "",
"applicationId": cfg.Bot.Discord.ApplicationID,
"guildId": cfg.Bot.Discord.GuildID,
"allowedChannelId": cfg.Bot.Discord.AllowedChannelID,
},
}
if cfg.Bot.TelegramToken != "" || (fc.Bot != nil && fc.Bot.TelegramToken != "") {
botResp["telegramTokenConfigured"] = true
}
if discordResp, ok := botResp["discord"].(map[string]any); ok {
if sources.Bot == config.SourceEnv && cfg.Bot.Discord.BotToken != "" {
discordResp["botToken"] = secretMask
} else if fc.Bot != nil && fc.Bot.Discord != nil && fc.Bot.Discord.BotToken != "" {
discordResp["botToken"] = secretMask
}
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"dockerHosts": map[string]any{
"source": sources.DockerHosts,
@ -88,6 +116,7 @@ func (ar *APIRouter) GetSettings(w http.ResponseWriter, r *http.Request) {
"value": cfg.ReadOnly,
},
"auth": authResp,
"bot": botResp,
})
}
@ -271,6 +300,173 @@ func (ar *APIRouter) UpdateAuth(w http.ResponseWriter, r *http.Request) {
WriteJsonResponse(w, http.StatusOK, map[string]any{"message": "Auth settings updated"})
}
func (ar *APIRouter) UpdateBot(w http.ResponseWriter, r *http.Request) {
var req struct {
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
TelegramToken string `json:"telegramToken"`
AllowedChatID string `json:"allowedChatId"`
Discord *struct {
Enabled bool `json:"enabled"`
BotToken string `json:"botToken"`
ApplicationID string `json:"applicationId"`
GuildID string `json:"guildId"`
AllowedChannelID string `json:"allowedChannelId"`
} `json:"discord,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
token := strings.TrimSpace(req.TelegramToken)
chatID := strings.TrimSpace(req.AllowedChatID)
mode := config.BotModePolling
if req.Mode != "" {
mode = config.NormalizeBotMode(req.Mode)
}
if token == secretMask {
fc := ar.manager.FileConfigSnapshot()
if fc.Bot == nil || fc.Bot.TelegramToken == "" {
http.Error(w, "no stored telegram token found; provide the actual token", http.StatusBadRequest)
return
}
token = fc.Bot.TelegramToken
}
if req.Enabled && (token == "" || chatID == "") {
http.Error(w, "telegramToken and allowedChatId are required when enabling bot", http.StatusBadRequest)
return
}
if req.Enabled && mode == config.BotModeJWTRelay {
authSvc := ar.registry.Auth()
if authSvc == nil || authSvc.IsDisabled() {
http.Error(w, "auth must be enabled before using jwt-relay bot mode", http.StatusConflict)
return
}
}
fc := ar.manager.FileConfigSnapshot()
nextBot := &config.FileBotConfig{
Enabled: &req.Enabled,
Mode: mode,
TelegramToken: token,
AllowedChatID: chatID,
}
if fc.Bot != nil && fc.Bot.Discord != nil {
existingDiscord := *fc.Bot.Discord
nextBot.Discord = &existingDiscord
}
if req.Discord != nil {
discordToken := strings.TrimSpace(req.Discord.BotToken)
if discordToken == secretMask {
if fc.Bot != nil && fc.Bot.Discord != nil && fc.Bot.Discord.BotToken != "" {
discordToken = fc.Bot.Discord.BotToken
} else {
http.Error(w, "no stored discord token found; provide the actual token", http.StatusBadRequest)
return
}
}
applicationID := strings.TrimSpace(req.Discord.ApplicationID)
guildID := strings.TrimSpace(req.Discord.GuildID)
channelID := strings.TrimSpace(req.Discord.AllowedChannelID)
if req.Discord.Enabled && (discordToken == "" || applicationID == "" || channelID == "") {
http.Error(w, "discord botToken, applicationId, and allowedChannelId are required when enabling Discord bot", http.StatusBadRequest)
return
}
nextBot.Discord = &config.FileDiscordBotConfig{
Enabled: &req.Discord.Enabled,
BotToken: discordToken,
ApplicationID: applicationID,
GuildID: guildID,
AllowedChannelID: channelID,
}
}
err := ar.manager.UpdateBotConfig(nextBot)
if err != nil {
http.Error(w, err.Error(), settingsErrorStatus(err))
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{"message": "Bot settings updated"})
}
func (ar *APIRouter) TestBot(w http.ResponseWriter, r *http.Request) {
var req struct {
TelegramToken string `json:"telegramToken"`
AllowedChatID string `json:"allowedChatId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
token := strings.TrimSpace(req.TelegramToken)
if token == secretMask {
fc := ar.manager.FileConfigSnapshot()
if fc.Bot == nil || fc.Bot.TelegramToken == "" {
http.Error(w, "no stored telegram token found; provide the actual token", http.StatusBadRequest)
return
}
token = fc.Bot.TelegramToken
}
svc := bot.NewService(ar.registry, ar.registry.Config().Bot)
if err := svc.SendTestMessage(r.Context(), token, strings.TrimSpace(req.AllowedChatID)); err != nil {
WriteJsonResponse(w, http.StatusOK, map[string]any{
"success": false,
"message": err.Error(),
})
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"success": true,
"message": "Test message sent",
})
}
func (ar *APIRouter) TestDiscordBot(w http.ResponseWriter, r *http.Request) {
var req struct {
BotToken string `json:"botToken"`
AllowedChannelID string `json:"allowedChannelId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
token := strings.TrimSpace(req.BotToken)
if token == secretMask {
fc := ar.manager.FileConfigSnapshot()
if fc.Bot != nil && fc.Bot.Discord != nil && fc.Bot.Discord.BotToken != "" {
token = fc.Bot.Discord.BotToken
} else {
http.Error(w, "no stored discord token found; provide the actual token", http.StatusBadRequest)
return
}
}
svc := bot.NewService(ar.registry, ar.registry.Config().Bot)
if err := svc.SendDiscordTestMessage(r.Context(), token, strings.TrimSpace(req.AllowedChannelID)); err != nil {
WriteJsonResponse(w, http.StatusOK, map[string]any{
"success": false,
"message": err.Error(),
})
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"success": true,
"message": "Discord test message sent",
})
}
// TestDockerHost handles POST /api/v1/settings/test/docker-host.
func (ar *APIRouter) TestDockerHost(w http.ResponseWriter, r *http.Request) {
var req struct {

View file

@ -2,13 +2,18 @@ package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/hhftechnology/vps-monitor/internal/auth"
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/coolify"
"github.com/hhftechnology/vps-monitor/internal/services"
)
type fakeCoolifySyncer struct {
@ -144,3 +149,216 @@ func TestSettingsErrorStatusDirectErrEnvironmentConfigured(t *testing.T) {
t.Fatalf("expected %d for direct ErrEnvironmentConfigured, got %d", http.StatusConflict, got)
}
}
func TestUpdateBotRejectsIncompleteDiscordConfig(t *testing.T) {
manager := newTestSettingsManager(t)
router := &APIRouter{
manager: manager,
registry: services.NewRegistry(nil, nil, nil, manager.Config(), nil),
}
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings/bot", strings.NewReader(`{
"enabled": false,
"mode": "polling",
"telegramToken": "",
"allowedChatId": "",
"discord": {
"enabled": true,
"botToken": "discord-token",
"applicationId": "app-1",
"allowedChannelId": ""
}
}`))
rec := httptest.NewRecorder()
router.UpdateBot(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected %d, got %d: %s", http.StatusBadRequest, rec.Code, rec.Body.String())
}
}
func TestUpdateBotPreservesMaskedDiscordToken(t *testing.T) {
manager := newTestSettingsManager(t)
router := &APIRouter{
manager: manager,
registry: services.NewRegistry(nil, nil, nil, manager.Config(), nil),
}
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings/bot", strings.NewReader(`{
"enabled": false,
"mode": "polling",
"telegramToken": "",
"allowedChatId": "",
"discord": {
"enabled": true,
"botToken": "discord-token",
"applicationId": "app-1",
"guildId": "guild-1",
"allowedChannelId": "channel-1"
}
}`))
rec := httptest.NewRecorder()
router.UpdateBot(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected initial update to succeed, got %d: %s", rec.Code, rec.Body.String())
}
router.registry.UpdateConfig(manager.Config())
req = httptest.NewRequest(http.MethodPut, "/api/v1/settings/bot", strings.NewReader(`{
"enabled": false,
"mode": "polling",
"telegramToken": "",
"allowedChatId": "",
"discord": {
"enabled": true,
"botToken": "••••••••",
"applicationId": "app-2",
"guildId": "",
"allowedChannelId": "channel-2"
}
}`))
rec = httptest.NewRecorder()
router.UpdateBot(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected masked update to succeed, got %d: %s", rec.Code, rec.Body.String())
}
got := manager.Config().Bot.Discord
if got.BotToken != "discord-token" {
t.Fatalf("expected masked update to preserve token, got %q", got.BotToken)
}
if got.ApplicationID != "app-2" || got.AllowedChannelID != "channel-2" || got.GuildID != "" {
t.Fatalf("unexpected discord config after masked update: %+v", got)
}
}
func TestGetSettingsMasksDiscordToken(t *testing.T) {
manager := newTestSettingsManager(t)
enabled := true
if err := manager.UpdateBotConfig(&config.FileBotConfig{
TelegramToken: "telegram-token",
Discord: &config.FileDiscordBotConfig{
Enabled: &enabled,
BotToken: "discord-token",
ApplicationID: "app-1",
AllowedChannelID: "channel-1",
},
}); err != nil {
t.Fatalf("UpdateBotConfig returned error: %v", err)
}
router := &APIRouter{
manager: manager,
registry: services.NewRegistry(nil, nil, nil, manager.Config(), nil),
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
rec := httptest.NewRecorder()
router.GetSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String())
}
var body struct {
Bot struct {
TelegramToken string `json:"telegramToken"`
TelegramTokenConfigured bool `json:"telegramTokenConfigured"`
Discord struct {
BotToken string `json:"botToken"`
} `json:"discord"`
} `json:"bot"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode settings response: %v", err)
}
if body.Bot.TelegramToken != "" {
t.Fatalf("expected telegram token to be omitted, got %q", body.Bot.TelegramToken)
}
if !body.Bot.TelegramTokenConfigured {
t.Fatal("expected telegramTokenConfigured=true")
}
if body.Bot.Discord.BotToken != secretMask {
t.Fatalf("expected masked discord token, got %q", body.Bot.Discord.BotToken)
}
}
func TestUpdateBotRejectsMaskedTelegramTokenWithoutStoredToken(t *testing.T) {
manager := newTestSettingsManager(t)
router := &APIRouter{
manager: manager,
registry: services.NewRegistry(nil, nil, nil, manager.Config(), nil),
}
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings/bot", strings.NewReader(`{
"enabled": false,
"mode": "polling",
"telegramToken": "••••••••",
"allowedChatId": ""
}`))
rec := httptest.NewRecorder()
router.UpdateBot(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected %d, got %d: %s", http.StatusBadRequest, rec.Code, rec.Body.String())
}
}
func TestTestBotRejectsMaskedTelegramTokenWithoutStoredToken(t *testing.T) {
manager := newTestSettingsManager(t)
router := &APIRouter{
manager: manager,
registry: services.NewRegistry(nil, nil, nil, manager.Config(), nil),
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/settings/test/bot", strings.NewReader(`{
"telegramToken": "••••••••",
"allowedChatId": "123"
}`))
rec := httptest.NewRecorder()
router.TestBot(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected %d, got %d: %s", http.StatusBadRequest, rec.Code, rec.Body.String())
}
}
func TestSettingsMutationRoutesRespectReadOnlyMode(t *testing.T) {
manager := newTestSettingsManager(t)
registry := services.NewRegistry(nil, nil, auth.NewDisabledService(), &config.Config{
ReadOnly: true,
Bot: config.BotConfig{Mode: config.BotModePolling},
}, nil)
router := NewRouter(registry, manager, nil)
for _, route := range []string{
"/api/v1/settings/docker-hosts",
"/api/v1/settings/coolify-hosts",
"/api/v1/settings/auth",
"/api/v1/settings/bot",
} {
req := httptest.NewRequest(http.MethodPut, route, strings.NewReader(`{}`))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected %d for %s, got %d: %s", http.StatusForbidden, route, rec.Code, rec.Body.String())
}
}
}
func newTestSettingsManager(t *testing.T) *config.Manager {
t.Helper()
t.Setenv("CONFIG_PATH", t.TempDir()+"/config.json")
t.Setenv("BOT_ENABLED", "")
t.Setenv("BOT_MODE", "")
t.Setenv("BOT_TELEGRAM_TOKEN", "")
t.Setenv("BOT_ALLOWED_CHAT_ID", "")
t.Setenv("BOT_POLL_INTERVAL", "")
t.Setenv("BOT_DISCORD_ENABLED", "")
t.Setenv("BOT_DISCORD_TOKEN", "")
t.Setenv("BOT_DISCORD_APPLICATION_ID", "")
t.Setenv("BOT_DISCORD_GUILD_ID", "")
t.Setenv("BOT_DISCORD_ALLOWED_CHANNEL_ID", "")
return config.NewManager()
}

View file

@ -0,0 +1,166 @@
package bot
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/hhftechnology/vps-monitor/internal/models"
"github.com/hhftechnology/vps-monitor/internal/services"
)
type commandHandler struct {
registry *services.Registry
}
func newCommandHandler(registry *services.Registry) *commandHandler {
return &commandHandler{registry: registry}
}
func (h *commandHandler) handle(text string) string {
switch {
case strings.HasPrefix(text, "/help"), strings.HasPrefix(text, "/start"):
return "Available commands:\n/status - current container health with history\n/critical - latest critical alerts\n/help - command list"
case strings.HasPrefix(text, "/critical"):
return h.buildCriticalMessage()
case strings.HasPrefix(text, "/status"):
return h.buildStatusMessage()
default:
return "Unknown command. Use /help."
}
}
func (h *commandHandler) buildCriticalMessage() string {
if h.registry == nil {
return "Alert monitoring is disabled."
}
monitor := h.registry.Alerts()
if monitor == nil {
return "Alert monitoring is disabled."
}
alertsList := monitor.GetHistory().GetAll()
critical := make([]models.Alert, 0, len(alertsList))
for _, alert := range alertsList {
if alert.Type == models.AlertCPUThreshold || alert.Type == models.AlertMemoryThreshold {
critical = append(critical, alert)
}
}
if len(critical) == 0 {
return "No critical alerts."
}
sort.SliceStable(critical, func(i, j int) bool {
return critical[i].Timestamp > critical[j].Timestamp
})
var lines []string
lines = append(lines, "Critical alerts:")
for _, alert := range critical[:min(5, len(critical))] {
lines = append(lines, fmt.Sprintf("- %s on %s (%s)", alert.ContainerName, alert.Host, alert.Type))
}
return strings.Join(lines, "\n")
}
func (h *commandHandler) buildStatusMessage() string {
if h.registry == nil {
return "Docker client unavailable."
}
dockerClient, release := h.registry.AcquireDocker()
defer release()
if dockerClient == nil {
return "Docker client unavailable."
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
containersMap, _, err := dockerClient.ListContainersAllHosts(ctx)
if err != nil {
return fmt.Sprintf("Failed to list containers: %v", err)
}
type containerLine struct {
name string
cpu float64
line string
}
var lines []containerLine
var linesMu sync.Mutex
var wg sync.WaitGroup
total := 0
running := 0
history := h.registry.Alerts()
var historyManager interface {
Get1hAverages(string, string) (float64, float64, bool)
Get12hAverages(string, string) (float64, float64, bool)
}
if history != nil {
historyManager = history.GetStatsHistory()
}
for hostName, containers := range containersMap {
for _, ctr := range containers {
total++
if ctr.State != "running" {
continue
}
running++
wg.Add(1)
go func(hName string, c models.ContainerInfo) {
defer wg.Done()
stats, err := dockerClient.GetContainerStatsOnce(ctx, hName, c.ID)
if err != nil {
return
}
name := c.ID[:12]
if len(c.Names) > 0 {
name = strings.TrimPrefix(c.Names[0], "/")
}
line := fmt.Sprintf("- %s@%s CPU %.1f%% MEM %.1f%%", name, hName, stats.CPUPercent, stats.MemoryPercent)
if historyManager != nil {
cpu1h, mem1h, has1h := historyManager.Get1hAverages(hName, c.ID)
cpu12h, mem12h, has12h := historyManager.Get12hAverages(hName, c.ID)
line = appendHistoryAverages(line, cpu1h, mem1h, has1h, cpu12h, mem12h, has12h)
}
linesMu.Lock()
lines = append(lines, containerLine{name: name, cpu: stats.CPUPercent, line: line})
linesMu.Unlock()
}(hostName, ctr)
}
}
wg.Wait()
sort.SliceStable(lines, func(i, j int) bool {
return lines[i].cpu > lines[j].cpu
})
message := []string{
fmt.Sprintf("Containers: %d total, %d running", total, running),
}
for _, line := range lines[:min(5, len(lines))] {
message = append(message, line.line)
}
return strings.Join(message, "\n")
}
func appendHistoryAverages(line string, cpu1h, mem1h float64, has1h bool, cpu12h, mem12h float64, has12h bool) string {
if has1h {
line += fmt.Sprintf(" | 1h %.1f/%.1f", cpu1h, mem1h)
}
if has12h {
line += fmt.Sprintf(" | 12h %.1f/%.1f", cpu12h, mem12h)
}
return line
}

View file

@ -0,0 +1,24 @@
package bot
import (
"strings"
"testing"
)
func TestAppendHistoryAveragesIncludesOnlyAvailableWindows(t *testing.T) {
line := appendHistoryAverages("container", 0, 0, false, 12, 34, true)
if strings.Contains(line, "1h") {
t.Fatalf("did not expect 1h segment, got %q", line)
}
if !strings.Contains(line, "12h 12.0/34.0") {
t.Fatalf("expected 12h segment, got %q", line)
}
line = appendHistoryAverages("container", 10, 20, true, 0, 0, false)
if !strings.Contains(line, "1h 10.0/20.0") {
t.Fatalf("expected 1h segment, got %q", line)
}
if strings.Contains(line, "12h") {
t.Fatalf("did not expect 12h segment, got %q", line)
}
}

View file

@ -0,0 +1,485 @@
package bot
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/hhftechnology/vps-monitor/internal/config"
)
const (
discordAPIBase = "https://discord.com/api/v10"
discordOpDispatch = 0
discordOpHeartbeat = 1
discordOpIdentify = 2
discordOpResume = 6
discordOpReconnect = 7
discordOpInvalidSession = 9
discordOpHello = 10
discordOpHeartbeatACK = 11
discordInteractionApplicationCommand = 2
discordResponseDeferredMessage = 5
discordMessageFlagEphemeral = 64
)
type websocketDialer interface {
Dial(urlStr string, requestHeader http.Header) (*websocket.Conn, *http.Response, error)
}
type discordGatewayResponse struct {
URL string `json:"url"`
}
type discordGatewayPayload struct {
Op int `json:"op"`
D json.RawMessage `json:"d"`
S *int64 `json:"s,omitempty"`
T string `json:"t,omitempty"`
}
type discordHelloPayload struct {
HeartbeatInterval int `json:"heartbeat_interval"`
}
type discordReadyPayload struct {
SessionID string `json:"session_id"`
ResumeGatewayURL string `json:"resume_gateway_url"`
}
type discordInteraction struct {
ID string `json:"id"`
Token string `json:"token"`
Type int `json:"type"`
GuildID string `json:"guild_id"`
ChannelID string `json:"channel_id"`
Data struct {
Name string `json:"name"`
} `json:"data"`
}
type discordCommand struct {
Name string `json:"name"`
Description string `json:"description"`
Type int `json:"type"`
}
func (s *Service) startDiscordLocked() {
if s.discordRunning || !isDiscordConfigured(s.cfg.Discord) {
return
}
cfg := s.cfg
s.discordStopCh = make(chan struct{})
s.discordDoneCh = make(chan struct{})
s.discordRunning = true
go s.discordLoop(cfg, s.discordStopCh, s.discordDoneCh)
}
func (s *Service) stopDiscord() {
s.mu.Lock()
if !s.discordRunning {
s.mu.Unlock()
return
}
stopCh := s.discordStopCh
doneCh := s.discordDoneCh
s.discordRunning = false
s.discordStopCh = nil
s.discordDoneCh = nil
s.mu.Unlock()
close(stopCh)
<-doneCh
}
func (s *Service) discordLoop(cfg config.BotConfig, stopCh <-chan struct{}, doneCh chan<- struct{}) {
defer close(doneCh)
if err := s.registerDiscordCommands(context.Background(), cfg.Discord); err != nil {
log.Printf("discord bot command registration failed: %v", err)
}
for {
select {
case <-stopCh:
return
default:
}
if err := s.runDiscordConnection(cfg, stopCh); err != nil {
log.Printf("discord bot gateway failed: %v", err)
}
select {
case <-time.After(5 * time.Second):
case <-stopCh:
return
}
}
}
func (s *Service) runDiscordConnection(cfg config.BotConfig, stopCh <-chan struct{}) error {
gatewayURL, err := s.discordGatewayURL(context.Background(), cfg.Discord.BotToken)
if err != nil {
return err
}
sessionID, resumeURL, _ := s.discordSession()
connectURL := gatewayURL
resuming := sessionID != "" && resumeURL != ""
if resuming {
connectURL = resumeURL
}
if !strings.Contains(connectURL, "?") {
connectURL += "?v=10&encoding=json"
}
conn, _, err := s.discordDialer.Dial(connectURL, nil)
if err != nil {
return err
}
defer conn.Close()
stopped := make(chan struct{})
go func() {
select {
case <-stopCh:
_ = conn.Close()
case <-stopped:
}
}()
defer close(stopped)
var writeMu sync.Mutex
ackMu := sync.Mutex{}
heartbeatAcked := true
heartbeatStop := make(chan struct{})
defer close(heartbeatStop)
for {
var payload discordGatewayPayload
if err := conn.ReadJSON(&payload); err != nil {
select {
case <-stopCh:
return nil
default:
return err
}
}
if payload.S != nil {
s.setDiscordSeq(*payload.S)
}
switch payload.Op {
case discordOpHello:
var hello discordHelloPayload
if err := json.Unmarshal(payload.D, &hello); err != nil {
return err
}
if hello.HeartbeatInterval <= 0 {
return fmt.Errorf("discord gateway hello missing heartbeat interval")
}
go s.discordHeartbeatLoop(conn, &writeMu, &ackMu, &heartbeatAcked, time.Duration(hello.HeartbeatInterval)*time.Millisecond, heartbeatStop)
if resuming {
if err := s.discordResume(conn, &writeMu, cfg.Discord.BotToken, sessionID); err != nil {
return err
}
} else if err := s.discordIdentify(conn, &writeMu, cfg.Discord.BotToken); err != nil {
return err
}
case discordOpHeartbeatACK:
ackMu.Lock()
heartbeatAcked = true
ackMu.Unlock()
case discordOpHeartbeat:
if err := s.discordSendHeartbeat(conn, &writeMu); err != nil {
return err
}
case discordOpReconnect:
return fmt.Errorf("discord gateway requested reconnect")
case discordOpInvalidSession:
s.clearDiscordSession()
return fmt.Errorf("discord gateway invalidated session")
case discordOpDispatch:
if payload.T == "READY" {
var ready discordReadyPayload
if err := json.Unmarshal(payload.D, &ready); err != nil {
return err
}
s.setDiscordSession(ready.SessionID, ready.ResumeGatewayURL)
}
if payload.T == "INTERACTION_CREATE" {
var interaction discordInteraction
if err := json.Unmarshal(payload.D, &interaction); err != nil {
log.Printf("discord interaction decode failed: %v", err)
continue
}
go s.handleDiscordInteraction(context.Background(), cfg.Discord, interaction)
}
}
}
}
func (s *Service) discordHeartbeatLoop(conn *websocket.Conn, writeMu, ackMu *sync.Mutex, acked *bool, interval time.Duration, stopCh <-chan struct{}) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
if err := s.discordSendHeartbeat(conn, writeMu); err != nil {
_ = conn.Close()
return
}
ackMu.Lock()
*acked = false
ackMu.Unlock()
for {
select {
case <-ticker.C:
ackMu.Lock()
if !*acked {
ackMu.Unlock()
_ = conn.Close()
return
}
*acked = false
ackMu.Unlock()
if err := s.discordSendHeartbeat(conn, writeMu); err != nil {
_ = conn.Close()
return
}
case <-stopCh:
return
}
}
}
func (s *Service) discordIdentify(conn *websocket.Conn, writeMu *sync.Mutex, token string) error {
payload := map[string]any{
"op": discordOpIdentify,
"d": map[string]any{
"token": token,
"intents": 0,
"properties": map[string]string{
"os": "linux",
"browser": "vps-monitor",
"device": "vps-monitor",
},
},
}
return discordWriteJSON(conn, writeMu, payload)
}
func (s *Service) discordResume(conn *websocket.Conn, writeMu *sync.Mutex, token, sessionID string) error {
payload := map[string]any{
"op": discordOpResume,
"d": map[string]any{
"token": token,
"session_id": sessionID,
"seq": s.discordSeq(),
},
}
return discordWriteJSON(conn, writeMu, payload)
}
func (s *Service) discordSendHeartbeat(conn *websocket.Conn, writeMu *sync.Mutex) error {
return discordWriteJSON(conn, writeMu, map[string]any{
"op": discordOpHeartbeat,
"d": s.discordSeq(),
})
}
func discordWriteJSON(conn *websocket.Conn, writeMu *sync.Mutex, payload any) error {
writeMu.Lock()
defer writeMu.Unlock()
return conn.WriteJSON(payload)
}
func (s *Service) discordGatewayURL(ctx context.Context, token string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.discordAPIBase+"/gateway/bot", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bot "+token)
res, err := s.client.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
if res.StatusCode >= 300 {
return "", fmt.Errorf("discord gateway lookup returned status %d", res.StatusCode)
}
var payload discordGatewayResponse
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return "", err
}
if payload.URL == "" {
return "", fmt.Errorf("discord gateway lookup returned empty url")
}
return payload.URL, nil
}
func (s *Service) registerDiscordCommands(ctx context.Context, cfg config.DiscordBotConfig) error {
commands := []discordCommand{
{Name: "help", Description: "Show VPS Monitor bot commands", Type: 1},
{Name: "status", Description: "Show current container health with history", Type: 1},
{Name: "critical", Description: "Show latest critical alerts", Type: 1},
}
endpoint := fmt.Sprintf("%s/applications/%s/commands", s.discordAPIBase, cfg.ApplicationID)
if cfg.GuildID != "" {
endpoint = fmt.Sprintf("%s/applications/%s/guilds/%s/commands", s.discordAPIBase, cfg.ApplicationID, cfg.GuildID)
}
return s.doDiscordJSON(ctx, http.MethodPut, endpoint, cfg.BotToken, commands, nil)
}
func (s *Service) handleDiscordInteraction(ctx context.Context, cfg config.DiscordBotConfig, interaction discordInteraction) {
if interaction.Type != discordInteractionApplicationCommand {
return
}
if err := s.deferDiscordInteraction(ctx, cfg, interaction); err != nil {
log.Printf("discord interaction defer failed: %v", err)
return
}
reply := s.discordInteractionReply(cfg, interaction)
if reply == "" {
reply = "No response."
}
if err := s.editDiscordInteractionResponse(ctx, cfg, interaction.Token, reply); err != nil {
log.Printf("discord interaction response failed: %v", err)
}
}
func (s *Service) discordInteractionReply(cfg config.DiscordBotConfig, interaction discordInteraction) string {
if cfg.GuildID != "" && interaction.GuildID != cfg.GuildID {
return "This Discord server is not allowed."
}
if cfg.AllowedChannelID != "" && interaction.ChannelID != cfg.AllowedChannelID {
return "This Discord channel is not allowed."
}
switch interaction.Data.Name {
case "help", "status", "critical":
return s.commands.handle("/" + interaction.Data.Name)
default:
return "Unknown command. Use /help."
}
}
func (s *Service) deferDiscordInteraction(ctx context.Context, cfg config.DiscordBotConfig, interaction discordInteraction) error {
endpoint := fmt.Sprintf("%s/interactions/%s/%s/callback", s.discordAPIBase, interaction.ID, interaction.Token)
payload := map[string]any{
"type": discordResponseDeferredMessage,
"data": map[string]any{"flags": discordMessageFlagEphemeral},
}
return s.doDiscordJSON(ctx, http.MethodPost, endpoint, cfg.BotToken, payload, nil)
}
func (s *Service) editDiscordInteractionResponse(ctx context.Context, cfg config.DiscordBotConfig, token, content string) error {
endpoint := fmt.Sprintf("%s/webhooks/%s/%s/messages/@original", s.discordAPIBase, cfg.ApplicationID, token)
payload := map[string]any{
"content": content,
"flags": discordMessageFlagEphemeral,
}
return s.doDiscordJSON(ctx, http.MethodPatch, endpoint, cfg.BotToken, payload, nil)
}
func (s *Service) SendDiscordTestMessage(ctx context.Context, token, channelID string) error {
if strings.TrimSpace(token) == "" || strings.TrimSpace(channelID) == "" {
return fmt.Errorf("discord bot token and channel id are required")
}
endpoint := fmt.Sprintf("%s/channels/%s/messages", s.discordAPIBase, strings.TrimSpace(channelID))
payload := map[string]string{"content": "VPS Monitor Discord bot test successful."}
return s.doDiscordJSON(ctx, http.MethodPost, endpoint, strings.TrimSpace(token), payload, nil)
}
func (s *Service) doDiscordJSON(ctx context.Context, method, endpoint, token string, payload, out any) error {
var body *bytes.Reader
if payload == nil {
body = bytes.NewReader(nil)
} else {
data, err := json.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bot "+token)
req.Header.Set("Content-Type", "application/json")
res, err := s.client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 300 {
return fmt.Errorf("discord %s returned status %d", method, res.StatusCode)
}
if out == nil {
return nil
}
return json.NewDecoder(res.Body).Decode(out)
}
func (s *Service) discordSession() (string, string, int64) {
s.mu.Lock()
defer s.mu.Unlock()
return s.discordSessionID, s.discordResumeURL, s.discordLastSeq
}
func (s *Service) setDiscordSession(sessionID, resumeURL string) {
s.mu.Lock()
defer s.mu.Unlock()
s.discordSessionID = sessionID
s.discordResumeURL = resumeURL
}
func (s *Service) clearDiscordSession() {
s.mu.Lock()
defer s.mu.Unlock()
s.discordSessionID = ""
s.discordResumeURL = ""
s.discordLastSeq = 0
}
func (s *Service) discordSeq() int64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.discordLastSeq
}
func (s *Service) setDiscordSeq(seq int64) {
s.mu.Lock()
defer s.mu.Unlock()
s.discordLastSeq = seq
}
func isDiscordConfigured(cfg config.DiscordBotConfig) bool {
return cfg.Enabled &&
strings.TrimSpace(cfg.BotToken) != "" &&
strings.TrimSpace(cfg.ApplicationID) != "" &&
strings.TrimSpace(cfg.AllowedChannelID) != ""
}

View file

@ -0,0 +1,310 @@
package bot
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/services"
)
const telegramAPIBase = "https://api.telegram.org"
var (
ErrRelayDisabled = errors.New("bot relay mode is disabled")
ErrRelayNotConfigured = errors.New("telegram token and allowed chat id are required")
ErrRelayChatNotAllowed = errors.New("chat id is not allowed")
ErrTelegramSendFailed = errors.New("telegram send failed")
)
type Service struct {
mu sync.Mutex
restartMu sync.Mutex
registry *services.Registry
commands *commandHandler
client *http.Client
discordAPIBase string
discordDialer websocketDialer
cfg config.BotConfig
running bool
stopCh chan struct{}
doneCh chan struct{}
offset int64
discordRunning bool
discordStopCh chan struct{}
discordDoneCh chan struct{}
discordSessionID string
discordResumeURL string
discordLastSeq int64
}
type telegramUpdateResponse struct {
OK bool `json:"ok"`
Result []telegramUpdate `json:"result"`
}
type telegramUpdate struct {
UpdateID int64 `json:"update_id"`
Message *telegramMessage `json:"message"`
}
type telegramMessage struct {
MessageID int64 `json:"message_id"`
Text string `json:"text"`
Chat telegramChat `json:"chat"`
}
type telegramChat struct {
ID int64 `json:"id"`
}
func NewService(registry *services.Registry, cfg config.BotConfig) *Service {
return &Service{
registry: registry,
client: &http.Client{
Timeout: 35 * time.Second,
},
commands: newCommandHandler(registry),
discordAPIBase: discordAPIBase,
discordDialer: websocket.DefaultDialer,
cfg: cfg,
}
}
func (s *Service) Start() {
s.mu.Lock()
defer s.mu.Unlock()
if !s.running && isConfigured(s.cfg) && s.cfg.Mode == config.BotModePolling {
s.stopCh = make(chan struct{})
s.doneCh = make(chan struct{})
s.running = true
go s.pollLoop(s.cfg, s.stopCh, s.doneCh)
}
s.startDiscordLocked()
}
func (s *Service) Stop() {
s.stopDiscord()
s.mu.Lock()
if !s.running {
s.mu.Unlock()
return
}
stopCh := s.stopCh
doneCh := s.doneCh
s.running = false
s.stopCh = nil
s.doneCh = nil
s.mu.Unlock()
close(stopCh)
<-doneCh
}
func (s *Service) UpdateConfig(cfg config.BotConfig) {
s.restartMu.Lock()
defer s.restartMu.Unlock()
s.Stop()
s.mu.Lock()
s.cfg = cfg
s.offset = 0
s.discordLastSeq = 0
s.discordSessionID = ""
s.discordResumeURL = ""
s.mu.Unlock()
s.Start()
}
func (s *Service) RelayCommand(ctx context.Context, chatID, text string) (string, error) {
s.mu.Lock()
cfg := s.cfg
s.mu.Unlock()
if cfg.Mode != config.BotModeJWTRelay {
return "", ErrRelayDisabled
}
if !isConfigured(cfg) {
return "", ErrRelayNotConfigured
}
text = strings.TrimSpace(text)
targetChatID := strings.TrimSpace(chatID)
if targetChatID == "" {
targetChatID = cfg.AllowedChatID
}
if cfg.AllowedChatID != "" && targetChatID != cfg.AllowedChatID {
return "", ErrRelayChatNotAllowed
}
reply := s.commands.handle(text)
if reply == "" {
return "", nil
}
if err := s.sendMessage(ctx, cfg.TelegramToken, targetChatID, reply); err != nil {
return "", fmt.Errorf("%w: %v", ErrTelegramSendFailed, err)
}
return reply, nil
}
func (s *Service) SendTestMessage(ctx context.Context, token, chatID string) error {
if strings.TrimSpace(token) == "" || strings.TrimSpace(chatID) == "" {
return fmt.Errorf("telegram token and chat id are required")
}
return s.sendMessage(ctx, token, chatID, "VPS Monitor bot test successful.")
}
func (s *Service) pollLoop(cfg config.BotConfig, stopCh <-chan struct{}, doneCh chan<- struct{}) {
defer close(doneCh)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
select {
case <-stopCh:
cancel()
case <-ctx.Done():
}
}()
for {
select {
case <-ctx.Done():
return
default:
}
if err := s.pollOnce(ctx, cfg); err != nil {
if errors.Is(err, context.Canceled) {
return
}
log.Printf("telegram bot poll failed: %v", err)
select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
return
}
}
}
}
func (s *Service) pollOnce(ctx context.Context, cfg config.BotConfig) error {
params := url.Values{}
params.Set("timeout", strconv.Itoa(int(cfg.PollInterval.Seconds())))
s.mu.Lock()
offset := s.offset
s.mu.Unlock()
if offset > 0 {
params.Set("offset", strconv.FormatInt(offset, 10))
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.apiURL(cfg.TelegramToken, "getUpdates", params), nil)
if err != nil {
return err
}
res, err := s.client.Do(req)
if err != nil {
return sanitizeError(err, cfg.TelegramToken)
}
defer res.Body.Close()
if res.StatusCode >= 300 {
return fmt.Errorf("telegram getUpdates returned status %d", res.StatusCode)
}
var payload telegramUpdateResponse
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return err
}
for _, update := range payload.Result {
s.mu.Lock()
if update.UpdateID >= s.offset {
s.offset = update.UpdateID + 1
}
s.mu.Unlock()
if update.Message == nil {
continue
}
if cfg.AllowedChatID != "" && strconv.FormatInt(update.Message.Chat.ID, 10) != cfg.AllowedChatID {
continue
}
reply := s.commands.handle(strings.TrimSpace(update.Message.Text))
if reply == "" {
continue
}
if err := s.sendMessage(ctx, cfg.TelegramToken, strconv.FormatInt(update.Message.Chat.ID, 10), reply); err != nil {
log.Printf("telegram bot send failed: %v", err)
}
}
return nil
}
func (s *Service) sendMessage(ctx context.Context, token, chatID, text string) error {
form := url.Values{}
form.Set("chat_id", chatID)
form.Set("text", text)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.apiURL(token, "sendMessage", nil), strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res, err := s.client.Do(req)
if err != nil {
return sanitizeError(err, token)
}
defer res.Body.Close()
if res.StatusCode >= 300 {
return fmt.Errorf("telegram sendMessage returned status %d", res.StatusCode)
}
return nil
}
func (s *Service) apiURL(token, method string, params url.Values) string {
base := fmt.Sprintf("%s/bot%s/%s", telegramAPIBase, token, method)
if params == nil || len(params) == 0 {
return base
}
return base + "?" + params.Encode()
}
func isConfigured(cfg config.BotConfig) bool {
return cfg.Enabled && strings.TrimSpace(cfg.TelegramToken) != "" && strings.TrimSpace(cfg.AllowedChatID) != ""
}
func sanitizeError(err error, token string) error {
if err == nil || token == "" {
return err
}
return errors.New(strings.ReplaceAll(err.Error(), token, "***"))
}

View file

@ -0,0 +1,179 @@
package bot
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"testing"
"github.com/hhftechnology/vps-monitor/internal/config"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
func TestStartSkipsPollingInRelayMode(t *testing.T) {
svc := NewService(nil, config.BotConfig{
Enabled: true,
Mode: config.BotModeJWTRelay,
TelegramToken: "token",
AllowedChatID: "chat",
})
svc.Start()
if svc.running {
t.Fatal("expected relay mode to skip polling startup")
}
}
func TestRelayCommandRejectsUnexpectedChat(t *testing.T) {
svc := NewService(nil, config.BotConfig{
Enabled: true,
Mode: config.BotModeJWTRelay,
TelegramToken: "token",
AllowedChatID: "chat-1",
})
_, err := svc.RelayCommand(context.Background(), "chat-2", "/help")
if !errors.Is(err, ErrRelayChatNotAllowed) {
t.Fatalf("expected not allowed error, got %v", err)
}
}
func TestRelayCommandSendsReplyViaTelegram(t *testing.T) {
var gotChatID string
var gotText string
svc := NewService(nil, config.BotConfig{
Enabled: true,
Mode: config.BotModeJWTRelay,
TelegramToken: "token",
AllowedChatID: "chat-1",
})
svc.client = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
values, err := url.ParseQuery(string(body))
if err != nil {
return nil, err
}
gotChatID = values.Get("chat_id")
gotText = values.Get("text")
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"ok":true}`)),
Header: make(http.Header),
}, nil
}),
}
reply, err := svc.RelayCommand(context.Background(), "", "/help")
if err != nil {
t.Fatalf("RelayCommand returned error: %v", err)
}
if gotChatID != "chat-1" {
t.Fatalf("expected chat-1, got %q", gotChatID)
}
if gotText == "" || gotText != reply {
t.Fatalf("expected reply to be sent, got text=%q reply=%q", gotText, reply)
}
}
func TestPollOnceUsesCancellableContext(t *testing.T) {
svc := NewService(nil, config.BotConfig{
Enabled: true,
Mode: config.BotModePolling,
TelegramToken: "token",
AllowedChatID: "chat-1",
})
svc.client = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
<-req.Context().Done()
return nil, req.Context().Err()
}),
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := svc.pollOnce(ctx, svc.cfg)
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context canceled, got %v", err)
}
}
func TestSharedCommandHandlerKeepsTelegramHelpReply(t *testing.T) {
reply := newCommandHandler(nil).handle("/help")
if !strings.Contains(reply, "/status") || !strings.Contains(reply, "/critical") {
t.Fatalf("expected help reply to list existing commands, got %q", reply)
}
}
func TestDiscordInteractionReplyRejectsUnexpectedChannel(t *testing.T) {
svc := NewService(nil, config.BotConfig{})
var interaction discordInteraction
interaction.Type = discordInteractionApplicationCommand
interaction.ChannelID = "channel-2"
interaction.Data.Name = "help"
reply := svc.discordInteractionReply(config.DiscordBotConfig{
Enabled: true,
BotToken: "token",
ApplicationID: "app",
AllowedChannelID: "channel-1",
}, interaction)
if !strings.Contains(reply, "channel is not allowed") {
t.Fatalf("expected channel rejection, got %q", reply)
}
}
func TestSendDiscordTestMessagePostsToChannel(t *testing.T) {
var gotAuth string
var gotPath string
var gotContent string
svc := NewService(nil, config.BotConfig{})
svc.client = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
gotAuth = req.Header.Get("Authorization")
gotPath = req.URL.Path
var body struct {
Content string `json:"content"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
return nil, err
}
gotContent = body.Content
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"id":"message-1"}`)),
Header: make(http.Header),
}, nil
}),
}
svc.discordAPIBase = "https://discord.test"
if err := svc.SendDiscordTestMessage(context.Background(), "token-1", "channel-1"); err != nil {
t.Fatalf("SendDiscordTestMessage returned error: %v", err)
}
if gotAuth != "Bot token-1" {
t.Fatalf("unexpected auth header %q", gotAuth)
}
if gotPath != "/channels/channel-1/messages" {
t.Fatalf("unexpected path %q", gotPath)
}
if !strings.Contains(gotContent, "VPS Monitor Discord bot test successful") {
t.Fatalf("unexpected content %q", gotContent)
}
}

View file

@ -37,8 +37,35 @@ type AlertConfig struct {
CPUThreshold float64 // 0-100, alert when exceeded
MemoryThreshold float64 // 0-100, alert when exceeded
CheckInterval time.Duration // How often to check thresholds
AlertsFilter string
}
type StatsConfig struct {
SampleInterval time.Duration
}
type BotConfig struct {
Enabled bool
Mode string
TelegramToken string
AllowedChatID string
PollInterval time.Duration
Discord DiscordBotConfig
}
type DiscordBotConfig struct {
Enabled bool
BotToken string
ApplicationID string
GuildID string
AllowedChannelID string
}
const (
BotModePolling = "polling"
BotModeJWTRelay = "jwt-relay"
)
// ScannerConfig holds configuration for vulnerability scanning
type ScannerConfig struct {
GrypeImage string
@ -68,6 +95,8 @@ type Config struct {
DockerHosts []DockerHost
CoolifyHosts []CoolifyHostConfig
Alerts AlertConfig
Stats StatsConfig
Bot BotConfig
Scanner ScannerConfig
}
@ -77,6 +106,8 @@ func NewConfig() *Config {
dockerHosts := parseDockerHosts()
coolifyHosts := parseCoolifyHostConfigs()
alertConfig := parseAlertConfig()
statsConfig := parseStatsConfig(alertConfig.CheckInterval)
botConfig := parseBotConfig()
// if we don't have any docker hosts, we should default back to
// the unix socket on the machine running vps-monitor.
@ -103,6 +134,8 @@ func NewConfig() *Config {
DockerHosts: dockerHosts,
CoolifyHosts: coolifyHosts,
Alerts: alertConfig,
Stats: statsConfig,
Bot: botConfig,
Scanner: scannerConfig,
}
}
@ -114,6 +147,7 @@ func parseAlertConfig() AlertConfig {
CPUThreshold: 80, // Default: 80%
MemoryThreshold: 90, // Default: 90%
CheckInterval: 30 * time.Second,
AlertsFilter: "all",
}
if cpuStr := os.Getenv("ALERTS_CPU_THRESHOLD"); cpuStr != "" {
@ -134,9 +168,75 @@ func parseAlertConfig() AlertConfig {
}
}
switch filter := strings.ToLower(strings.TrimSpace(os.Getenv("ALERTS_FILTER"))); filter {
case "", "all":
config.AlertsFilter = "all"
case "critical":
config.AlertsFilter = "critical"
default:
config.AlertsFilter = "all"
}
return config
}
func parseStatsConfig(alertsCheckInterval time.Duration) StatsConfig {
config := StatsConfig{
SampleInterval: alertsCheckInterval,
}
if intervalStr := strings.TrimSpace(os.Getenv("STATS_SAMPLE_INTERVAL")); intervalStr != "" {
if interval, err := time.ParseDuration(intervalStr); err == nil && interval > 0 {
config.SampleInterval = interval
}
}
return config
}
func parseBotConfig() BotConfig {
cfg := BotConfig{
Enabled: os.Getenv("BOT_ENABLED") == "true",
Mode: NormalizeBotMode(os.Getenv("BOT_MODE")),
TelegramToken: strings.TrimSpace(os.Getenv("BOT_TELEGRAM_TOKEN")),
AllowedChatID: strings.TrimSpace(os.Getenv("BOT_ALLOWED_CHAT_ID")),
PollInterval: 15 * time.Second,
Discord: DiscordBotConfig{
Enabled: os.Getenv("BOT_DISCORD_ENABLED") == "true",
BotToken: strings.TrimSpace(os.Getenv("BOT_DISCORD_TOKEN")),
ApplicationID: strings.TrimSpace(os.Getenv("BOT_DISCORD_APPLICATION_ID")),
GuildID: strings.TrimSpace(os.Getenv("BOT_DISCORD_GUILD_ID")),
AllowedChannelID: strings.TrimSpace(os.Getenv("BOT_DISCORD_ALLOWED_CHANNEL_ID")),
},
}
if intervalStr := strings.TrimSpace(os.Getenv("BOT_POLL_INTERVAL")); intervalStr != "" {
if interval, err := time.ParseDuration(intervalStr); err == nil && interval > 0 {
cfg.PollInterval = interval
}
}
if cfg.TelegramToken == "" || cfg.AllowedChatID == "" {
cfg.Enabled = false
}
if cfg.Discord.BotToken == "" || cfg.Discord.ApplicationID == "" || cfg.Discord.AllowedChannelID == "" {
cfg.Discord.Enabled = false
}
return cfg
}
func NormalizeBotMode(raw string) string {
switch strings.TrimSpace(raw) {
case "", BotModePolling:
return BotModePolling
case BotModeJWTRelay:
return BotModeJWTRelay
default:
return BotModePolling
}
}
func parseCoolifyHostConfigs() []CoolifyHostConfig {
// Format: COOLIFY_CONFIGS=hostA|https://coolify-a.com|tokenA,hostB|https://coolify-b.com|tokenB
raw := os.Getenv("COOLIFY_CONFIGS")

View file

@ -39,12 +39,29 @@ type FileNotificationConfig struct {
MinSeverity string `json:"minSeverity,omitempty"`
}
type FileBotConfig struct {
Enabled *bool `json:"enabled,omitempty"`
Mode string `json:"mode,omitempty"`
TelegramToken string `json:"telegramToken,omitempty"`
AllowedChatID string `json:"allowedChatId,omitempty"`
Discord *FileDiscordBotConfig `json:"discord,omitempty"`
}
type FileDiscordBotConfig struct {
Enabled *bool `json:"enabled,omitempty"`
BotToken string `json:"botToken,omitempty"`
ApplicationID string `json:"applicationId,omitempty"`
GuildID string `json:"guildId,omitempty"`
AllowedChannelID string `json:"allowedChannelId,omitempty"`
}
// FileConfig represents the JSON config file structure.
type FileConfig struct {
DockerHosts []DockerHost `json:"dockerHosts,omitempty"`
CoolifyHosts []CoolifyHostConfig `json:"coolifyHosts,omitempty"`
ReadOnly *bool `json:"readOnly,omitempty"`
Auth *FileAuthConfig `json:"auth,omitempty"`
Bot *FileBotConfig `json:"bot,omitempty"`
Scanner *FileScannerConfig `json:"scanner,omitempty"`
}
@ -66,6 +83,7 @@ type EnvSnapshot struct {
CoolifySet bool
ReadOnlySet bool
AuthSet bool
BotSet bool
ScannerSet bool
}
@ -88,6 +106,7 @@ type ConfigSources struct {
CoolifyHosts Source `json:"coolifyHosts"`
ReadOnly Source `json:"readOnly"`
Auth Source `json:"auth"`
Bot Source `json:"bot"`
}
// NewManager creates a config manager that loads from env vars and an optional file.
@ -104,6 +123,16 @@ func NewManager() *Manager {
AuthSet: os.Getenv("JWT_SECRET") != "" ||
os.Getenv("ADMIN_USERNAME") != "" ||
os.Getenv("ADMIN_PASSWORD") != "",
BotSet: os.Getenv("BOT_TELEGRAM_TOKEN") != "" ||
os.Getenv("BOT_ALLOWED_CHAT_ID") != "" ||
os.Getenv("BOT_ENABLED") != "" ||
os.Getenv("BOT_MODE") != "" ||
os.Getenv("BOT_POLL_INTERVAL") != "" ||
os.Getenv("BOT_DISCORD_ENABLED") != "" ||
os.Getenv("BOT_DISCORD_TOKEN") != "" ||
os.Getenv("BOT_DISCORD_APPLICATION_ID") != "" ||
os.Getenv("BOT_DISCORD_GUILD_ID") != "" ||
os.Getenv("BOT_DISCORD_ALLOWED_CHANNEL_ID") != "",
ScannerSet: os.Getenv("SCANNER_GRYPE_IMAGE") != "" ||
os.Getenv("SCANNER_TRIVY_IMAGE") != "" ||
os.Getenv("SCANNER_SYFT_IMAGE") != "" ||
@ -144,6 +173,25 @@ func NewManager() *Manager {
return m
}
func (m *Manager) UpdateBotConfig(bot *FileBotConfig) error {
m.mu.Lock()
if m.envSnapshot.BotSet {
m.mu.Unlock()
return fmt.Errorf("%w: bot is configured via environment variables and cannot be changed from the UI", ErrEnvironmentConfigured)
}
oldBot := m.fileConfig.Bot
m.fileConfig.Bot = bot
if err := m.persist(); err != nil {
m.fileConfig.Bot = oldBot
m.mu.Unlock()
return err
}
m.remerge()
return nil
}
// Config returns the current merged config (thread-safe).
func (m *Manager) Config() *Config {
m.mu.RLock()
@ -406,6 +454,7 @@ func (m *Manager) merge() (*Config, ConfigSources) {
// Preserve vps-monitor specific fields from env config
cfg.Hostname = m.envConfig.Hostname
cfg.Alerts = m.envConfig.Alerts
cfg.Stats = m.envConfig.Stats
// Docker hosts: env hosts + file hosts combined. Env hosts win on name collision.
envDockerNames := make(map[string]bool)
@ -475,6 +524,54 @@ func (m *Manager) merge() (*Config, ConfigSources) {
sources.Auth = SourceDefault
}
cfg.Bot = m.envConfig.Bot
if fc := m.fileConfig.Bot; fc != nil {
if fc.Enabled != nil {
cfg.Bot.Enabled = *fc.Enabled
}
if fc.Mode != "" {
cfg.Bot.Mode = NormalizeBotMode(fc.Mode)
}
if fc.TelegramToken != "" {
cfg.Bot.TelegramToken = fc.TelegramToken
}
if fc.AllowedChatID != "" {
cfg.Bot.AllowedChatID = fc.AllowedChatID
}
if fc.Discord != nil {
if fc.Discord.Enabled != nil {
cfg.Bot.Discord.Enabled = *fc.Discord.Enabled
}
if fc.Discord.BotToken != "" {
cfg.Bot.Discord.BotToken = fc.Discord.BotToken
}
if fc.Discord.ApplicationID != "" {
cfg.Bot.Discord.ApplicationID = fc.Discord.ApplicationID
}
if fc.Discord.GuildID != "" {
cfg.Bot.Discord.GuildID = fc.Discord.GuildID
}
if fc.Discord.AllowedChannelID != "" {
cfg.Bot.Discord.AllowedChannelID = fc.Discord.AllowedChannelID
}
}
}
if cfg.Bot.TelegramToken == "" || cfg.Bot.AllowedChatID == "" {
cfg.Bot.Enabled = false
}
if cfg.Bot.Discord.BotToken == "" || cfg.Bot.Discord.ApplicationID == "" || cfg.Bot.Discord.AllowedChannelID == "" {
cfg.Bot.Discord.Enabled = false
}
if m.envSnapshot.BotSet && m.fileConfig.Bot != nil {
sources.Bot = SourceMixed
} else if m.envSnapshot.BotSet {
sources.Bot = SourceEnv
} else if m.fileConfig.Bot != nil {
sources.Bot = SourceFile
} else {
sources.Bot = SourceDefault
}
// Scanner: start with env config, override with file config where set
cfg.Scanner = m.envConfig.Scanner
if fc := m.fileConfig.Scanner; fc != nil {

View file

@ -4,6 +4,7 @@ import (
"errors"
"path/filepath"
"testing"
"time"
)
// TestErrEnvironmentConfiguredSentinel ensures the sentinel error is defined and
@ -713,4 +714,112 @@ func TestNewManagerSetsEnvSnapshotForResourceLimitVars(t *testing.T) {
}
})
}
}
}
func TestUpdateBotConfigPersistsAndMerges(t *testing.T) {
m := &Manager{
envSnapshot: EnvSnapshot{},
envConfig: NewConfig(),
filePath: filepath.Join(t.TempDir(), "config.json"),
}
m.merged, m.sources = m.merge()
enabled := true
if err := m.UpdateBotConfig(&FileBotConfig{
Enabled: &enabled,
Mode: BotModeJWTRelay,
TelegramToken: "token-1",
AllowedChatID: "chat-1",
}); err != nil {
t.Fatalf("UpdateBotConfig returned error: %v", err)
}
merged := m.Config()
if !merged.Bot.Enabled || merged.Bot.Mode != BotModeJWTRelay || merged.Bot.TelegramToken != "token-1" || merged.Bot.AllowedChatID != "chat-1" {
t.Fatalf("unexpected merged bot config: %+v", merged.Bot)
}
if m.Sources().Bot != SourceFile {
t.Fatalf("expected bot source to be file, got %s", m.Sources().Bot)
}
}
func TestDiscordBotEnvConfigParsesAndDisablesWhenIncomplete(t *testing.T) {
t.Setenv("BOT_DISCORD_ENABLED", "true")
t.Setenv("BOT_DISCORD_TOKEN", "discord-token")
t.Setenv("BOT_DISCORD_APPLICATION_ID", "app-1")
t.Setenv("BOT_DISCORD_ALLOWED_CHANNEL_ID", "")
cfg := NewConfig()
if cfg.Bot.Discord.Enabled {
t.Fatalf("expected incomplete discord bot config to be disabled: %+v", cfg.Bot.Discord)
}
t.Setenv("BOT_DISCORD_ALLOWED_CHANNEL_ID", "channel-1")
cfg = NewConfig()
if !cfg.Bot.Discord.Enabled {
t.Fatalf("expected complete discord bot config to be enabled: %+v", cfg.Bot.Discord)
}
if cfg.Bot.Discord.BotToken != "discord-token" || cfg.Bot.Discord.ApplicationID != "app-1" || cfg.Bot.Discord.AllowedChannelID != "channel-1" {
t.Fatalf("unexpected discord bot config: %+v", cfg.Bot.Discord)
}
}
func TestAlertFilterNormalizesSupportedValues(t *testing.T) {
t.Setenv("ALERTS_FILTER", " CRITICAL ")
cfg := NewConfig()
if cfg.Alerts.AlertsFilter != "critical" {
t.Fatalf("expected critical filter, got %q", cfg.Alerts.AlertsFilter)
}
t.Setenv("ALERTS_FILTER", "unexpected")
cfg = NewConfig()
if cfg.Alerts.AlertsFilter != "all" {
t.Fatalf("expected invalid filter to default to all, got %q", cfg.Alerts.AlertsFilter)
}
}
func TestStatsSampleIntervalFallsBackToAlertsInterval(t *testing.T) {
t.Setenv("ALERTS_CHECK_INTERVAL", "45s")
t.Setenv("STATS_SAMPLE_INTERVAL", "")
cfg := NewConfig()
if cfg.Stats.SampleInterval != 45*time.Second {
t.Fatalf("expected stats interval to fall back to alerts interval, got %s", cfg.Stats.SampleInterval)
}
t.Setenv("STATS_SAMPLE_INTERVAL", "2m")
cfg = NewConfig()
if cfg.Stats.SampleInterval != 2*time.Minute {
t.Fatalf("expected stats interval override, got %s", cfg.Stats.SampleInterval)
}
}
func TestUpdateBotConfigPersistsAndMergesDiscord(t *testing.T) {
m := &Manager{
envSnapshot: EnvSnapshot{},
envConfig: NewConfig(),
filePath: filepath.Join(t.TempDir(), "config.json"),
}
m.merged, m.sources = m.merge()
enabled := true
if err := m.UpdateBotConfig(&FileBotConfig{
Discord: &FileDiscordBotConfig{
Enabled: &enabled,
BotToken: "discord-token",
ApplicationID: "app-1",
GuildID: "guild-1",
AllowedChannelID: "channel-1",
},
}); err != nil {
t.Fatalf("UpdateBotConfig returned error: %v", err)
}
merged := m.Config()
if !merged.Bot.Discord.Enabled ||
merged.Bot.Discord.BotToken != "discord-token" ||
merged.Bot.Discord.ApplicationID != "app-1" ||
merged.Bot.Discord.GuildID != "guild-1" ||
merged.Bot.Discord.AllowedChannelID != "channel-1" {
t.Fatalf("unexpected merged discord bot config: %+v", merged.Bot.Discord)
}
}

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

@ -31,4 +31,5 @@ type AlertConfigResponse struct {
MemoryThreshold float64 `json:"memory_threshold"`
CheckInterval string `json:"check_interval"`
WebhookEnabled bool `json:"webhook_enabled"`
AlertsFilter string `json:"alerts_filter"`
}

View file

@ -2,16 +2,24 @@ package models
// ContainerInfo represents the minimal container information exposed by the API
type ContainerInfo struct {
ID string `json:"id"`
Names []string `json:"names"`
Image string `json:"image"`
ImageID string `json:"image_id"`
Command string `json:"command"`
Created int64 `json:"created"`
State string `json:"state"`
Status string `json:"status"`
Labels map[string]string `json:"labels,omitempty"`
Host string `json:"host"`
ID string `json:"id"`
Names []string `json:"names"`
Image string `json:"image"`
ImageID string `json:"image_id"`
Command string `json:"command"`
Created int64 `json:"created"`
State string `json:"state"`
Status string `json:"status"`
Labels map[string]string `json:"labels,omitempty"`
Host string `json:"host"`
HistoricalStats *HistoricalStats `json:"historical_stats,omitempty"`
}
type HistoricalStats struct {
CPU1h float64 `json:"cpu_1h"`
Memory1h float64 `json:"memory_1h"`
CPU12h float64 `json:"cpu_12h"`
Memory12h float64 `json:"memory_12h"`
}
// LogOptions represents options for fetching container logs

View file

@ -15,3 +15,12 @@ type ContainerStats struct {
PIDs uint64 `json:"pids"`
Timestamp int64 `json:"timestamp"`
}
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"`
Samples []ContainerStats `json:"samples,omitempty"`
}

View file

@ -14,6 +14,8 @@ import (
_ "modernc.org/sqlite"
)
const maxContainerStatsLimit = 1440
// ScanDB manages the SQLite database for persisting scan results and settings.
type ScanDB struct {
db *sql.DB
@ -184,6 +186,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 +881,145 @@ 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
}
if limit > maxContainerStatsLimit {
limit = maxContainerStatsLimit
}
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,171 @@
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 TestGetRecentContainerStatsClampsLimit(t *testing.T) {
db := newTestScanDB(t)
now := time.Unix(1_700_000_000, 0).UTC()
for i := 0; i < maxContainerStatsLimit+5; i++ {
if err := db.InsertContainerStat(models.ContainerStats{
ContainerID: "container-1",
Host: "host-a",
CPUPercent: float64(i),
Timestamp: now.Add(time.Duration(i) * time.Second).Unix(),
}); err != nil {
t.Fatalf("InsertContainerStat() error = %v", err)
}
}
series, err := db.GetRecentContainerStats("host-a", "container-1", now.Add(-time.Hour), maxContainerStatsLimit+1000)
if err != nil {
t.Fatalf("GetRecentContainerStats() error = %v", err)
}
if len(series) != maxContainerStatsLimit {
t.Fatalf("expected clamped sample count %d, got %d", maxContainerStatsLimit, len(series))
}
}
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
}

View file

@ -0,0 +1,105 @@
package stats
import (
"sync"
"time"
"github.com/hhftechnology/vps-monitor/internal/models"
)
const defaultMaxDataPoints = 1440
type DataPoint struct {
CPUPercent float64
MemoryPercent float64
Timestamp time.Time
}
type ContainerHistory struct {
dataPoints []DataPoint
}
type HistoryManager struct {
mu sync.RWMutex
containers map[string]*ContainerHistory
maxSize int
}
func NewHistoryManager() *HistoryManager {
return &HistoryManager{
containers: make(map[string]*ContainerHistory),
maxSize: defaultMaxDataPoints,
}
}
func ContainerKey(host, containerID string) string {
return host + ":" + containerID
}
func (hm *HistoryManager) RecordStats(host, containerID string, stats models.ContainerStats) {
hm.mu.Lock()
defer hm.mu.Unlock()
key := ContainerKey(host, containerID)
history, exists := hm.containers[key]
if !exists {
history = &ContainerHistory{
dataPoints: make([]DataPoint, 0, hm.maxSize),
}
hm.containers[key] = history
}
history.dataPoints = append(history.dataPoints, DataPoint{
CPUPercent: stats.CPUPercent,
MemoryPercent: stats.MemoryPercent,
Timestamp: time.Unix(stats.Timestamp, 0),
})
if len(history.dataPoints) > hm.maxSize {
history.dataPoints = history.dataPoints[len(history.dataPoints)-hm.maxSize:]
}
}
func (hm *HistoryManager) GetAverages(host, containerID string, duration time.Duration) (cpuAvg, memAvg float64, hasData bool) {
hm.mu.RLock()
defer hm.mu.RUnlock()
history, exists := hm.containers[ContainerKey(host, containerID)]
if !exists || len(history.dataPoints) == 0 {
return 0, 0, false
}
cutoff := time.Now().Add(-duration)
var cpuSum, memSum float64
var count int
for i := len(history.dataPoints) - 1; i >= 0; i-- {
point := history.dataPoints[i]
if point.Timestamp.Before(cutoff) {
break
}
cpuSum += point.CPUPercent
memSum += point.MemoryPercent
count++
}
if count == 0 {
return 0, 0, false
}
return cpuSum / float64(count), memSum / float64(count), true
}
func (hm *HistoryManager) Get1hAverages(host, containerID string) (cpuAvg, memAvg float64, hasData bool) {
return hm.GetAverages(host, containerID, time.Hour)
}
func (hm *HistoryManager) Get12hAverages(host, containerID string) (cpuAvg, memAvg float64, hasData bool) {
return hm.GetAverages(host, containerID, 12*time.Hour)
}
func (hm *HistoryManager) CleanupContainer(host, containerID string) {
hm.mu.Lock()
defer hm.mu.Unlock()
delete(hm.containers, ContainerKey(host, containerID))
}

View file

@ -0,0 +1,71 @@
package stats
import (
"testing"
"time"
"github.com/hhftechnology/vps-monitor/internal/models"
)
func TestHistoryManagerUsesHostScopedKeys(t *testing.T) {
manager := NewHistoryManager()
now := time.Now().Unix()
manager.RecordStats("host-a", "container-1", models.ContainerStats{
CPUPercent: 10,
MemoryPercent: 20,
Timestamp: now,
})
manager.RecordStats("host-b", "container-1", models.ContainerStats{
CPUPercent: 50,
MemoryPercent: 60,
Timestamp: now,
})
cpuA, memA, okA := manager.Get1hAverages("host-a", "container-1")
cpuB, memB, okB := manager.Get1hAverages("host-b", "container-1")
if !okA || !okB {
t.Fatalf("expected both host/container pairs to have data")
}
if cpuA != 10 || memA != 20 {
t.Fatalf("unexpected host-a averages: cpu=%v mem=%v", cpuA, memA)
}
if cpuB != 50 || memB != 60 {
t.Fatalf("unexpected host-b averages: cpu=%v mem=%v", cpuB, memB)
}
}
func TestHistoryManagerExcludesExpiredPoints(t *testing.T) {
manager := NewHistoryManager()
now := time.Now()
manager.RecordStats("host-a", "container-1", models.ContainerStats{
CPUPercent: 90,
MemoryPercent: 90,
Timestamp: now.Add(-13 * time.Hour).Unix(),
})
manager.RecordStats("host-a", "container-1", models.ContainerStats{
CPUPercent: 80,
MemoryPercent: 80,
Timestamp: now.Add(-12 * time.Hour).Add(-1 * time.Second).Unix(),
})
manager.RecordStats("host-a", "container-1", models.ContainerStats{
CPUPercent: 10,
MemoryPercent: 20,
Timestamp: now.Add(-12 * time.Hour).Add(1 * time.Second).Unix(),
})
manager.RecordStats("host-a", "container-1", models.ContainerStats{
CPUPercent: 30,
MemoryPercent: 40,
Timestamp: now.Unix(),
})
cpu, mem, ok := manager.Get12hAverages("host-a", "container-1")
if !ok {
t.Fatal("expected in-window data")
}
if cpu != 20 || mem != 30 {
t.Fatalf("expected only in-window samples, got cpu=%v mem=%v", cpu, mem)
}
}