diff --git a/frontend/package.json b/frontend/package.json index 78c1ae87..aa207139 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "vps-monitor", - "version": "2.0.0", + "version": "2.3.1", "private": true, "description": "VPS Monitor", "author": "HHF Technology ", diff --git a/frontend/src/components/footer.tsx b/frontend/src/components/footer.tsx index cda3d182..84abc3ff 100644 --- a/frontend/src/components/footer.tsx +++ b/frontend/src/components/footer.tsx @@ -13,18 +13,6 @@ export function Footer() { target="_blank" rel="noopener noreferrer" className="text-muted-foreground transition-colors hover:text-foreground" - > - - - - Google Play - - - Apple App Store + Google Play ; + +type ChartContextValue = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(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 { + 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 ( + +
+ + {children} + +
+
+ ); +} + +export const ChartTooltip = RechartsPrimitive.Tooltip; + +type TooltipPayloadItem = { + color?: string; + dataKey?: string | number; + name?: string | number; + payload?: Record; + value?: number | string; +}; + +interface ChartTooltipContentProps extends React.HTMLAttributes { + 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 ( +
+ {!hideLabel && label !== undefined && ( +
{label}
+ )} +
+ {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 ( +
+
+ + {labelNode} +
+ {valueNode} +
+ ); + })} +
+
+ ); +}); diff --git a/frontend/src/features/containers/api/container-actions.test.ts b/frontend/src/features/containers/api/container-actions.test.ts new file mode 100644 index 00000000..f38197b8 --- /dev/null +++ b/frontend/src/features/containers/api/container-actions.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { restartContainer } from "./container-actions"; + +const storage = new Map(); + +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, + }); + }); +}); diff --git a/frontend/src/features/containers/api/container-actions.ts b/frontend/src/features/containers/api/container-actions.ts index 512f1f10..514232e6 100644 --- a/frontend/src/features/containers/api/container-actions.ts +++ b/frontend/src/features/containers/api/container-actions.ts @@ -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 { - 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 { + 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); } diff --git a/frontend/src/features/containers/api/get-container-history.test.ts b/frontend/src/features/containers/api/get-container-history.test.ts new file mode 100644 index 00000000..a8c761d9 --- /dev/null +++ b/frontend/src/features/containers/api/get-container-history.test.ts @@ -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; + +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([]); + }); +}); diff --git a/frontend/src/features/containers/api/get-container-history.ts b/frontend/src/features/containers/api/get-container-history.ts new file mode 100644 index 00000000..0252efee --- /dev/null +++ b/frontend/src/features/containers/api/get-container-history.ts @@ -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 { + has_data: boolean; + samples: ContainerStats[]; +} + +export async function getContainerHistory( + id: string, + host: string, +): Promise { + 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 ?? [], + }; +} diff --git a/frontend/src/features/containers/components/container-details-sheet.test.tsx b/frontend/src/features/containers/components/container-details-sheet.test.tsx new file mode 100644 index 00000000..caeb8a69 --- /dev/null +++ b/frontend/src/features/containers/components/container-details-sheet.test.tsx @@ -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
{children}
; + }, + TabsContent: ({ value, children }: any) => { + const context = React.useContext(TabsContext); + if (context.value !== value) return null; + return
{children}
; + }, + TabsList: ({ children }: any) =>
{children}
, + TabsTrigger: ({ value, children, disabled, ...props }: any) => { + const context = React.useContext(TabsContext); + return ; + }, + }; +}); + +vi.mock("./environment-variables", () => ({ + EnvironmentVariables: ({ + onContainerIdChange, + }: { + onContainerIdChange: (containerId: string) => void; + }) => ( + + ), +})); + +vi.mock("recharts", async () => { + const actual = await vi.importActual("recharts"); + + return { + ...actual, + ResponsiveContainer: ({ children }: { children?: ReactNode }) => ( +
{children}
+ ), + }; +}); + +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( + , + ); + + 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( + , + ); + + rerender( + , + ); + + 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" }), + ); + }); +}); diff --git a/frontend/src/features/containers/components/container-details-sheet.tsx b/frontend/src/features/containers/components/container-details-sheet.tsx index 0afce20f..111f41f1 100644 --- a/frontend/src/features/containers/components/container-details-sheet.tsx +++ b/frontend/src/features/containers/components/container-details-sheet.tsx @@ -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 ( - - - - - {containerName} - - {container.state} - - - - {container.image} - - + const merged = new Map(); + for (const sample of persistedSamples) { + merged.set(sample.timestamp, sample); + } + for (const sample of history) { + merged.set(sample.timestamp, sample); + } - - - - - Stats - - - - Terminal - - - - Env Vars - - + return Array.from(merged.values()) + .sort((a, b) => a.timestamp - b.timestamp) + .slice(-60); + }, [history, persistedHistory?.samples]); - - {/* Stats Controls */} -
-
- - {isConnected ? "Live" : "Disconnected"} - - {history.length > 0 && ( - - {history.length} data points - - )} -
- - - - - - {!isRunning - ? "Container must be running" - : isConnected - ? "Stop streaming stats" - : "Start streaming stats"} - - -
+ if (!container) return null; - {error && ( -

{error}

- )} + const containerName = + container.names?.[0]?.replace(/^\//, "") || container.id.slice(0, 12); + const isRunning = container.state.toLowerCase() === "running"; + const historyStats = container.historical_stats; - {!isRunning && ( - - - Container is not running. Start the container to view stats. - - - )} + return ( + + + + + {containerName} + + {container.state} + + + + {container.image} + + - {isRunning && isConnected && !stats && ( -
- - Connecting... -
- )} + + + + + Stats + + + + Terminal + + + + Env Vars + + - {isRunning && !isConnected && !stats && ( - - - Click "Start" to stream real-time container stats - - - )} + + {historyStats && ( +
+ + + 1h CPU +
+ {historyStats.cpu_1h != null ? `${historyStats.cpu_1h.toFixed(1)}%` : "N/A"} +
+
+
+ + + 1h RAM +
+ {historyStats.memory_1h != null ? `${historyStats.memory_1h.toFixed(1)}%` : "N/A"} +
+
+
+ + + 12h CPU +
+ {historyStats.cpu_12h != null ? `${historyStats.cpu_12h.toFixed(1)}%` : "N/A"} +
+
+
+ + + 12h RAM +
+ {historyStats.memory_12h != null ? `${historyStats.memory_12h.toFixed(1)}%` : "N/A"} +
+
+
+
+ )} + {/* Stats Controls */} +
+
+ + {isConnected ? "Live" : "Disconnected"} + + {history.length > 0 && ( + + {history.length} data points + + )} +
+ + + + + + {!isRunning + ? "Container must be running" + : isConnected + ? "Stop streaming stats" + : "Start streaming stats"} + + +
- {/* Live Stats Cards */} - {statsCards && ( -
- {statsCards.map((card) => { - const Icon = card.icon; - return ( - - -
-
- -
-
-

- {card.label} -

-

- {card.value} -

- {card.subValue && ( -

- {card.subValue} -

- )} - {card.subLabel && ( -

- {card.subLabel} -

- )} -
-
-
-
- ); - })} -
- )} + {error &&

{error}

} - {/* Stats Charts */} - -
+ {!isRunning && ( + + + Container is not running. Start the container to view stats. + + + )} - - {isRunning ? ( - - - Loading terminal... - - } - > - - - ) : ( - - - Container is not running. Start the container to access terminal. - - - )} - + {isRunning && isConnected && !stats && ( +
+ + Connecting... +
+ )} - - - -
-
-
- ); + {isRunning && !isConnected && !stats && ( + + + Click "Start" to stream real-time container stats + + + )} + + {/* Live Stats Cards */} + {statsCards && ( +
+ {statsCards.map((card) => { + const Icon = card.icon; + return ( + + +
+
+ +
+
+

+ {card.label} +

+

+ {card.value} +

+ {card.subValue && ( +

+ {card.subValue} +

+ )} + {card.subLabel && ( +

+ {card.subLabel} +

+ )} +
+
+
+
+ ); + })} +
+ )} + + {/* Stats Charts */} + +
+ + + {isRunning ? ( + + + Loading terminal... + + } + > + + + ) : ( + + + Container is not running. Start the container to access + terminal. + + + )} + + + + + +
+
+
+ ); } diff --git a/frontend/src/features/containers/components/container-stats-charts.tsx b/frontend/src/features/containers/components/container-stats-charts.tsx index 395fb33e..a014adff 100644 --- a/frontend/src/features/containers/components/container-stats-charts.tsx +++ b/frontend/src/features/containers/components/container-stats-charts.tsx @@ -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 ( + + + {title} + + + + {children} + + {data.length > 0 ? legend : null} + + + ); +} + +function SeriesLegend({ config }: { config: ChartConfig }) { + return ( +
+ {Object.entries(config).map(([key, value]) => ( +
+ + {value.label} +
+ ))} +
+ ); } export function ContainerStatsCharts({ history }: ContainerStatsChartsProps) { @@ -63,283 +150,176 @@ export function ContainerStatsCharts({ history }: ContainerStatsChartsProps) { })); }, [history]); - if (history.length === 0) { + if (chartData.length === 0) { return ( -
+
No data yet. Stats will appear once streaming begins.
); } return ( -
- {/* CPU Chart */} - - - CPU Usage (%) - - -
- - - - - - - - - - - `${value}%`} - className="text-muted-foreground" - /> - [`${(value as number).toFixed(2)}%`, "CPU"]} - /> - - - -
-
-
+
+ + + + + `${value}%`} + tickLine={false} + width={44} + /> + `${Number(value).toFixed(2)}%`} + /> + } + /> + + + - {/* Memory Chart */} - - - Memory Usage (%) - - -
- - - - - - - - - - - `${value}%`} - className="text-muted-foreground" - /> - { - const payload = (props as { payload: ChartData }).payload; - return [ - `${(value as number).toFixed(2)}% (${formatBytes(payload.memoryUsage)} / ${formatBytes(payload.memoryLimit)})`, - "Memory", - ]; - }} - /> - - - -
-
-
+ + + + + `${value}%`} + tickLine={false} + width={44} + /> + { + const payload = item.payload as ChartData | undefined; + return `${Number(value).toFixed(2)}% (${formatBytes(payload?.memoryUsage ?? 0)} / ${formatBytes(payload?.memoryLimit ?? 0)})`; + }} + /> + } + /> + + + - {/* Network I/O Chart */} - - - Network I/O - - -
- - - - - - - - - - - - - - - formatBytes(value)} - className="text-muted-foreground" - /> - [ - formatBytes(value as number), - name === "networkRx" ? "RX (Received)" : "TX (Transmitted)", - ]} - /> - - - - -
-
-
-
- RX (Received) -
-
-
- TX (Transmitted) -
-
- - + } + > + + + + formatBytes(Number(value))} + tickLine={false} + width={64} + /> + formatBytes(Number(value))} + /> + } + /> + + + + - {/* Block I/O Chart */} - - - Block I/O - - -
- - - - - - - - - - - - - - - formatBytes(value)} - className="text-muted-foreground" - /> - [ - formatBytes(value as number), - name === "blockRead" ? "Read" : "Write", - ]} - /> - - - - -
-
-
-
- Read -
-
-
- Write -
-
- - + } + > + + + + formatBytes(Number(value))} + tickLine={false} + width={64} + /> + formatBytes(Number(value))} + /> + } + /> + + + +
); } diff --git a/frontend/src/features/containers/components/container-utils.test.ts b/frontend/src/features/containers/components/container-utils.test.ts new file mode 100644 index 00000000..ae73e567 --- /dev/null +++ b/frontend/src/features/containers/components/container-utils.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/features/containers/components/container-utils.ts b/frontend/src/features/containers/components/container-utils.ts index d3a0f690..f13b1b04 100644 --- a/frontend/src/features/containers/components/container-utils.ts +++ b/frontend/src/features/containers/components/container-utils.ts @@ -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(); + const groups = new Map(); - 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); } diff --git a/frontend/src/features/containers/components/containers-dashboard.test.tsx b/frontend/src/features/containers/components/containers-dashboard.test.tsx new file mode 100644 index 00000000..443c8446 --- /dev/null +++ b/frontend/src/features/containers/components/containers-dashboard.test.tsx @@ -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) => ( +
+)); +const mockContainerDetailsSheet = vi.fn((_props: unknown) => ( +
+)); + +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: () =>
summary cards
, +})); + +vi.mock("./containers-toolbar", () => ({ + ContainersToolbar: () =>
toolbar
, +})); + +vi.mock("./containers-state-summary", () => ({ + ContainersStateSummary: () =>
state summary
, +})); + +vi.mock("./containers-pagination", () => ({ + ContainersPagination: () =>
pagination
, +})); + +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; + }) => ( +
+ {pageItems.map((item) => ( + + ))} + + +
+ ), +})); + +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( + + + , + ); +} + +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)); + }); +}); diff --git a/frontend/src/features/containers/components/containers-dashboard.tsx b/frontend/src/features/containers/components/containers-dashboard.tsx index 588cc58c..c0c2b5ab 100644 --- a/frontend/src/features/containers/components/containers-dashboard.tsx +++ b/frontend/src/features/containers/components/containers-dashboard.tsx @@ -1,35 +1,44 @@ import { useQueryClient } from "@tanstack/react-query"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { DateRange } from "react-day-picker"; import { toast } from "sonner"; - import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; - import { - removeContainer, - restartContainer, - startContainer, - stopContainer, + removeContainer, + restartContainer, + startContainer, + stopContainer, } from "../api/container-actions"; +import type { GetContainersResponse } from "../api/get-containers"; import { useContainersDashboardUrlState } from "../hooks/use-containers-dashboard-url-state"; import { useContainersQuery } from "../hooks/use-containers-query"; import { useSystemStats } from "../hooks/use-system-stats"; - -import { - formatContainerName, - getInitialStateCounts, - groupByCompose, -} from "./container-utils"; +import type { ContainerInfo } from "../types"; import { ContainerDetailsSheet } from "./container-details-sheet"; +import type { + ContainerActionType, + GroupByOption, + SortColumn, + SortDirection, + StatsInterval, +} from "./container-utils"; +import { + formatContainerName, + getHistoricalValue, + getInitialStateCounts, + groupByCompose, +} from "./container-utils"; import { ContainersLogsSheet } from "./containers-logs-sheet"; import { ContainersPagination } from "./containers-pagination"; import { ContainersStateSummary } from "./containers-state-summary"; @@ -37,511 +46,694 @@ import { ContainersSummaryCards } from "./containers-summary-cards"; import { ContainersTable } from "./containers-table"; import { ContainersToolbar } from "./containers-toolbar"; -import type { DateRange } from "react-day-picker"; -import type { GetContainersResponse } from "../api/get-containers"; -import type { ContainerInfo } from "../types"; -import type { - ContainerActionType, - GroupByOption, - SortDirection, -} from "./container-utils"; - export function ContainersDashboard() { - const queryClient = useQueryClient(); - const { data, error, isError, isFetching, isLoading, refetch } = - useContainersQuery(); - const { data: systemStats } = useSystemStats(); + const queryClient = useQueryClient(); + const { data, error, isError, isFetching, isLoading, refetch } = + useContainersQuery(); + const { data: systemStats } = useSystemStats(); - const containers = data?.containers ?? []; - const isReadOnly = data?.readOnly ?? false; - const hosts = data?.hosts ?? []; - const hostErrors = data?.hostErrors ?? []; + const containers = data?.containers ?? []; + const isReadOnly = data?.readOnly ?? false; + const hosts = data?.hosts ?? []; + const hostErrors = data?.hostErrors ?? []; - const hostInfo = useMemo( - () => ({ - hostname: systemStats?.hostInfo.hostname ?? "Loading...", - os: systemStats?.hostInfo.platform ?? "Unknown", - kernel: systemStats?.hostInfo.kernelVersion ?? "Unknown", - }), - [systemStats] - ); + const hostInfo = useMemo( + () => ({ + hostname: systemStats?.hostInfo.hostname ?? "Loading...", + os: systemStats?.hostInfo.platform ?? "Unknown", + kernel: systemStats?.hostInfo.kernelVersion ?? "Unknown", + }), + [systemStats], + ); - const systemUsage = useMemo( - () => ({ - cpu: Math.round(systemStats?.usage.cpuPercent ?? 0), - memory: Math.round(systemStats?.usage.memoryPercent ?? 0), - disk: Math.round(systemStats?.usage.diskPercent ?? 0), - }), - [systemStats] - ); + const systemUsage = useMemo( + () => ({ + cpu: Math.round(systemStats?.usage.cpuPercent ?? 0), + memory: Math.round(systemStats?.usage.memoryPercent ?? 0), + disk: Math.round(systemStats?.usage.diskPercent ?? 0), + }), + [systemStats], + ); - const { - searchTerm, - setSearchTerm, - stateFilter, - setStateFilter, - hostFilter, - setHostFilter, - sortDirection, - setSortDirection, - groupBy, - setGroupBy, - dateRange, - setDateRange, - clearDateRange, - pageSize, - setPageSize, - page, - setPage, - } = useContainersDashboardUrlState(); - const [selectedContainer, setSelectedContainer] = - useState(null); - const [isLogsSheetOpen, setIsLogsSheetOpen] = useState(false); - const [isDetailsSheetOpen, setIsDetailsSheetOpen] = useState(false); - const [detailsContainer, setDetailsContainer] = - useState(null); - const [pendingAction, setPendingAction] = useState<{ - id: string; - type: ContainerActionType; - } | null>(null); - const [confirmAction, setConfirmAction] = useState<{ - type: Extract; - container: ContainerInfo; - } | null>(null); + const { + searchTerm, + setSearchTerm, + stateFilter, + setStateFilter, + hostFilter, + setHostFilter, + sortDirection, + setSortDirection, + sortBy, + setSortBy, + groupBy, + setGroupBy, + statsInterval, + setStatsInterval, + dateRange, + setDateRange, + clearDateRange, + pageSize, + setPageSize, + page, + setPage, + expandedGroups, + setExpandedGroups, + } = useContainersDashboardUrlState(); + const [selectedContainer, setSelectedContainer] = + useState(null); + const [isLogsSheetOpen, setIsLogsSheetOpen] = useState(false); + const [isDetailsSheetOpen, setIsDetailsSheetOpen] = useState(false); + const [detailsContainer, setDetailsContainer] = + useState(null); + const [selectedContainerIds, setSelectedContainerIds] = useState( + [], + ); + const [pendingAction, setPendingAction] = useState<{ + id: string; + type: ContainerActionType; + } | null>(null); + const [confirmAction, setConfirmAction] = useState<{ + type: Extract; + containers: ContainerInfo[]; + clearSelectionOnConfirm?: boolean; + } | null>(null); - // Helper function to check if a container matches filters - const matchesFilters = useMemo(() => { - const normalizedSearch = searchTerm.trim().toLowerCase(); + // Helper function to check if a container matches filters + const matchesFilters = useMemo(() => { + const normalizedSearch = searchTerm.trim().toLowerCase(); - return ( - container: ContainerInfo, - options: { includeStateFilter?: boolean } = {} - ) => { - const matchesSearch = - !normalizedSearch || - container.id.toLowerCase().startsWith(normalizedSearch) || - container.image.toLowerCase().includes(normalizedSearch) || - container.names.some((name) => - name.toLowerCase().includes(normalizedSearch) - ); + return ( + container: ContainerInfo, + options: { includeStateFilter?: boolean } = {}, + ) => { + const matchesSearch = + !normalizedSearch || + container.id.toLowerCase().startsWith(normalizedSearch) || + container.image.toLowerCase().includes(normalizedSearch) || + container.names.some((name) => + name.toLowerCase().includes(normalizedSearch), + ); - const matchesHost = hostFilter === "all" || container.host === hostFilter; + const matchesHost = hostFilter === "all" || container.host === hostFilter; - const containerDate = new Date(container.created * 1000); - const matchesDateRange = - !dateRange || - (dateRange.from && - dateRange.to && - containerDate >= dateRange.from && - containerDate <= dateRange.to) || - (dateRange.from && !dateRange.to && containerDate >= dateRange.from) || - (!dateRange.from && dateRange.to && containerDate <= dateRange.to); + const containerDate = new Date(container.created * 1000); + const matchesDateRange = + !dateRange || + (dateRange.from && + dateRange.to && + containerDate >= dateRange.from && + containerDate <= dateRange.to) || + (dateRange.from && !dateRange.to && containerDate >= dateRange.from) || + (!dateRange.from && dateRange.to && containerDate <= dateRange.to); - const matchesState = options.includeStateFilter - ? stateFilter === "all" || container.state.toLowerCase() === stateFilter - : true; + const matchesState = options.includeStateFilter + ? stateFilter === "all" || container.state.toLowerCase() === stateFilter + : true; - return matchesSearch && matchesHost && matchesDateRange && matchesState; - }; - }, [searchTerm, hostFilter, dateRange, stateFilter]); + return matchesSearch && matchesHost && matchesDateRange && matchesState; + }; + }, [searchTerm, hostFilter, dateRange, stateFilter]); - const availableStates = useMemo(() => { - const unique = new Set(); - containers.forEach((container) => { - if (container.state) { - unique.add(container.state.toLowerCase()); - } - }); - return Array.from(unique).sort(); - }, [containers]); + const availableStates = useMemo(() => { + const unique = new Set(); + containers.forEach((container) => { + if (container.state) { + unique.add(container.state.toLowerCase()); + } + }); + return Array.from(unique).sort(); + }, [containers]); - const filteredContainers = useMemo(() => { - const filtered = containers.filter((container) => - matchesFilters(container, { includeStateFilter: true }) - ); + const getSortValue = useCallback( + (container: ContainerInfo, column: SortColumn): number | string => { + switch (column) { + case "name": + return formatContainerName(container.names).toLowerCase(); + case "state": + return container.state.toLowerCase(); + case "uptime": + return Date.now() - (container.created * 1000); + case "created": + return container.created; + case "cpu": + return getHistoricalValue(container, statsInterval, "cpu") ?? -1; + case "ram": + return getHistoricalValue(container, statsInterval, "memory") ?? -1; + default: + return container.created; + } + }, + [statsInterval], + ); - return filtered.sort((a, b) => - sortDirection === "desc" ? b.created - a.created : a.created - b.created - ); - }, [containers, matchesFilters, sortDirection]); + const filteredContainers = useMemo(() => { + const filtered = containers.filter((container) => + matchesFilters(container, { includeStateFilter: true }), + ); - const totalPages = - filteredContainers.length === 0 - ? 1 - : Math.ceil(filteredContainers.length / pageSize); + return filtered.sort((a, b) => { + const aValue = getSortValue(a, sortBy); + const bValue = getSortValue(b, sortBy); - useEffect(() => { - if (page > totalPages) { - setPage(totalPages); - } - }, [page, totalPages, setPage]); + if (typeof aValue === "string" && typeof bValue === "string") { + return sortDirection === "desc" + ? bValue.localeCompare(aValue) + : aValue.localeCompare(bValue); + } - const startIndex = filteredContainers.length ? (page - 1) * pageSize + 1 : 0; - const endIndex = filteredContainers.length - ? Math.min(page * pageSize, filteredContainers.length) - : 0; + return sortDirection === "desc" + ? (bValue as number) - (aValue as number) + : (aValue as number) - (bValue as number); + }); + }, [containers, getSortValue, matchesFilters, sortBy, sortDirection]); - const pageItems = useMemo(() => { - const offset = (page - 1) * pageSize; - return filteredContainers.slice(offset, offset + pageSize); - }, [filteredContainers, page, pageSize]); + const totalPages = + filteredContainers.length === 0 + ? 1 + : Math.ceil(filteredContainers.length / pageSize); - const groupedItems = useMemo(() => { - if (groupBy !== "compose") { - return null; - } - return groupByCompose(pageItems); - }, [pageItems, groupBy]); + useEffect(() => { + const nextPage = Math.max(1, totalPages); + if (page > nextPage && page !== nextPage) { + setPage(nextPage); + } + }, [page, totalPages, setPage]); - const stateCounts = useMemo(() => { - const counts = getInitialStateCounts(); + const startIndex = filteredContainers.length ? (page - 1) * pageSize + 1 : 0; + const endIndex = filteredContainers.length + ? Math.min(page * pageSize, filteredContainers.length) + : 0; - // Filter by host, search, and date - but NOT by state filter - // This way state counts reflect the current host selection - containers.forEach((container) => { - if (matchesFilters(container, { includeStateFilter: false })) { - const state = container.state.toLowerCase(); - if (state === "running") counts.running++; - else if (state === "exited") counts.exited++; - else if (state === "paused") counts.paused++; - else if (state === "restarting") counts.restarting++; - else if (state === "dead") counts.dead++; - else counts.other++; - } - }); + const pageItems = useMemo(() => { + const offset = (page - 1) * pageSize; + return filteredContainers.slice(offset, offset + pageSize); + }, [filteredContainers, page, pageSize]); - return counts; - }, [containers, matchesFilters]); + const groupedItems = useMemo(() => { + if (groupBy !== "compose") { + return null; + } + return groupByCompose(pageItems, sortDirection); + }, [pageItems, groupBy, sortDirection]); - const executeAction = async ( - actionType: ContainerActionType, - container: ContainerInfo - ) => { - setPendingAction({ id: container.id, type: actionType }); - try { - let message = ""; - switch (actionType) { - case "start": - message = await startContainer(container.id, container.host); - break; - case "stop": - message = await stopContainer(container.id, container.host); - break; - case "restart": - message = await restartContainer(container.id, container.host); - break; - case "remove": - message = await removeContainer(container.id, container.host); - break; - default: - return; - } - if (message) { - toast.success(message); - } - await refetch(); - } catch (error) { - if (error instanceof Error) { - toast.error(error.message); - } else { - toast.error("Unexpected error while performing container action."); - } - } finally { - setPendingAction(null); - } - }; + const stateCounts = useMemo(() => { + const counts = getInitialStateCounts(); - const handleConfirmAction = async () => { - if (!confirmAction) return; - const { type, container } = confirmAction; - await executeAction(type, container); - setConfirmAction(null); - }; + // Filter by host, search, and date - but NOT by state filter + // This way state counts reflect the current host selection + containers.forEach((container) => { + if (matchesFilters(container, { includeStateFilter: false })) { + const state = container.state.toLowerCase(); + if (state === "running") counts.running++; + else if (state === "exited") counts.exited++; + else if (state === "paused") counts.paused++; + else if (state === "restarting") counts.restarting++; + else if (state === "dead") counts.dead++; + else counts.other++; + } + }); - const handleConfirmDialogOpenChange = (open: boolean) => { - if (!open) { - setConfirmAction(null); - } - }; + return counts; + }, [containers, matchesFilters]); - const handleSearchChange = (value: string) => { - setSearchTerm(value); - }; + const executeAction = async ( + actionType: ContainerActionType, + container: ContainerInfo, + ) => { + setPendingAction({ id: container.id, type: actionType }); + try { + let result: Awaited> | null = null; + switch (actionType) { + case "start": + result = await startContainer(container.id, container.host); + break; + case "stop": + result = await stopContainer(container.id, container.host); + break; + case "restart": + result = await restartContainer(container.id, container.host); + break; + case "remove": + result = await removeContainer(container.id, container.host); + break; + default: + return; + } + if (result?.message) { + if (result.isPending) { + toast.info(result.message); + } else { + toast.success(result.message); + } + } + await refetch(); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message); + } else { + toast.error("Unexpected error while performing container action."); + } + } finally { + setPendingAction(null); + } + }; - const handleStateFilterChange = (value: string) => { - setStateFilter(value); - }; + const handleConfirmAction = async () => { + if (!confirmAction) return; + const { type, containers, clearSelectionOnConfirm } = confirmAction; + for (const container of containers) { + await executeAction(type, container); + } + if (clearSelectionOnConfirm) { + setSelectedContainerIds([]); + } + setConfirmAction(null); + }; - const handleHostFilterChange = (value: string) => { - setHostFilter(value); - }; + const handleConfirmDialogOpenChange = (open: boolean) => { + if (!open) { + setConfirmAction(null); + } + }; - const handleSortDirectionChange = (direction: SortDirection) => { - setSortDirection(direction); - }; + const handleSearchChange = (value: string) => { + setSearchTerm(value); + }; - const handleGroupByChange = (value: GroupByOption) => { - setGroupBy(value); - }; + const handleStateFilterChange = (value: string) => { + setStateFilter(value); + }; - const handleDateRangeChange = (range: DateRange | undefined) => { - setDateRange(range); - }; + const handleHostFilterChange = (value: string) => { + setHostFilter(value); + }; - const handleDateRangeClear = () => { - clearDateRange(); - }; + const handleSortDirectionChange = (direction: SortDirection) => { + setSortDirection(direction); + }; - const handlePageSizeChange = (size: number) => { - setPageSize(size); - }; + const handleSortByChange = (value: SortColumn) => { + setSortBy(value); + }; - const handlePageChange = (nextPage: number) => { - setPage(nextPage); - }; + const handleGroupByChange = (value: GroupByOption) => { + setGroupBy(value); + }; - const handleViewLogs = (container: ContainerInfo) => { - setSelectedContainer(container); - setIsLogsSheetOpen(true); - }; + const handleStatsIntervalChange = (value: StatsInterval) => { + setStatsInterval(value); + }; - const handleLogsSheetOpenChange = (open: boolean) => { - setIsLogsSheetOpen(open); - if (!open) { - setSelectedContainer(null); - } - }; + const handleDateRangeChange = (range: DateRange | undefined) => { + setDateRange(range); + }; - const handleViewStats = (container: ContainerInfo) => { - setDetailsContainer(container); - setIsDetailsSheetOpen(true); - }; + const handleDateRangeClear = () => { + clearDateRange(); + }; - const handleDetailsSheetOpenChange = (open: boolean) => { - setIsDetailsSheetOpen(open); - if (!open) { - setDetailsContainer(null); - } - }; + const handlePageSizeChange = (size: number) => { + setPageSize(size); + }; - const handleContainerRecreated = async (newContainerId: string) => { - await queryClient.refetchQueries({ - queryKey: ["containers"], - exact: false, - }); + const handlePageChange = (nextPage: number) => { + setPage(nextPage); + }; - const updatedData = queryClient.getQueryData([ - "containers", - ]); - const newContainer = updatedData?.containers?.find( - (c) => c.id === newContainerId - ); + const handleViewLogs = (container: ContainerInfo) => { + setSelectedContainer(container); + setIsLogsSheetOpen(true); + }; - if (newContainer) { - setSelectedContainer(newContainer); - } - }; + const handleLogsSheetOpenChange = (open: boolean) => { + setIsLogsSheetOpen(open); + if (!open) { + setSelectedContainer(null); + } + }; - const handleStartContainer = (container: ContainerInfo) => { - void executeAction("start", container); - }; + const handleViewStats = (container: ContainerInfo) => { + setDetailsContainer(container); + setIsDetailsSheetOpen(true); + }; - const handleStopContainer = (container: ContainerInfo) => { - setConfirmAction({ type: "stop", container }); - }; + const handleDetailsSheetOpenChange = (open: boolean) => { + setIsDetailsSheetOpen(open); + if (!open) { + setDetailsContainer(null); + } + }; - const handleRestartContainer = (container: ContainerInfo) => { - void executeAction("restart", container); - }; + const handleContainerRecreated = async (newContainerId: string) => { + await queryClient.refetchQueries({ + queryKey: ["containers"], + exact: false, + }); - const handleDeleteContainer = (container: ContainerInfo) => { - setConfirmAction({ type: "remove", container }); - }; + const updatedData = queryClient.getQueryData([ + "containers", + ]); + const newContainer = updatedData?.containers?.find( + (c) => c.id === newContainerId, + ); - const confirmActionTitle = - confirmAction?.type === "stop" - ? "Stop container?" - : confirmAction?.type === "remove" - ? "Remove container?" - : ""; + if (newContainer) { + setSelectedContainer(newContainer); + } + }; - const confirmActionDescription = - confirmAction?.type === "stop" - ? "Stopping a container will terminate its running processes." - : confirmAction?.type === "remove" - ? "Removing a container will permanently delete it and its resources. This action cannot be undone." - : ""; + const handleStartContainer = (container: ContainerInfo) => { + void executeAction("start", container); + }; - const confirmActionButtonLabel = confirmAction - ? confirmAction.type === "stop" - ? "Stop Container" - : "Remove Container" - : "Confirm"; + const handleStopContainer = (container: ContainerInfo) => { + setConfirmAction({ type: "stop", containers: [container] }); + }; - const isConfirmActionPending = - !!confirmAction && - pendingAction?.id === confirmAction.container.id && - pendingAction?.type === confirmAction.type; + const handleRestartContainer = (container: ContainerInfo) => { + void executeAction("restart", container); + }; - return ( -
- + const handleDeleteContainer = (container: ContainerInfo) => { + setConfirmAction({ type: "remove", containers: [container] }); + }; - {hostErrors.length > 0 && ( -
-

- Some Docker hosts are unavailable -

-
    - {hostErrors.map((he) => ( -
  • - {he.host}: {he.message} -
  • - ))} -
-
- )} + const handleToggleSelect = (id: string) => { + setSelectedContainerIds((current) => + current.includes(id) + ? current.filter((value) => value !== id) + : [...current, id], + ); + }; -
- + const handleSelectAll = () => { + if (selectedContainerIds.length === pageItems.length) { + setSelectedContainerIds([]); + return; + } - + setSelectedContainerIds(pageItems.map((container) => container.id)); + }; - { - void refetch(); - }} - /> + const handleToggleGroup = (project: string) => { + setExpandedGroups( + expandedGroups.includes(project) + ? expandedGroups.filter((value) => value !== project) + : [...expandedGroups, project], + ); + }; - -
+ const handleBatchAction = async (action: ContainerActionType) => { + const selectedContainers = containers.filter((container) => + selectedContainerIds.includes(container.id), + ); - - - - {confirmActionTitle} - - {confirmActionDescription} - - - {confirmAction && ( -
-
- Container Details -
-
-
- Name - - {formatContainerName(confirmAction.container.names)} - -
-
- Image - - {confirmAction.container.image} - -
-
- ID - - {confirmAction.container.id.slice(0, 12)} - -
-
-
- )} - - - Cancel - - { - void handleConfirmAction(); - }} - disabled={isConfirmActionPending} - > - {isConfirmActionPending && } - {confirmActionButtonLabel} - - -
-
+ if ( + (action === "stop" || action === "remove") && + selectedContainers.length > 0 + ) { + setConfirmAction({ + type: action, + containers: selectedContainers, + clearSelectionOnConfirm: true, + }); + return; + } - + for (const container of selectedContainers) { + await executeAction(action, container); + } - -
- ); + setSelectedContainerIds([]); + }; + + useEffect(() => { + setSelectedContainerIds((current) => { + const nextSelectedIds = current.filter((id) => + pageItems.some((container) => container.id === id), + ); + return nextSelectedIds.length === current.length + ? current + : nextSelectedIds; + }); + }, [pageItems]); + + const confirmActionTitle = + confirmAction?.type === "stop" + ? confirmAction.containers.length === 1 + ? "Stop container?" + : "Stop containers?" + : confirmAction?.type === "remove" + ? confirmAction.containers.length === 1 + ? "Remove container?" + : "Remove containers?" + : ""; + + const confirmActionDescription = + confirmAction?.type === "stop" + ? confirmAction.containers.length === 1 + ? "Stopping a container will terminate its running processes." + : `Stopping ${confirmAction.containers.length} containers will terminate their running processes.` + : confirmAction?.type === "remove" + ? confirmAction.containers.length === 1 + ? "Removing a container will permanently delete it and its resources. This action cannot be undone." + : `Removing ${confirmAction.containers.length} containers will permanently delete them and their resources. This action cannot be undone.` + : ""; + + const confirmActionButtonLabel = confirmAction + ? confirmAction.type === "stop" + ? confirmAction.containers.length === 1 + ? "Stop Container" + : "Stop Containers" + : confirmAction.containers.length === 1 + ? "Remove Container" + : "Remove Containers" + : "Confirm"; + + const isConfirmActionPending = + !!confirmAction && + confirmAction.containers.some((container) => container.id === pendingAction?.id) && + pendingAction?.type === confirmAction.type; + + return ( +
+ + + {hostErrors.length > 0 && ( + +

+ Some Docker hosts are unavailable +

+
    + {hostErrors.map((he) => ( +
  • + {he.host}: {he.message} +
  • + ))} +
+
+ )} + +
+ + + + + {selectedContainerIds.length > 0 && ( +
+

+ {selectedContainerIds.length} selected +

+
+ + + + + +
+
+ )} + + { + void refetch(); + }} + /> + + +
+ + + + + {confirmActionTitle} + + {confirmActionDescription} + + + {confirmAction?.containers.length === 1 && ( +
+
+ Container Details +
+
+
+ Name + + {formatContainerName(confirmAction.containers[0].names)} + +
+
+ Image + + {confirmAction.containers[0].image} + +
+
+ ID + + {confirmAction.containers[0].id.slice(0, 12)} + +
+
+
+ )} + + + Cancel + + { + void handleConfirmAction(); + }} + disabled={isConfirmActionPending} + > + {isConfirmActionPending && } + {confirmActionButtonLabel} + + +
+
+ + {isLogsSheetOpen && selectedContainer ? ( + + ) : null} + + {isDetailsSheetOpen && detailsContainer ? ( + + ) : null} +
+ ); } diff --git a/frontend/src/features/containers/components/containers-table.test.tsx b/frontend/src/features/containers/components/containers-table.test.tsx new file mode 100644 index 00000000..ed866d72 --- /dev/null +++ b/frontend/src/features/containers/components/containers-table.test.tsx @@ -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( + , + ); + + expect(screen.getAllByText("Collecting")).toHaveLength(2); + }); + + it("keeps the compose group label together", () => { + render( + , + ); + + 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( + , + ); + + fireEvent.click( + screen.getByRole("button", { name: `Copy ${baseContainer.image}` }), + ); + + expect(writeText).toHaveBeenCalledWith(baseContainer.image); + }); +}); diff --git a/frontend/src/features/containers/components/containers-table.tsx b/frontend/src/features/containers/components/containers-table.tsx index e87a19b6..19582ad1 100644 --- a/frontend/src/features/containers/components/containers-table.tsx +++ b/frontend/src/features/containers/components/containers-table.tsx @@ -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 ( - - - {formatContainerName(container.names)} - - - {container.image} - - - - {toTitleCase(container.state)} - - - - {formatUptime(container.created)} - - - {formatCreatedDate(container.created)} - - - - - - - {container.command} - - - - {container.command} - - - - - - -
- {state === "exited" && ( - - - - - - - - {isReadOnly ? "Start (Read-only mode)" : "Start"} - - - )} - {state === "running" && ( - - - - - - - - {isReadOnly ? "Stop (Read-only mode)" : "Stop"} - - - )} - - - - - - - - {isReadOnly ? "Restart (Read-only mode)" : "Restart"} - - - - - - - - - - {isReadOnly ? "Delete (Read-only mode)" : "Delete"} - - - - - - - View Logs - - - - - - View Stats - -
-
-
-
- ); - }; + 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 ( -
- - - - Name - Image - - State - - Uptime - Created - Command - - Actions - - - - - {isLoading ? ( - - -
- - Loading containers… -
-
-
- ) : isError ? ( - - -
-

- {(error as Error)?.message || "Unable to load containers."} -

- -
-
-
- ) : filteredContainers.length === 0 ? ( - - -
- No containers found. -
-
-
- ) : groupBy === "compose" && groupedItems ? ( - groupedItems.map((group) => ( - - - - {group.project} · {group.items.length} container - {group.items.length === 1 ? "" : "s"} - - - {group.items.map(renderContainerRow)} - - )) - ) : ( - pageItems.map(renderContainerRow) - )} -
-
-
- ); + return ( + + + onToggleSelect(container.id)} + aria-label={`Select ${formatContainerName(container.names)}`} + /> + + + {formatContainerName(container.names)} + + + + + + + + + {container.image} + + + + + + + {toTitleCase(container.state)} + + + + {formatUptime(container.created)} + + + {formatCreatedDate(container.created)} + + + {formatHistoricalMetric(cpuAverage)} + + + {formatHistoricalMetric(memoryAverage)} + + + + + + + {container.command} + + + + {container.command} + + + + + + +
+ {state === "exited" && ( + + + + + + + + {isReadOnly ? "Start (Read-only mode)" : "Start"} + + + )} + {state === "running" && ( + + + + + + + + {isReadOnly ? "Stop (Read-only mode)" : "Stop"} + + + )} + + + + + + + + {isReadOnly ? "Restart (Read-only mode)" : "Restart"} + + + + + + + + + + {isReadOnly ? "Delete (Read-only mode)" : "Delete"} + + + + + + + View Logs + + + + + + View Stats + +
+
+
+
+ ); + }; + + return ( +
+ + + + + 0 && + selectedIds.length === pageItems.length + } + onChange={onSelectAll} + aria-label="Select all containers on this page" + /> + + Name + Image + + State + + Uptime + Created + + CPU {statsInterval} + + + RAM {statsInterval} + + Command + + Actions + + + + + {isLoading ? ( + + +
+ + Loading containers… +
+
+
+ ) : isError ? ( + + +
+

+ {(error as Error)?.message || "Unable to load containers."} +

+ +
+
+
+ ) : filteredContainers.length === 0 ? ( + + +
+ No containers found. +
+
+
+ ) : groupBy === "compose" && groupedItems ? ( + groupedItems.map((group) => ( + + + + + + + {expandedGroups.includes(group.project) + ? group.items.map(renderContainerRow) + : null} + + )) + ) : ( + pageItems.map(renderContainerRow) + )} +
+
+
+ ); } diff --git a/frontend/src/features/containers/components/containers-toolbar.tsx b/frontend/src/features/containers/components/containers-toolbar.tsx index f0f35328..efcff46c 100644 --- a/frontend/src/features/containers/components/containers-toolbar.tsx +++ b/frontend/src/features/containers/components/containers-toolbar.tsx @@ -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 Date range; - } + const sortLabels: Record = { + 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 Date range; + } - 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 ( -
- onSearchChange(event.target.value)} - placeholder="Search containers..." - className="sm:max-w-sm" - /> -
- - - - - - - - All hosts - - {availableHosts.map((host) => ( - - {host.Name} - - ))} - - - + return dateRange.from.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + }; - - - - - - - - All states - - {availableStates.map((state) => ( - - {toTitleCase(state)} - - ))} - - - + return ( +
+ onSearchChange(event.target.value)} + placeholder="Search containers..." + className="sm:max-w-sm" + /> +
+ + + + + + + + All hosts + + {availableHosts.map((host) => ( + + {host.Name} + + ))} + + + - - - - - - - onSortDirectionChange(value as SortDirection) - } - > - - Newest first - - - Oldest first - - - - + + + + + + + + All states + + {availableStates.map((state) => ( + + {toTitleCase(state)} + + ))} + + + - - - - - - onGroupByChange(value as GroupByOption)} - > - - No grouping - - - By compose project - - - - + + + + + + onSortByChange(value as SortColumn)} + > + Name + State + + Uptime + + + Created + + CPU + RAM + + + - - - - - - - - + + + + + + + onSortDirectionChange(value as SortDirection) + } + > + + Descending + + + Ascending + + + + - + + + + + + onGroupByChange(value as GroupByOption)} + > + + No grouping + + + By compose project + + + + - - - - - Settings - +
+ + +
- {isAuthEnabled && ( - - - - - - Logout {user?.username ? `(${user.username})` : ""} - - - )} -
-
- ); + + + + + + + + + + + + + + + + Settings + + + {isAuthEnabled && ( + + + + + + Logout {user?.username ? `(${user.username})` : ""} + + + )} +
+
+ ); } diff --git a/frontend/src/features/containers/hooks/use-container-history.ts b/frontend/src/features/containers/hooks/use-container-history.ts new file mode 100644 index 00000000..9db2de73 --- /dev/null +++ b/frontend/src/features/containers/hooks/use-container-history.ts @@ -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, + }); +} diff --git a/frontend/src/features/containers/hooks/use-containers-dashboard-url-state.test.ts b/frontend/src/features/containers/hooks/use-containers-dashboard-url-state.test.ts new file mode 100644 index 00000000..bd1484fc --- /dev/null +++ b/frontend/src/features/containers/hooks/use-containers-dashboard-url-state.test.ts @@ -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); + }); +}); diff --git a/frontend/src/features/containers/hooks/use-containers-dashboard-url-state.ts b/frontend/src/features/containers/hooks/use-containers-dashboard-url-state.ts index c78df569..5841a64b 100644 --- a/frontend/src/features/containers/hooks/use-containers-dashboard-url-state.ts +++ b/frontend/src/features/containers/hooks/use-containers-dashboard-url-state.ts @@ -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, +) { + 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) => { + 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, + }; } diff --git a/frontend/src/features/containers/types.ts b/frontend/src/features/containers/types.ts index e16427cf..bcc9a3f8 100644 --- a/frontend/src/features/containers/types.ts +++ b/frontend/src/features/containers/types.ts @@ -14,12 +14,19 @@ export interface ContainerInfo { status: string labels?: Record 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 } diff --git a/frontend/src/features/settings/api/test-bot.ts b/frontend/src/features/settings/api/test-bot.ts new file mode 100644 index 00000000..b8e44fe8 --- /dev/null +++ b/frontend/src/features/settings/api/test-bot.ts @@ -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 }>; +} diff --git a/frontend/src/features/settings/api/update-bot.ts b/frontend/src/features/settings/api/update-bot.ts new file mode 100644 index 00000000..ebd68eab --- /dev/null +++ b/frontend/src/features/settings/api/update-bot.ts @@ -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 { + 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"; +} diff --git a/frontend/src/features/settings/components/bot-section.test.tsx b/frontend/src/features/settings/components/bot-section.test.tsx new file mode 100644 index 00000000..23873063 --- /dev/null +++ b/frontend/src/features/settings/components/bot-section.test.tsx @@ -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(); + + expect((screen.getByLabelText("Telegram token") as HTMLInputElement).value).toBe("••••••••"); + }); + + it("disables controls for mixed env-backed bot config", () => { + render(); + + expect((screen.getByLabelText("Telegram token") as HTMLInputElement).disabled).toBe(true); + expect(screen.queryByRole("button", { name: /save changes/i })).toBeNull(); + }); +}); diff --git a/frontend/src/features/settings/components/bot-section.tsx b/frontend/src/features/settings/components/bot-section.tsx new file mode 100644 index 00000000..8367ccf7 --- /dev/null +++ b/frontend/src/features/settings/components/bot-section.tsx @@ -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 ( + <> + + +
+ Telegram Bot + {isEnvBacked && } +
+ + Configure the Telegram bot for `/help`, `/status`, and `/critical` + commands. + +
+ +
+ + +
+ +
+
+ + +
+
+ + setTelegramToken(event.target.value)} + disabled={controlsDisabled} + type="password" + placeholder="123456:ABC..." + /> +
+
+ + setAllowedChatId(event.target.value)} + disabled={controlsDisabled} + placeholder="123456789" + /> +
+
+ + {mode === "jwt-relay" && ( +
+

JWT relay

+

+ Relay path:{" "} + {config.relayPath} +

+

+ Protected by existing auth:{" "} + {config.relayUsesAuth ? "yes" : "no"} +

+ {!authEnabled && ( +

+ Enable dashboard auth before using JWT relay mode. +

+ )} +
+ )} + + {!isEnvBacked && ( +
+ + +
+ )} +
+
+ + + +
+ Discord Bot + {isEnvBacked && } +
+ + Configure Discord slash commands for `/help`, `/status`, and + `/critical`. + +
+ +
+ + +
+ +
+
+ + setDiscordBotToken(event.target.value)} + disabled={controlsDisabled} + type="password" + placeholder="MTA..." + /> +
+
+ + setDiscordApplicationId(event.target.value)} + disabled={controlsDisabled} + placeholder="123456789012345678" + /> +
+
+ + setDiscordGuildId(event.target.value)} + disabled={controlsDisabled} + placeholder="Optional" + /> +
+
+ + + setDiscordAllowedChannelId(event.target.value) + } + disabled={controlsDisabled} + placeholder="123456789012345678" + /> +
+
+ +
+ Slash command responses are ephemeral. Set a guild ID to register + commands to one server immediately; leave it blank for global + command registration. +
+ + {!isEnvBacked && ( +
+ + +
+ )} +
+
+ + ); +} diff --git a/frontend/src/features/settings/components/settings-page.tsx b/frontend/src/features/settings/components/settings-page.tsx index c57914e0..982983af 100644 --- a/frontend/src/features/settings/components/settings-page.tsx +++ b/frontend/src/features/settings/components/settings-page.tsx @@ -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 ( -
- -
- ); - } + if (isLoading) { + return ( +
+ +
+ ); + } - if (error) { - return ( -
-

- Failed to load settings: {error.message} -

-
- ); - } + if (error) { + return ( +
+

+ Failed to load settings: {error.message} +

+
+ ); + } - if (!data) return null; + if (!data) return null; - return ( -
-
-

Settings

-

- Manage VPS Monitor configuration. Sections marked as set via environment - variable can only be changed by updating the environment and - restarting. -

-
- - - - - -
- ); + return ( +
+
+

Settings

+

+ Manage VPS Monitor configuration. Sections marked as set via + environment variable can only be changed by updating the environment + and restarting. +

+
+ + + + + + +
+ ); } diff --git a/frontend/src/features/settings/hooks/use-settings.ts b/frontend/src/features/settings/hooks/use-settings.ts index bc0aef8e..f23c3fa9 100644 --- a/frontend/src/features/settings/hooks/use-settings.ts +++ b/frontend/src/features/settings/hooks/use-settings.ts @@ -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), + }); } diff --git a/frontend/src/features/settings/types.ts b/frontend/src/features/settings/types.ts index 2d1ad08e..fc7c3e85 100644 --- a/frontend/src/features/settings/types.ts +++ b/frontend/src/features/settings/types.ts @@ -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; } diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index df6332ba..8934038f 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -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 ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/home/cmd/server/main.go b/home/cmd/server/main.go index dcc9d309..d8d245d0 100644 --- a/home/cmd/server/main.go +++ b/home/cmd/server/main.go @@ -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, } diff --git a/home/internal/alerts/monitor.go b/home/internal/alerts/monitor.go index f3897959..90b1cb0e 100644 --- a/home/internal/alerts/monitor.go +++ b/home/internal/alerts/monitor.go @@ -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 +} diff --git a/home/internal/alerts/monitor_test.go b/home/internal/alerts/monitor_test.go new file mode 100644 index 00000000..4882fa02 --- /dev/null +++ b/home/internal/alerts/monitor_test.go @@ -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") + } +} diff --git a/home/internal/api/bot_handlers.go b/home/internal/api/bot_handlers.go new file mode 100644 index 00000000..3e95355b --- /dev/null +++ b/home/internal/api/bot_handlers.go @@ -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" + } +} diff --git a/home/internal/api/bot_handlers_test.go b/home/internal/api/bot_handlers_test.go new file mode 100644 index 00000000..0bb973dc --- /dev/null +++ b/home/internal/api/bot_handlers_test.go @@ -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, + }) +} diff --git a/home/internal/api/container_handlers_test.go b/home/internal/api/container_handlers_test.go new file mode 100644 index 00000000..4cb00d00 --- /dev/null +++ b/home/internal/api/container_handlers_test.go @@ -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)) +} diff --git a/home/internal/api/handlers.go b/home/internal/api/handlers.go index c451973b..6bab29be 100644 --- a/home/internal/api/handlers.go +++ b/home/internal/api/handlers.go @@ -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) { diff --git a/home/internal/api/router.go b/home/internal/api/router.go index d718836e..8b283048 100644 --- a/home/internal/api/router.go +++ b/home/internal/api/router.go @@ -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) { diff --git a/home/internal/api/settings_handlers.go b/home/internal/api/settings_handlers.go index c3deab61..fa3f5ada 100644 --- a/home/internal/api/settings_handlers.go +++ b/home/internal/api/settings_handlers.go @@ -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 { diff --git a/home/internal/api/settings_handlers_test.go b/home/internal/api/settings_handlers_test.go index 4fe1c45a..49ab19fe 100644 --- a/home/internal/api/settings_handlers_test.go +++ b/home/internal/api/settings_handlers_test.go @@ -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() +} diff --git a/home/internal/bot/command.go b/home/internal/bot/command.go new file mode 100644 index 00000000..c12bbcd7 --- /dev/null +++ b/home/internal/bot/command.go @@ -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 +} diff --git a/home/internal/bot/command_test.go b/home/internal/bot/command_test.go new file mode 100644 index 00000000..8daf2de5 --- /dev/null +++ b/home/internal/bot/command_test.go @@ -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) + } +} diff --git a/home/internal/bot/discord.go b/home/internal/bot/discord.go new file mode 100644 index 00000000..caa1a33d --- /dev/null +++ b/home/internal/bot/discord.go @@ -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) != "" +} diff --git a/home/internal/bot/telegram.go b/home/internal/bot/telegram.go new file mode 100644 index 00000000..00d27b55 --- /dev/null +++ b/home/internal/bot/telegram.go @@ -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, "***")) +} diff --git a/home/internal/bot/telegram_test.go b/home/internal/bot/telegram_test.go new file mode 100644 index 00000000..f62b3ff1 --- /dev/null +++ b/home/internal/bot/telegram_test.go @@ -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) + } +} diff --git a/home/internal/config/config.go b/home/internal/config/config.go index 389f124d..1e1da33e 100644 --- a/home/internal/config/config.go +++ b/home/internal/config/config.go @@ -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") diff --git a/home/internal/config/manager.go b/home/internal/config/manager.go index 019b79bd..6d17f0fb 100644 --- a/home/internal/config/manager.go +++ b/home/internal/config/manager.go @@ -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 { diff --git a/home/internal/config/manager_test.go b/home/internal/config/manager_test.go index a6c34f31..d781ff62 100644 --- a/home/internal/config/manager_test.go +++ b/home/internal/config/manager_test.go @@ -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) { } }) } -} \ No newline at end of file +} + +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) + } +} diff --git a/home/internal/containerstats/collector.go b/home/internal/containerstats/collector.go new file mode 100644 index 00000000..af1c8fcd --- /dev/null +++ b/home/internal/containerstats/collector.go @@ -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() + } + } +} diff --git a/home/internal/models/alert.go b/home/internal/models/alert.go index d729b9bd..687bdc31 100644 --- a/home/internal/models/alert.go +++ b/home/internal/models/alert.go @@ -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"` } diff --git a/home/internal/models/container.go b/home/internal/models/container.go index 24041b91..e818bbfa 100644 --- a/home/internal/models/container.go +++ b/home/internal/models/container.go @@ -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 diff --git a/home/internal/models/stats.go b/home/internal/models/stats.go index e201bc10..a152dcc3 100644 --- a/home/internal/models/stats.go +++ b/home/internal/models/stats.go @@ -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"` +} diff --git a/home/internal/scanner/db.go b/home/internal/scanner/db.go index b35dc5d7..787df681 100644 --- a/home/internal/scanner/db.go +++ b/home/internal/scanner/db.go @@ -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. diff --git a/home/internal/scanner/db_container_stats_test.go b/home/internal/scanner/db_container_stats_test.go new file mode 100644 index 00000000..19a10af9 --- /dev/null +++ b/home/internal/scanner/db_container_stats_test.go @@ -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]) + } +} diff --git a/home/internal/services/registry.go b/home/internal/services/registry.go index 9172a5be..7ac7b199 100644 --- a/home/internal/services/registry.go +++ b/home/internal/services/registry.go @@ -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 +} diff --git a/home/internal/stats/history.go b/home/internal/stats/history.go new file mode 100644 index 00000000..26c62c5b --- /dev/null +++ b/home/internal/stats/history.go @@ -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)) +} diff --git a/home/internal/stats/history_test.go b/home/internal/stats/history_test.go new file mode 100644 index 00000000..54e7aad5 --- /dev/null +++ b/home/internal/stats/history_test.go @@ -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) + } +}