mirror of
https://github.com/hhftechnology/vps-monitor.git
synced 2026-04-28 03:29:55 +00:00
Merge pull request #18 from hhftechnology/dev
Add bot service, persistent container stats, and enhanced charts UI
This commit is contained in:
commit
bc16708f96
56 changed files with 7391 additions and 2077 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "vps-monitor",
|
||||
"version": "2.0.0",
|
||||
"version": "2.3.1",
|
||||
"private": true,
|
||||
"description": "VPS Monitor",
|
||||
"author": "HHF Technology <https://github.com/hhftechnology>",
|
||||
|
|
|
|||
|
|
@ -13,18 +13,6 @@ export function Footer() {
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<svg className="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 330 330" fill="none">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M68 290.485c-5.5 9.6-17.8 12.8-27.3 7.3-9.6-5.5-12.8-17.8-7.3-27.3l14.3-24.7q24.15-7.35 39.6 11.4zm138.9-53.9H25c-11 0-20-9-20-20s9-20 20-20h51l65.4-113.2-20.5-35.4c-5.5-9.6-2.2-21.8 7.3-27.3 9.6-5.5 21.8-2.2 27.3 7.3l8.9 15.4 8.9-15.4c5.5-9.6 17.8-12.8 27.3-7.3 9.6 5.5 12.8 17.8 7.3 27.3l-85.8 148.6h62.1c20.2 0 31.5 23.7 22.7 40m98.1 0h-29l19.6 33.9c5.5 9.6 2.2 21.8-7.3 27.3-9.6 5.5-21.8 2.2-27.3-7.3-32.9-56.9-57.5-99.7-74-128.1-16.7-29-4.8-58 7.1-67.8 13.1 22.7 32.7 56.7 58.9 102h52c11 0 20 9 20 20 0 11.1-9 20-20 20"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Google Play</span>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<svg className="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
|
|
@ -33,7 +21,7 @@ export function Footer() {
|
|||
d="M3.739.505c.519-.038 1.024.15 1.55.422.53.276 1.179.692 1.996 1.216l5.433 3.483c.47.301.86.55 1.148.774.289.225.55.48.696.822.21.497.21 1.058 0 1.555-.145.343-.407.598-.696.823s-.678.473-1.148.774l-5.433 3.483c-.817.524-1.466.94-1.996 1.216-.526.272-1.031.46-1.55.422a2.68 2.68 0 0 1-1.955-1.069 2 2 0 0 1-.276-.515c-.143-.387-.202-.85-.23-1.376-.029-.53-.028-1.186-.028-1.978V5.443c0-1.336-.005-2.322.156-3.011.074-.315.189-.605.378-.858A2.68 2.68 0 0 1 3.74.504M2.879 13.8c.247.265.585.428.95.454.165.012.415-.04.887-.286.468-.242 1.06-.622 1.898-1.158l3.13-2.007L7.883 9.03zm5.904-5.63 2.038 1.942 1.226-.785c.49-.315.822-.528 1.056-.709.232-.181.294-.277.314-.325a.75.75 0 0 0 0-.588c-.02-.049-.081-.144-.314-.325-.234-.181-.565-.395-1.056-.71l-1.011-.648zM3.83 1.745c-.417.03-.8.239-1.05.573a1 1 0 0 0-.05.08l5.154 4.915 2.076-1.98L6.614 3.19c-.838-.536-1.43-.916-1.898-1.158-.472-.245-.722-.298-.887-.286m-1.336 8.812c0 .783 0 1.388.025 1.871l4.465-4.257-4.477-4.269c-.011.413-.013.918-.013 1.54z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Apple App Store</span>
|
||||
<span className="sr-only">Google Play</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/hhftechnology/vps-monitor"
|
||||
|
|
|
|||
169
frontend/src/components/ui/chart.tsx
Normal file
169
frontend/src/components/ui/chart.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode;
|
||||
color?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
type ChartContextValue = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextValue | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Chart components must be used within a ChartContainer.");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
interface ChartContainerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
config: ChartConfig;
|
||||
children: React.ReactElement;
|
||||
}
|
||||
|
||||
export function ChartContainer({
|
||||
config,
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: ChartContainerProps) {
|
||||
const chartStyle = React.useMemo(() => {
|
||||
const entries = Object.entries(config).map(([key, value]) => [
|
||||
`--color-${key}`,
|
||||
value.color ?? "currentColor",
|
||||
]);
|
||||
|
||||
return Object.fromEntries(entries) as React.CSSProperties;
|
||||
}, [config]);
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/60 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none",
|
||||
className,
|
||||
)}
|
||||
style={{ ...chartStyle, ...style }}
|
||||
{...props}
|
||||
>
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
type TooltipPayloadItem = {
|
||||
color?: string;
|
||||
dataKey?: string | number;
|
||||
name?: string | number;
|
||||
payload?: Record<string, unknown>;
|
||||
value?: number | string;
|
||||
};
|
||||
|
||||
interface ChartTooltipContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
active?: boolean;
|
||||
payload?: TooltipPayloadItem[];
|
||||
label?: string | number;
|
||||
hideLabel?: boolean;
|
||||
formatter?: (
|
||||
value: number | string,
|
||||
name: string,
|
||||
item: TooltipPayloadItem,
|
||||
) => React.ReactNode | [React.ReactNode, React.ReactNode];
|
||||
}
|
||||
|
||||
export const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ChartTooltipContentProps
|
||||
>(function ChartTooltipContent(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
className,
|
||||
hideLabel = false,
|
||||
formatter,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[11rem] gap-2 rounded-lg border bg-card px-3 py-2 text-xs shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{!hideLabel && label !== undefined && (
|
||||
<div className="font-medium text-foreground">{label}</div>
|
||||
)}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item) => {
|
||||
const dataKey = String(item.dataKey ?? item.name ?? "");
|
||||
const itemConfig = config[dataKey];
|
||||
const itemLabel = String(itemConfig?.label ?? item.name ?? dataKey);
|
||||
const formatted = formatter?.(
|
||||
item.value ?? "",
|
||||
itemLabel,
|
||||
item,
|
||||
);
|
||||
|
||||
let valueNode: React.ReactNode = item.value ?? "—";
|
||||
let labelNode: React.ReactNode = itemLabel;
|
||||
|
||||
if (Array.isArray(formatted)) {
|
||||
valueNode = formatted[0];
|
||||
labelNode = formatted[1];
|
||||
} else if (formatted !== undefined) {
|
||||
valueNode = formatted;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${dataKey}-${itemLabel}`}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
item.color ?? itemConfig?.color ?? `var(--color-${dataKey})`,
|
||||
}}
|
||||
/>
|
||||
<span className="truncate text-muted-foreground">{labelNode}</span>
|
||||
</div>
|
||||
<span className="font-medium text-foreground">{valueNode}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { restartContainer } from "./container-actions";
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
describe("container-actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
storage.clear();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("marks 202 responses as pending", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
message: "Container restart initiated",
|
||||
status: "pending",
|
||||
}),
|
||||
{
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await restartContainer("abc123", "host-a");
|
||||
expect(result).toEqual({
|
||||
message: "Container restart initiated",
|
||||
isPending: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -6,48 +6,54 @@ const BASE_URL = `${API_BASE_URL}/api/v1/containers`;
|
|||
type ContainerAction = "start" | "stop" | "restart" | "remove";
|
||||
|
||||
interface ActionResponse {
|
||||
message?: string;
|
||||
message?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ActionResult {
|
||||
message: string;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
async function performContainerAction(
|
||||
id: string,
|
||||
action: ContainerAction,
|
||||
host: string
|
||||
): Promise<string> {
|
||||
const endpoint = `${BASE_URL}/${encodeURIComponent(id)}/${action}?host=${encodeURIComponent(host)}`;
|
||||
const response = await authenticatedFetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
id: string,
|
||||
action: ContainerAction,
|
||||
host: string,
|
||||
): Promise<ActionResult> {
|
||||
const endpoint = `${BASE_URL}/${encodeURIComponent(id)}/${action}?host=${encodeURIComponent(host)}`;
|
||||
const response = await authenticatedFetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `Failed to ${action} container`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `Failed to ${action} container`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ActionResponse | undefined;
|
||||
const isPending = response.status === 202;
|
||||
const data = (await response.json()) as ActionResponse | undefined;
|
||||
|
||||
if (data && typeof data.message === "string") {
|
||||
return data.message;
|
||||
}
|
||||
|
||||
return "Action completed successfully";
|
||||
return {
|
||||
message: data?.message || "Action completed successfully",
|
||||
isPending,
|
||||
};
|
||||
}
|
||||
|
||||
export function startContainer(id: string, host: string) {
|
||||
return performContainerAction(id, "start", host);
|
||||
return performContainerAction(id, "start", host);
|
||||
}
|
||||
|
||||
export function stopContainer(id: string, host: string) {
|
||||
return performContainerAction(id, "stop", host);
|
||||
return performContainerAction(id, "stop", host);
|
||||
}
|
||||
|
||||
export function restartContainer(id: string, host: string) {
|
||||
return performContainerAction(id, "restart", host);
|
||||
return performContainerAction(id, "restart", host);
|
||||
}
|
||||
|
||||
export function removeContainer(id: string, host: string) {
|
||||
return performContainerAction(id, "remove", host);
|
||||
return performContainerAction(id, "remove", host);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import { authenticatedFetch } from "@/lib/api-client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getContainerHistory } from "./get-container-history";
|
||||
|
||||
vi.mock("@/lib/api-client", () => ({
|
||||
authenticatedFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = authenticatedFetch as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe("getContainerHistory", () => {
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("normalizes missing samples to an empty array", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
cpu_1h: 1,
|
||||
memory_1h: 2,
|
||||
cpu_12h: 3,
|
||||
memory_12h: 4,
|
||||
has_data: true,
|
||||
}),
|
||||
} as unknown as Response);
|
||||
|
||||
const history = await getContainerHistory("container-1", "local");
|
||||
|
||||
expect(history.samples).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { authenticatedFetch } from "@/lib/api-client";
|
||||
import { API_BASE_URL } from "@/types/api";
|
||||
|
||||
import type { ContainerInfo } from "../types";
|
||||
import type { ContainerStats } from "../types/stats";
|
||||
|
||||
export interface ContainerHistoryStats
|
||||
extends NonNullable<ContainerInfo["historical_stats"]> {
|
||||
has_data: boolean;
|
||||
samples: ContainerStats[];
|
||||
}
|
||||
|
||||
export async function getContainerHistory(
|
||||
id: string,
|
||||
host: string,
|
||||
): Promise<ContainerHistoryStats> {
|
||||
const endpoint = `${API_BASE_URL}/api/v1/containers/${encodeURIComponent(id)}/stats/history?host=${encodeURIComponent(host)}`;
|
||||
const response = await authenticatedFetch(endpoint);
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ContainerHistoryStats;
|
||||
return {
|
||||
...data,
|
||||
samples: data.samples ?? [],
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ContainerDetailsSheet } from "./container-details-sheet";
|
||||
|
||||
const mockUseContainerStats = vi.fn();
|
||||
const mockUseContainerHistory = vi.fn();
|
||||
|
||||
vi.mock("../hooks/use-container-stats", () => ({
|
||||
useContainerStats: (...args: unknown[]) => mockUseContainerStats(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-container-history", () => ({
|
||||
useContainerHistory: (...args: unknown[]) => mockUseContainerHistory(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/tabs", async () => {
|
||||
const React = await import("react");
|
||||
const TabsContext = React.createContext<{ value: string; onValueChange: (v: string) => void }>({ value: "", onValueChange: () => {} });
|
||||
|
||||
return {
|
||||
Tabs: ({ value, defaultValue, onValueChange, children }: any) => {
|
||||
const [v, setV] = React.useState(value || defaultValue);
|
||||
const handleChange = (newV: string) => {
|
||||
setV(newV);
|
||||
if (onValueChange) onValueChange(newV);
|
||||
};
|
||||
React.useEffect(() => { if (value !== undefined) setV(value); }, [value]);
|
||||
return <TabsContext.Provider value={{ value: v, onValueChange: handleChange }}><div>{children}</div></TabsContext.Provider>;
|
||||
},
|
||||
TabsContent: ({ value, children }: any) => {
|
||||
const context = React.useContext(TabsContext);
|
||||
if (context.value !== value) return null;
|
||||
return <div>{children}</div>;
|
||||
},
|
||||
TabsList: ({ children }: any) => <div>{children}</div>,
|
||||
TabsTrigger: ({ value, children, disabled, ...props }: any) => {
|
||||
const context = React.useContext(TabsContext);
|
||||
return <button type="button" disabled={disabled} onClick={() => context.onValueChange(value)} {...props}>{children}</button>;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./environment-variables", () => ({
|
||||
EnvironmentVariables: ({
|
||||
onContainerIdChange,
|
||||
}: {
|
||||
onContainerIdChange: (containerId: string) => void;
|
||||
}) => (
|
||||
<button type="button" onClick={() => onContainerIdChange("container-2")}>
|
||||
Update container id
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("recharts", async () => {
|
||||
const actual = await vi.importActual<typeof import("recharts")>("recharts");
|
||||
|
||||
return {
|
||||
...actual,
|
||||
ResponsiveContainer: ({ children }: { children?: ReactNode }) => (
|
||||
<div style={{ width: 960, height: 320 }}>{children}</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
class ResizeObserverMock {
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
|
||||
Object.defineProperty(HTMLElement.prototype, "clientWidth", {
|
||||
configurable: true,
|
||||
value: 960,
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 320,
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContainerDetailsSheet", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockUseContainerStats.mockReturnValue({
|
||||
stats: null,
|
||||
history: [],
|
||||
isConnected: false,
|
||||
error: null,
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
clearHistory: vi.fn(),
|
||||
});
|
||||
|
||||
mockUseContainerHistory.mockReturnValue({
|
||||
data: {
|
||||
cpu_1h: 12,
|
||||
memory_1h: 34,
|
||||
cpu_12h: 18,
|
||||
memory_12h: 40,
|
||||
has_data: true,
|
||||
samples: [
|
||||
{
|
||||
container_id: "container-1",
|
||||
host: "local",
|
||||
cpu_percent: 10,
|
||||
memory_usage: 512,
|
||||
memory_limit: 1024,
|
||||
memory_percent: 50,
|
||||
network_rx: 100,
|
||||
network_tx: 80,
|
||||
block_read: 20,
|
||||
block_write: 10,
|
||||
pids: 4,
|
||||
timestamp: 1_700_000_000,
|
||||
},
|
||||
{
|
||||
container_id: "container-1",
|
||||
host: "local",
|
||||
cpu_percent: 14,
|
||||
memory_usage: 600,
|
||||
memory_limit: 1024,
|
||||
memory_percent: 58,
|
||||
network_rx: 120,
|
||||
network_tx: 95,
|
||||
block_read: 25,
|
||||
block_write: 12,
|
||||
pids: 5,
|
||||
timestamp: 1_700_000_030,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders stats charts from persisted samples before live history arrives", () => {
|
||||
render(
|
||||
<ContainerDetailsSheet
|
||||
container={{
|
||||
id: "container-1",
|
||||
names: ["/api"],
|
||||
image: "ghcr.io/example/api:latest",
|
||||
image_id: "sha256:123",
|
||||
command: "node server.js",
|
||||
created: 1_700_000_000,
|
||||
state: "running",
|
||||
status: "Up 2 hours",
|
||||
host: "local",
|
||||
historical_stats: {
|
||||
cpu_1h: 12,
|
||||
memory_1h: 34,
|
||||
cpu_12h: 18,
|
||||
memory_12h: 40,
|
||||
},
|
||||
}}
|
||||
host="local"
|
||||
isOpen
|
||||
onOpenChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("CPU Usage (%)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Memory Usage (%)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Network I/O")).toBeInTheDocument();
|
||||
expect(screen.getByText("Block I/O")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps hooks stable with a null container and respects child container id updates", async () => {
|
||||
const { rerender } = render(
|
||||
<ContainerDetailsSheet
|
||||
container={null}
|
||||
host="local"
|
||||
isOpen
|
||||
onOpenChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<ContainerDetailsSheet
|
||||
container={{
|
||||
id: "container-1",
|
||||
names: ["/api"],
|
||||
image: "ghcr.io/example/api:latest",
|
||||
image_id: "sha256:123",
|
||||
command: "node server.js",
|
||||
created: 1_700_000_000,
|
||||
state: "running",
|
||||
status: "Up 2 hours",
|
||||
host: "local",
|
||||
}}
|
||||
host="local"
|
||||
isOpen
|
||||
onOpenChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: /env vars/i }),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: /update container id/i }),
|
||||
);
|
||||
|
||||
expect(mockUseContainerStats).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ containerId: "container-2" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,336 +1,409 @@
|
|||
import {
|
||||
ActivityIcon,
|
||||
CpuIcon,
|
||||
HardDriveIcon,
|
||||
MemoryStickIcon,
|
||||
NetworkIcon,
|
||||
PlayIcon,
|
||||
SettingsIcon,
|
||||
SquareIcon,
|
||||
TerminalIcon,
|
||||
ActivityIcon,
|
||||
CpuIcon,
|
||||
HardDriveIcon,
|
||||
MemoryStickIcon,
|
||||
NetworkIcon,
|
||||
PlayIcon,
|
||||
SettingsIcon,
|
||||
SquareIcon,
|
||||
TerminalIcon,
|
||||
} from "lucide-react";
|
||||
import { lazy, Suspense, useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
import { useContainerHistory } from "../hooks/use-container-history";
|
||||
import { useContainerStats } from "../hooks/use-container-stats";
|
||||
import type { ContainerInfo } from "../types";
|
||||
import { ContainerStatsCharts } from "./container-stats-charts";
|
||||
import { EnvironmentVariables } from "./environment-variables";
|
||||
|
||||
import type { ContainerInfo } from "../types";
|
||||
|
||||
// Lazy load terminal to reduce initial bundle size
|
||||
const Terminal = lazy(() =>
|
||||
import("./terminal").then((module) => ({ default: module.Terminal }))
|
||||
import("./terminal").then((module) => ({ default: module.Terminal })),
|
||||
);
|
||||
|
||||
interface ContainerDetailsSheetProps {
|
||||
container: ContainerInfo | null;
|
||||
host: string;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
isReadOnly?: boolean;
|
||||
container: ContainerInfo | null;
|
||||
host: string;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${value.toFixed(1)}%`;
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function ContainerDetailsSheet({
|
||||
container,
|
||||
host,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
isReadOnly = false,
|
||||
container,
|
||||
host,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
isReadOnly = false,
|
||||
}: ContainerDetailsSheetProps) {
|
||||
const [activeTab, setActiveTab] = useState("stats");
|
||||
const [containerId, setContainerId] = useState(container?.id ?? "");
|
||||
const [activeTab, setActiveTab] = useState("stats");
|
||||
const [containerId, setContainerId] = useState(container?.id ?? "");
|
||||
|
||||
// Update containerId when container changes
|
||||
const effectiveContainerId = container?.id ?? containerId;
|
||||
useEffect(() => {
|
||||
setContainerId(container?.id ?? "");
|
||||
}, [container?.id]);
|
||||
|
||||
const {
|
||||
stats,
|
||||
history,
|
||||
isConnected,
|
||||
error,
|
||||
connect,
|
||||
disconnect,
|
||||
clearHistory,
|
||||
} = useContainerStats({
|
||||
containerId: effectiveContainerId,
|
||||
host,
|
||||
enabled: isOpen && activeTab === "stats",
|
||||
});
|
||||
const effectiveContainerId = containerId || container?.id || "";
|
||||
|
||||
const handleContainerIdChange = useCallback((newId: string) => {
|
||||
setContainerId(newId);
|
||||
}, []);
|
||||
const {
|
||||
stats,
|
||||
history,
|
||||
isConnected,
|
||||
error,
|
||||
connect,
|
||||
disconnect,
|
||||
clearHistory,
|
||||
} = useContainerStats({
|
||||
containerId: effectiveContainerId,
|
||||
host,
|
||||
enabled: isOpen && activeTab === "stats",
|
||||
});
|
||||
const { data: persistedHistory } = useContainerHistory(
|
||||
effectiveContainerId,
|
||||
host,
|
||||
isOpen && activeTab === "stats",
|
||||
);
|
||||
|
||||
const handleToggleStats = useCallback(() => {
|
||||
if (isConnected) {
|
||||
disconnect();
|
||||
} else {
|
||||
clearHistory();
|
||||
connect();
|
||||
}
|
||||
}, [isConnected, disconnect, clearHistory, connect]);
|
||||
const handleContainerIdChange = useCallback((newId: string) => {
|
||||
setContainerId(newId);
|
||||
}, []);
|
||||
|
||||
const statsCards = useMemo(() => {
|
||||
if (!stats) return null;
|
||||
const handleToggleStats = useCallback(() => {
|
||||
if (isConnected) {
|
||||
disconnect();
|
||||
} else {
|
||||
clearHistory();
|
||||
connect();
|
||||
}
|
||||
}, [isConnected, disconnect, clearHistory, connect]);
|
||||
|
||||
return [
|
||||
{
|
||||
label: "CPU",
|
||||
value: formatPercent(stats.cpu_percent),
|
||||
icon: CpuIcon,
|
||||
color: stats.cpu_percent > 80 ? "text-red-500" : "text-primary",
|
||||
},
|
||||
{
|
||||
label: "Memory",
|
||||
value: `${formatBytes(stats.memory_usage)} / ${formatBytes(stats.memory_limit)}`,
|
||||
subValue: formatPercent(stats.memory_percent),
|
||||
icon: MemoryStickIcon,
|
||||
color: stats.memory_percent > 80 ? "text-red-500" : "text-primary",
|
||||
},
|
||||
{
|
||||
label: "Network I/O",
|
||||
value: `${formatBytes(stats.network_rx)} / ${formatBytes(stats.network_tx)}`,
|
||||
subLabel: "RX / TX",
|
||||
icon: NetworkIcon,
|
||||
color: "text-primary",
|
||||
},
|
||||
{
|
||||
label: "Block I/O",
|
||||
value: `${formatBytes(stats.block_read)} / ${formatBytes(stats.block_write)}`,
|
||||
subLabel: "Read / Write",
|
||||
icon: HardDriveIcon,
|
||||
color: "text-primary",
|
||||
},
|
||||
{
|
||||
label: "PIDs",
|
||||
value: stats.pids.toString(),
|
||||
icon: ActivityIcon,
|
||||
color: "text-primary",
|
||||
},
|
||||
];
|
||||
}, [stats]);
|
||||
const statsCards = useMemo(() => {
|
||||
if (!stats) return null;
|
||||
|
||||
if (!container) return null;
|
||||
return [
|
||||
{
|
||||
label: "CPU",
|
||||
value: formatPercent(stats.cpu_percent),
|
||||
icon: CpuIcon,
|
||||
color: stats.cpu_percent > 80 ? "text-red-500" : "text-primary",
|
||||
},
|
||||
{
|
||||
label: "Memory",
|
||||
value: `${formatBytes(stats.memory_usage)} / ${formatBytes(stats.memory_limit)}`,
|
||||
subValue: formatPercent(stats.memory_percent),
|
||||
icon: MemoryStickIcon,
|
||||
color: stats.memory_percent > 80 ? "text-red-500" : "text-primary",
|
||||
},
|
||||
{
|
||||
label: "Network I/O",
|
||||
value: `${formatBytes(stats.network_rx)} / ${formatBytes(stats.network_tx)}`,
|
||||
subLabel: "RX / TX",
|
||||
icon: NetworkIcon,
|
||||
color: "text-primary",
|
||||
},
|
||||
{
|
||||
label: "Block I/O",
|
||||
value: `${formatBytes(stats.block_read)} / ${formatBytes(stats.block_write)}`,
|
||||
subLabel: "Read / Write",
|
||||
icon: HardDriveIcon,
|
||||
color: "text-primary",
|
||||
},
|
||||
{
|
||||
label: "PIDs",
|
||||
value: stats.pids.toString(),
|
||||
icon: ActivityIcon,
|
||||
color: "text-primary",
|
||||
},
|
||||
];
|
||||
}, [stats]);
|
||||
|
||||
const containerName = container.names?.[0]?.replace(/^\//, "") || container.id.slice(0, 12);
|
||||
const isRunning = container.state.toLowerCase() === "running";
|
||||
const chartHistory = useMemo(() => {
|
||||
const persistedSamples = persistedHistory?.samples ?? [];
|
||||
if (persistedSamples.length === 0) {
|
||||
return history;
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="sm:max-w-2xl w-full overflow-y-auto p-0">
|
||||
<SheetHeader className="px-6 pt-6">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
{containerName}
|
||||
<Badge
|
||||
variant={isRunning ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{container.state}
|
||||
</Badge>
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-xs font-mono truncate">
|
||||
{container.image}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
const merged = new Map<number, (typeof persistedSamples)[number]>();
|
||||
for (const sample of persistedSamples) {
|
||||
merged.set(sample.timestamp, sample);
|
||||
}
|
||||
for (const sample of history) {
|
||||
merged.set(sample.timestamp, sample);
|
||||
}
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex-1 flex flex-col px-6 pb-6"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="stats" className="flex items-center gap-2">
|
||||
<ActivityIcon className="size-4" />
|
||||
Stats
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="terminal"
|
||||
className="flex items-center gap-2"
|
||||
disabled={!isRunning}
|
||||
>
|
||||
<TerminalIcon className="size-4" />
|
||||
Terminal
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="env" className="flex items-center gap-2">
|
||||
<SettingsIcon className="size-4" />
|
||||
Env Vars
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
return Array.from(merged.values())
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
.slice(-60);
|
||||
}, [history, persistedHistory?.samples]);
|
||||
|
||||
<TabsContent value="stats" className="space-y-6 mt-4">
|
||||
{/* Stats Controls */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={isConnected ? "default" : "secondary"}>
|
||||
{isConnected ? "Live" : "Disconnected"}
|
||||
</Badge>
|
||||
{history.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{history.length} data points
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={isConnected ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleToggleStats}
|
||||
disabled={!isRunning}
|
||||
>
|
||||
{isConnected ? (
|
||||
<>
|
||||
<SquareIcon className="mr-2 size-4" />
|
||||
Stop
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlayIcon className="mr-2 size-4" />
|
||||
Start
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{!isRunning
|
||||
? "Container must be running"
|
||||
: isConnected
|
||||
? "Stop streaming stats"
|
||||
: "Start streaming stats"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
if (!container) return null;
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
const containerName =
|
||||
container.names?.[0]?.replace(/^\//, "") || container.id.slice(0, 12);
|
||||
const isRunning = container.state.toLowerCase() === "running";
|
||||
const historyStats = container.historical_stats;
|
||||
|
||||
{!isRunning && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground text-sm">
|
||||
Container is not running. Start the container to view stats.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full overflow-y-auto p-0 sm:max-w-4xl">
|
||||
<SheetHeader className="px-6 pt-6">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
{containerName}
|
||||
<Badge
|
||||
variant={isRunning ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{container.state}
|
||||
</Badge>
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-xs font-mono truncate">
|
||||
{container.image}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{isRunning && isConnected && !stats && (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Spinner className="mr-2 size-4" />
|
||||
Connecting...
|
||||
</div>
|
||||
)}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-1 flex-col px-6 pb-6"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="stats" className="flex items-center gap-2">
|
||||
<ActivityIcon className="size-4" />
|
||||
Stats
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="terminal"
|
||||
className="flex items-center gap-2"
|
||||
disabled={!isRunning}
|
||||
>
|
||||
<TerminalIcon className="size-4" />
|
||||
Terminal
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="env" className="flex items-center gap-2">
|
||||
<SettingsIcon className="size-4" />
|
||||
Env Vars
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{isRunning && !isConnected && !stats && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground text-sm">
|
||||
Click "Start" to stream real-time container stats
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<TabsContent value="stats" className="mt-4 flex flex-col gap-6">
|
||||
{historyStats && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-3 text-sm">
|
||||
1h CPU
|
||||
<div className="font-semibold">
|
||||
{historyStats.cpu_1h != null ? `${historyStats.cpu_1h.toFixed(1)}%` : "N/A"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-3 text-sm">
|
||||
1h RAM
|
||||
<div className="font-semibold">
|
||||
{historyStats.memory_1h != null ? `${historyStats.memory_1h.toFixed(1)}%` : "N/A"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-3 text-sm">
|
||||
12h CPU
|
||||
<div className="font-semibold">
|
||||
{historyStats.cpu_12h != null ? `${historyStats.cpu_12h.toFixed(1)}%` : "N/A"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-3 text-sm">
|
||||
12h RAM
|
||||
<div className="font-semibold">
|
||||
{historyStats.memory_12h != null ? `${historyStats.memory_12h.toFixed(1)}%` : "N/A"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
{/* Stats Controls */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={isConnected ? "default" : "secondary"}>
|
||||
{isConnected ? "Live" : "Disconnected"}
|
||||
</Badge>
|
||||
{history.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{history.length} data points
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={isConnected ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleToggleStats}
|
||||
disabled={!isRunning}
|
||||
>
|
||||
{isConnected ? (
|
||||
<>
|
||||
<SquareIcon className="mr-2 size-4" />
|
||||
Stop
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlayIcon className="mr-2 size-4" />
|
||||
Start
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{!isRunning
|
||||
? "Container must be running"
|
||||
: isConnected
|
||||
? "Stop streaming stats"
|
||||
: "Start streaming stats"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Live Stats Cards */}
|
||||
{statsCards && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{statsCards.map((card) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<Card key={card.label} className="overflow-hidden">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex flex-col items-center text-center gap-2">
|
||||
<div className={`p-2 rounded-lg bg-muted ${card.color}`}>
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs text-muted-foreground font-medium">
|
||||
{card.label}
|
||||
</p>
|
||||
<p className="text-sm font-semibold truncate max-w-full">
|
||||
{card.value}
|
||||
</p>
|
||||
{card.subValue && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{card.subValue}
|
||||
</p>
|
||||
)}
|
||||
{card.subLabel && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{card.subLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{/* Stats Charts */}
|
||||
<ContainerStatsCharts history={history} />
|
||||
</TabsContent>
|
||||
{!isRunning && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground text-sm">
|
||||
Container is not running. Start the container to view stats.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<TabsContent value="terminal" className="min-h-[400px] mt-4">
|
||||
{isRunning ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-[400px] text-muted-foreground">
|
||||
<Spinner className="mr-2 size-4" />
|
||||
Loading terminal...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Terminal containerId={effectiveContainerId} host={host} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground text-sm">
|
||||
Container is not running. Start the container to access terminal.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
{isRunning && isConnected && !stats && (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Spinner className="mr-2 size-4" />
|
||||
Connecting...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TabsContent value="env" className="mt-4">
|
||||
<EnvironmentVariables
|
||||
containerId={effectiveContainerId}
|
||||
containerHost={host}
|
||||
isReadOnly={isReadOnly}
|
||||
onContainerIdChange={handleContainerIdChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
{isRunning && !isConnected && !stats && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground text-sm">
|
||||
Click "Start" to stream real-time container stats
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Live Stats Cards */}
|
||||
{statsCards && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{statsCards.map((card) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<Card key={card.label} className="overflow-hidden">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<div
|
||||
className={`p-2 rounded-lg bg-muted ${card.color}`}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground font-medium">
|
||||
{card.label}
|
||||
</p>
|
||||
<p className="break-words text-sm font-semibold leading-tight">
|
||||
{card.value}
|
||||
</p>
|
||||
{card.subValue && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{card.subValue}
|
||||
</p>
|
||||
)}
|
||||
{card.subLabel && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{card.subLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Charts */}
|
||||
<ContainerStatsCharts history={chartHistory} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="terminal" className="min-h-[400px] mt-4">
|
||||
{isRunning ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-[400px] text-muted-foreground">
|
||||
<Spinner className="mr-2 size-4" />
|
||||
Loading terminal...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Terminal containerId={effectiveContainerId} host={host} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground text-sm">
|
||||
Container is not running. Start the container to access
|
||||
terminal.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="env" className="mt-4">
|
||||
<EnvironmentVariables
|
||||
containerId={effectiveContainerId}
|
||||
containerHost={host}
|
||||
isReadOnly={isReadOnly}
|
||||
onContainerIdChange={handleContainerIdChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import { useMemo } from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
Line,
|
||||
LineChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
|
||||
import type { ContainerStats } from "../types/stats";
|
||||
|
||||
|
|
@ -17,24 +21,7 @@ interface ContainerStatsChartsProps {
|
|||
history: ContainerStats[];
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
interface ChartData {
|
||||
type ChartData = {
|
||||
time: string;
|
||||
timestamp: number;
|
||||
cpu: number;
|
||||
|
|
@ -45,6 +32,106 @@ interface ChartData {
|
|||
networkTx: number;
|
||||
blockRead: number;
|
||||
blockWrite: number;
|
||||
};
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
return new Date(timestamp * 1000).toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
const cpuChartConfig = {
|
||||
cpu: {
|
||||
label: "CPU",
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const memoryChartConfig = {
|
||||
memory: {
|
||||
label: "Memory",
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const networkChartConfig = {
|
||||
networkRx: {
|
||||
label: "RX (Received)",
|
||||
color: "var(--chart-3)",
|
||||
},
|
||||
networkTx: {
|
||||
label: "TX (Transmitted)",
|
||||
color: "var(--chart-4)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const blockChartConfig = {
|
||||
blockRead: {
|
||||
label: "Read",
|
||||
color: "var(--chart-5)",
|
||||
},
|
||||
blockWrite: {
|
||||
label: "Write",
|
||||
color: "var(--destructive)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
interface StatsChartCardProps {
|
||||
title: string;
|
||||
data: ChartData[];
|
||||
config: ChartConfig;
|
||||
children: React.ReactElement;
|
||||
legend?: React.ReactNode;
|
||||
}
|
||||
|
||||
function StatsChartCard({
|
||||
title,
|
||||
data,
|
||||
config,
|
||||
children,
|
||||
legend,
|
||||
}: StatsChartCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<ChartContainer config={config} className="h-[220px] w-full">
|
||||
{children}
|
||||
</ChartContainer>
|
||||
{data.length > 0 ? legend : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesLegend({ config }: { config: ChartConfig }) {
|
||||
return (
|
||||
<div className="flex flex-wrap justify-center gap-4 text-xs text-muted-foreground">
|
||||
{Object.entries(config).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: value.color }}
|
||||
/>
|
||||
<span>{value.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContainerStatsCharts({ history }: ContainerStatsChartsProps) {
|
||||
|
|
@ -63,283 +150,176 @@ export function ContainerStatsCharts({ history }: ContainerStatsChartsProps) {
|
|||
}));
|
||||
}, [history]);
|
||||
|
||||
if (history.length === 0) {
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
No data yet. Stats will appear once streaming begins.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* CPU Chart */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">CPU Usage (%)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="cpuGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "var(--radius)",
|
||||
}}
|
||||
labelStyle={{ color: "hsl(var(--foreground))" }}
|
||||
formatter={(value) => [`${(value as number).toFixed(2)}%`, "CPU"]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cpu"
|
||||
stroke="hsl(var(--primary))"
|
||||
fill="url(#cpuGradient)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<StatsChartCard title="CPU Usage (%)" data={chartData} config={cpuChartConfig}>
|
||||
<LineChart accessibilityLayer data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="time"
|
||||
minTickGap={32}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
tickLine={false}
|
||||
width={44}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value) => `${Number(value).toFixed(2)}%`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
dataKey="cpu"
|
||||
dot={false}
|
||||
stroke="var(--color-cpu)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
</LineChart>
|
||||
</StatsChartCard>
|
||||
|
||||
{/* Memory Chart */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Memory Usage (%)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="memoryGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(var(--chart-2))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--chart-2))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "var(--radius)",
|
||||
}}
|
||||
labelStyle={{ color: "hsl(var(--foreground))" }}
|
||||
formatter={(value, _name, props) => {
|
||||
const payload = (props as { payload: ChartData }).payload;
|
||||
return [
|
||||
`${(value as number).toFixed(2)}% (${formatBytes(payload.memoryUsage)} / ${formatBytes(payload.memoryLimit)})`,
|
||||
"Memory",
|
||||
];
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memory"
|
||||
stroke="hsl(var(--chart-2))"
|
||||
fill="url(#memoryGradient)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatsChartCard
|
||||
title="Memory Usage (%)"
|
||||
data={chartData}
|
||||
config={memoryChartConfig}
|
||||
>
|
||||
<LineChart accessibilityLayer data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="time"
|
||||
minTickGap={32}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
tickLine={false}
|
||||
width={44}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value, _name, item) => {
|
||||
const payload = item.payload as ChartData | undefined;
|
||||
return `${Number(value).toFixed(2)}% (${formatBytes(payload?.memoryUsage ?? 0)} / ${formatBytes(payload?.memoryLimit ?? 0)})`;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
dataKey="memory"
|
||||
dot={false}
|
||||
stroke="var(--color-memory)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
</LineChart>
|
||||
</StatsChartCard>
|
||||
|
||||
{/* Network I/O Chart */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Network I/O</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="rxGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(var(--chart-3))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--chart-3))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="txGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(var(--chart-4))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--chart-4))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => formatBytes(value)}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "var(--radius)",
|
||||
}}
|
||||
labelStyle={{ color: "hsl(var(--foreground))" }}
|
||||
formatter={(value, name) => [
|
||||
formatBytes(value as number),
|
||||
name === "networkRx" ? "RX (Received)" : "TX (Transmitted)",
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="networkRx"
|
||||
stroke="hsl(var(--chart-3))"
|
||||
fill="url(#rxGradient)"
|
||||
strokeWidth={2}
|
||||
name="networkRx"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="networkTx"
|
||||
stroke="hsl(var(--chart-4))"
|
||||
fill="url(#txGradient)"
|
||||
strokeWidth={2}
|
||||
name="networkTx"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="size-2 rounded-full" style={{ backgroundColor: "hsl(var(--chart-3))" }} />
|
||||
<span>RX (Received)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="size-2 rounded-full" style={{ backgroundColor: "hsl(var(--chart-4))" }} />
|
||||
<span>TX (Transmitted)</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatsChartCard
|
||||
title="Network I/O"
|
||||
data={chartData}
|
||||
config={networkChartConfig}
|
||||
legend={<SeriesLegend config={networkChartConfig} />}
|
||||
>
|
||||
<LineChart accessibilityLayer data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="time"
|
||||
minTickGap={32}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => formatBytes(Number(value))}
|
||||
tickLine={false}
|
||||
width={64}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value) => formatBytes(Number(value))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
dataKey="networkRx"
|
||||
dot={false}
|
||||
stroke="var(--color-networkRx)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
<Line
|
||||
dataKey="networkTx"
|
||||
dot={false}
|
||||
stroke="var(--color-networkTx)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
</LineChart>
|
||||
</StatsChartCard>
|
||||
|
||||
{/* Block I/O Chart */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Block I/O</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="readGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(var(--chart-5))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--chart-5))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="writeGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(var(--destructive))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--destructive))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => formatBytes(value)}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "var(--radius)",
|
||||
}}
|
||||
labelStyle={{ color: "hsl(var(--foreground))" }}
|
||||
formatter={(value, name) => [
|
||||
formatBytes(value as number),
|
||||
name === "blockRead" ? "Read" : "Write",
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="blockRead"
|
||||
stroke="hsl(var(--chart-5))"
|
||||
fill="url(#readGradient)"
|
||||
strokeWidth={2}
|
||||
name="blockRead"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="blockWrite"
|
||||
stroke="hsl(var(--destructive))"
|
||||
fill="url(#writeGradient)"
|
||||
strokeWidth={2}
|
||||
name="blockWrite"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="size-2 rounded-full" style={{ backgroundColor: "hsl(var(--chart-5))" }} />
|
||||
<span>Read</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="size-2 rounded-full" style={{ backgroundColor: "hsl(var(--destructive))" }} />
|
||||
<span>Write</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatsChartCard
|
||||
title="Block I/O"
|
||||
data={chartData}
|
||||
config={blockChartConfig}
|
||||
legend={<SeriesLegend config={blockChartConfig} />}
|
||||
>
|
||||
<LineChart accessibilityLayer data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
dataKey="time"
|
||||
minTickGap={32}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => formatBytes(Number(value))}
|
||||
tickLine={false}
|
||||
width={64}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value) => formatBytes(Number(value))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
dataKey="blockRead"
|
||||
dot={false}
|
||||
stroke="var(--color-blockRead)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
<Line
|
||||
dataKey="blockWrite"
|
||||
dot={false}
|
||||
stroke="var(--color-blockWrite)"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
</LineChart>
|
||||
</StatsChartCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
|
|
@ -3,111 +3,148 @@ import type { ContainerInfo } from "../types";
|
|||
export type SortDirection = "asc" | "desc";
|
||||
export type GroupByOption = "none" | "compose";
|
||||
export type ContainerActionType = "start" | "stop" | "restart" | "remove";
|
||||
export type StatsInterval = "1h" | "12h";
|
||||
export type SortColumn =
|
||||
| "name"
|
||||
| "state"
|
||||
| "uptime"
|
||||
| "created"
|
||||
| "cpu"
|
||||
| "ram";
|
||||
|
||||
export interface GroupedContainers {
|
||||
project: string;
|
||||
items: ContainerInfo[];
|
||||
project: string;
|
||||
items: ContainerInfo[];
|
||||
}
|
||||
|
||||
export interface StateCounts {
|
||||
running: number;
|
||||
exited: number;
|
||||
paused: number;
|
||||
restarting: number;
|
||||
dead: number;
|
||||
other: number;
|
||||
running: number;
|
||||
exited: number;
|
||||
paused: number;
|
||||
restarting: number;
|
||||
dead: number;
|
||||
other: number;
|
||||
}
|
||||
|
||||
const COMPOSE_PROJECT_LABEL = "com.docker.compose.project";
|
||||
|
||||
export function formatContainerName(names: string[]) {
|
||||
if (!names.length) {
|
||||
return "—";
|
||||
}
|
||||
const [primary] = names;
|
||||
return primary.startsWith("/") ? primary.slice(1) : primary;
|
||||
if (!names.length) {
|
||||
return "—";
|
||||
}
|
||||
const [primary] = names;
|
||||
return primary.startsWith("/") ? primary.slice(1) : primary;
|
||||
}
|
||||
|
||||
export function formatCreatedDate(createdSeconds: number) {
|
||||
const createdDate = new Date(createdSeconds * 1000);
|
||||
return createdDate.toLocaleString(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
const createdDate = new Date(createdSeconds * 1000);
|
||||
return createdDate.toLocaleString(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatUptime(createdSeconds: number) {
|
||||
const now = Date.now();
|
||||
const createdMs = createdSeconds * 1000;
|
||||
const diffMs = now - createdMs;
|
||||
const now = Date.now();
|
||||
const createdMs = createdSeconds * 1000;
|
||||
const diffMs = now - createdMs;
|
||||
|
||||
const seconds = Math.floor(diffMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
const weeks = Math.floor(days / 7);
|
||||
const months = Math.floor(days / 30);
|
||||
const years = Math.floor(days / 365);
|
||||
const seconds = Math.floor(diffMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
const weeks = Math.floor(days / 7);
|
||||
const months = Math.floor(days / 30);
|
||||
const years = Math.floor(days / 365);
|
||||
|
||||
if (years > 0) return `${years} year${years > 1 ? "s" : ""}`;
|
||||
if (months > 0) return `${months} month${months > 1 ? "s" : ""}`;
|
||||
if (weeks > 0) return `${weeks} week${weeks > 1 ? "s" : ""}`;
|
||||
if (days > 0) return `${days} day${days > 1 ? "s" : ""}`;
|
||||
if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""}`;
|
||||
if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""}`;
|
||||
return `${seconds} second${seconds !== 1 ? "s" : ""}`;
|
||||
if (years > 0) return `${years} year${years > 1 ? "s" : ""}`;
|
||||
if (months > 0) return `${months} month${months > 1 ? "s" : ""}`;
|
||||
if (weeks > 0) return `${weeks} week${weeks > 1 ? "s" : ""}`;
|
||||
if (days > 0) return `${days} day${days > 1 ? "s" : ""}`;
|
||||
if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""}`;
|
||||
if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""}`;
|
||||
return `${seconds} second${seconds !== 1 ? "s" : ""}`;
|
||||
}
|
||||
|
||||
export function toTitleCase(value: string) {
|
||||
if (!value) return value;
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
if (!value) return value;
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
export function getStateBadgeClass(state: string) {
|
||||
const normalized = state.toLowerCase();
|
||||
switch (normalized) {
|
||||
case "running":
|
||||
return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400";
|
||||
case "paused":
|
||||
return "bg-amber-500/10 text-amber-700 dark:text-amber-400";
|
||||
case "exited":
|
||||
case "dead":
|
||||
return "bg-rose-500/10 text-rose-700 dark:text-rose-400";
|
||||
case "restarting":
|
||||
return "bg-blue-500/10 text-blue-700 dark:text-blue-400";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
const normalized = state.toLowerCase();
|
||||
switch (normalized) {
|
||||
case "running":
|
||||
return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400";
|
||||
case "paused":
|
||||
return "bg-amber-500/10 text-amber-700 dark:text-amber-400";
|
||||
case "exited":
|
||||
case "dead":
|
||||
return "bg-rose-500/10 text-rose-700 dark:text-rose-400";
|
||||
case "restarting":
|
||||
return "bg-blue-500/10 text-blue-700 dark:text-blue-400";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
export function groupByCompose(
|
||||
containers: ContainerInfo[]
|
||||
containers: ContainerInfo[],
|
||||
sortDirection: SortDirection = "desc",
|
||||
): GroupedContainers[] {
|
||||
const groups = new Map<string, ContainerInfo[]>();
|
||||
const groups = new Map<string, ContainerInfo[]>();
|
||||
|
||||
containers.forEach((container) => {
|
||||
const key =
|
||||
container.labels?.[COMPOSE_PROJECT_LABEL]?.trim() || "Standalone";
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
}
|
||||
groups.get(key)?.push(container)
|
||||
});
|
||||
containers.forEach((container) => {
|
||||
const key =
|
||||
container.labels?.[COMPOSE_PROJECT_LABEL]?.trim() || "Standalone";
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
}
|
||||
groups.get(key)?.push(container);
|
||||
});
|
||||
|
||||
return Array.from(groups.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([project, items]) => ({ project, items }));
|
||||
return Array.from(groups.entries())
|
||||
.sort(([, itemsA], [, itemsB]) => {
|
||||
const createdA = Math.max(...itemsA.map((item) => item.created));
|
||||
const createdB = Math.max(...itemsB.map((item) => item.created));
|
||||
return sortDirection === "desc"
|
||||
? createdB - createdA
|
||||
: createdA - createdB;
|
||||
})
|
||||
.map(([project, items]) => ({ project, items }));
|
||||
}
|
||||
|
||||
export function getHistoricalValue(
|
||||
container: ContainerInfo,
|
||||
interval: StatsInterval,
|
||||
metric: "cpu" | "memory",
|
||||
) {
|
||||
const stats = container.historical_stats;
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value =
|
||||
interval === "1h"
|
||||
? metric === "cpu"
|
||||
? stats.cpu_1h
|
||||
: stats.memory_1h
|
||||
: metric === "cpu"
|
||||
? stats.cpu_12h
|
||||
: stats.memory_12h;
|
||||
|
||||
return value ?? null;
|
||||
}
|
||||
|
||||
export function getInitialStateCounts(): StateCounts {
|
||||
return {
|
||||
running: 0,
|
||||
exited: 0,
|
||||
paused: 0,
|
||||
restarting: 0,
|
||||
dead: 0,
|
||||
other: 0,
|
||||
};
|
||||
return {
|
||||
running: 0,
|
||||
exited: 0,
|
||||
paused: 0,
|
||||
restarting: 0,
|
||||
dead: 0,
|
||||
other: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -115,10 +152,10 @@ export function getInitialStateCounts(): StateCounts {
|
|||
* Falls back to container ID if no name is available
|
||||
*/
|
||||
export function getContainerUrlIdentifier(container: ContainerInfo): string {
|
||||
if (container.names && container.names.length > 0) {
|
||||
const name = container.names[0];
|
||||
return name.startsWith("/") ? name.slice(1) : name;
|
||||
}
|
||||
// Fallback to short ID if no name
|
||||
return container.id.substring(0, 12);
|
||||
if (container.names && container.names.length > 0) {
|
||||
const name = container.names[0];
|
||||
return name.startsWith("/") ? name.slice(1) : name;
|
||||
}
|
||||
// Fallback to short ID if no name
|
||||
return container.id.substring(0, 12);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,243 @@
|
|||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ContainersDashboard } from "./containers-dashboard";
|
||||
|
||||
const mockUseContainersQuery = vi.fn();
|
||||
const mockUseSystemStats = vi.fn();
|
||||
const mockUseContainersDashboardUrlState = vi.fn();
|
||||
const mockStartContainer = vi.fn();
|
||||
const mockStopContainer = vi.fn();
|
||||
const mockRestartContainer = vi.fn();
|
||||
const mockRemoveContainer = vi.fn();
|
||||
const mockContainersLogsSheet = vi.fn((_props: unknown) => (
|
||||
<div data-testid="logs-sheet" />
|
||||
));
|
||||
const mockContainerDetailsSheet = vi.fn((_props: unknown) => (
|
||||
<div data-testid="details-sheet" />
|
||||
));
|
||||
|
||||
vi.mock("../hooks/use-containers-query", () => ({
|
||||
useContainersQuery: (...args: unknown[]) => mockUseContainersQuery(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../api/container-actions", () => ({
|
||||
startContainer: (...args: unknown[]) => mockStartContainer(...args),
|
||||
stopContainer: (...args: unknown[]) => mockStopContainer(...args),
|
||||
restartContainer: (...args: unknown[]) => mockRestartContainer(...args),
|
||||
removeContainer: (...args: unknown[]) => mockRemoveContainer(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-system-stats", () => ({
|
||||
useSystemStats: (...args: unknown[]) => mockUseSystemStats(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-containers-dashboard-url-state", () => ({
|
||||
useContainersDashboardUrlState: (...args: unknown[]) =>
|
||||
mockUseContainersDashboardUrlState(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./containers-summary-cards", () => ({
|
||||
ContainersSummaryCards: () => <div>summary cards</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./containers-toolbar", () => ({
|
||||
ContainersToolbar: () => <div>toolbar</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./containers-state-summary", () => ({
|
||||
ContainersStateSummary: () => <div>state summary</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./containers-pagination", () => ({
|
||||
ContainersPagination: () => <div>pagination</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./containers-table", () => ({
|
||||
ContainersTable: ({
|
||||
pageItems,
|
||||
onToggleSelect,
|
||||
onViewLogs,
|
||||
onViewStats,
|
||||
}: {
|
||||
pageItems: Array<{ id: string }>;
|
||||
onToggleSelect: (id: string) => void;
|
||||
onViewLogs: (container: { id: string }) => void;
|
||||
onViewStats: (container: { id: string }) => void;
|
||||
}) => (
|
||||
<div>
|
||||
{pageItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onToggleSelect(item.id)}
|
||||
>
|
||||
Select {item.id}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={() => onViewLogs(pageItems[0])}>
|
||||
Open logs
|
||||
</button>
|
||||
<button type="button" onClick={() => onViewStats(pageItems[0])}>
|
||||
Open stats
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./containers-logs-sheet", () => ({
|
||||
ContainersLogsSheet: (props: unknown) => mockContainersLogsSheet(props),
|
||||
}));
|
||||
|
||||
vi.mock("./container-details-sheet", () => ({
|
||||
ContainerDetailsSheet: (props: unknown) => mockContainerDetailsSheet(props),
|
||||
}));
|
||||
|
||||
const container = {
|
||||
id: "container-1",
|
||||
names: ["/api"],
|
||||
image: "ghcr.io/example/api:latest",
|
||||
image_id: "sha256:123",
|
||||
command: "node server.js",
|
||||
created: 1_700_000_000,
|
||||
state: "running",
|
||||
status: "Up 2 hours",
|
||||
host: "local",
|
||||
historical_stats: {
|
||||
cpu_1h: 12,
|
||||
memory_1h: 34,
|
||||
cpu_12h: 20,
|
||||
memory_12h: 40,
|
||||
},
|
||||
};
|
||||
|
||||
const secondContainer = {
|
||||
...container,
|
||||
id: "container-2",
|
||||
names: ["/worker"],
|
||||
image: "ghcr.io/example/worker:latest",
|
||||
};
|
||||
|
||||
function renderDashboard() {
|
||||
const queryClient = new QueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ContainersDashboard />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ContainersDashboard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockStartContainer.mockResolvedValue({ message: "started" });
|
||||
mockStopContainer.mockResolvedValue({ message: "stopped" });
|
||||
mockRestartContainer.mockResolvedValue({ message: "restarted" });
|
||||
mockRemoveContainer.mockResolvedValue({ message: "removed" });
|
||||
|
||||
mockUseContainersQuery.mockReturnValue({
|
||||
data: {
|
||||
containers: [container],
|
||||
readOnly: false,
|
||||
hosts: [{ Name: "local", Host: "unix:///var/run/docker.sock" }],
|
||||
hostErrors: [],
|
||||
},
|
||||
error: null,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
mockUseSystemStats.mockReturnValue({
|
||||
data: {
|
||||
hostInfo: {
|
||||
hostname: "vps-1",
|
||||
platform: "linux",
|
||||
kernelVersion: "6.8.0",
|
||||
},
|
||||
usage: {
|
||||
cpuPercent: 12,
|
||||
memoryPercent: 40,
|
||||
diskPercent: 55,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockUseContainersDashboardUrlState.mockReturnValue({
|
||||
searchTerm: "",
|
||||
setSearchTerm: vi.fn(),
|
||||
stateFilter: "all",
|
||||
setStateFilter: vi.fn(),
|
||||
hostFilter: "all",
|
||||
setHostFilter: vi.fn(),
|
||||
sortDirection: "desc",
|
||||
setSortDirection: vi.fn(),
|
||||
sortBy: "created",
|
||||
setSortBy: vi.fn(),
|
||||
groupBy: "none",
|
||||
setGroupBy: vi.fn(),
|
||||
statsInterval: "1h",
|
||||
setStatsInterval: vi.fn(),
|
||||
dateRange: undefined,
|
||||
setDateRange: vi.fn(),
|
||||
clearDateRange: vi.fn(),
|
||||
pageSize: 10,
|
||||
setPageSize: vi.fn(),
|
||||
page: 1,
|
||||
setPage: vi.fn(),
|
||||
expandedGroups: [],
|
||||
setExpandedGroups: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the home dashboard without mounting closed overlay sheets", () => {
|
||||
renderDashboard();
|
||||
|
||||
expect(screen.getByText("summary cards")).toBeInTheDocument();
|
||||
expect(mockContainersLogsSheet).not.toHaveBeenCalled();
|
||||
expect(mockContainerDetailsSheet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mounts the requested sheet only after the matching action is triggered", () => {
|
||||
renderDashboard();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open logs" }));
|
||||
expect(mockContainersLogsSheet).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("logs-sheet")).toBeInTheDocument();
|
||||
expect(mockContainerDetailsSheet).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open stats" }));
|
||||
expect(mockContainerDetailsSheet).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("details-sheet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("confirms multi-select stop before executing actions", async () => {
|
||||
mockUseContainersQuery.mockReturnValue({
|
||||
data: {
|
||||
containers: [container, secondContainer],
|
||||
readOnly: false,
|
||||
hosts: [{ Name: "local", Host: "unix:///var/run/docker.sock" }],
|
||||
hostErrors: [],
|
||||
},
|
||||
error: null,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
renderDashboard();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Select container-1" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Select container-2" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
|
||||
|
||||
expect(mockStopContainer).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stop Containers" }));
|
||||
|
||||
await waitFor(() => expect(mockStopContainer).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,131 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ContainersTable } from "./containers-table";
|
||||
|
||||
const baseContainer = {
|
||||
id: "container-1",
|
||||
names: ["/api"],
|
||||
image: "ghcr.io/example/api:latest",
|
||||
image_id: "sha256:1",
|
||||
command: "node server.js",
|
||||
created: 1_700_000_000,
|
||||
state: "running",
|
||||
status: "Up 2 hours",
|
||||
host: "local",
|
||||
labels: {
|
||||
"com.docker.compose.project": "project-alpha",
|
||||
},
|
||||
};
|
||||
|
||||
describe("ContainersTable", () => {
|
||||
it('shows "Collecting" when historical stats are not available yet', () => {
|
||||
render(
|
||||
<ContainersTable
|
||||
error={null}
|
||||
expandedGroups={[]}
|
||||
filteredContainers={[baseContainer]}
|
||||
groupBy="none"
|
||||
groupedItems={null}
|
||||
isError={false}
|
||||
isLoading={false}
|
||||
isReadOnly={false}
|
||||
onDelete={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onSelectAll={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
onToggleGroup={vi.fn()}
|
||||
onToggleSelect={vi.fn()}
|
||||
onViewLogs={vi.fn()}
|
||||
onViewStats={vi.fn()}
|
||||
pageItems={[baseContainer]}
|
||||
pendingAction={null}
|
||||
selectedIds={[]}
|
||||
statsInterval="1h"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText("Collecting")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps the compose group label together", () => {
|
||||
render(
|
||||
<ContainersTable
|
||||
error={null}
|
||||
expandedGroups={["project-alpha"]}
|
||||
filteredContainers={[baseContainer, { ...baseContainer, id: "container-2", names: ["/worker"] }]}
|
||||
groupBy="compose"
|
||||
groupedItems={[
|
||||
{
|
||||
project: "project-alpha",
|
||||
items: [baseContainer, { ...baseContainer, id: "container-2", names: ["/worker"] }],
|
||||
},
|
||||
]}
|
||||
isError={false}
|
||||
isLoading={false}
|
||||
isReadOnly={false}
|
||||
onDelete={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onSelectAll={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
onToggleGroup={vi.fn()}
|
||||
onToggleSelect={vi.fn()}
|
||||
onViewLogs={vi.fn()}
|
||||
onViewStats={vi.fn()}
|
||||
pageItems={[baseContainer, { ...baseContainer, id: "container-2", names: ["/worker"] }]}
|
||||
pendingAction={null}
|
||||
selectedIds={[]}
|
||||
statsInterval="1h"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /project-alpha/i }).textContent).toContain(
|
||||
"project-alpha · 2 containers",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the image copy control as an accessible button", () => {
|
||||
const writeText = vi.fn();
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
|
||||
render(
|
||||
<ContainersTable
|
||||
error={null}
|
||||
expandedGroups={[]}
|
||||
filteredContainers={[baseContainer]}
|
||||
groupBy="none"
|
||||
groupedItems={null}
|
||||
isError={false}
|
||||
isLoading={false}
|
||||
isReadOnly={false}
|
||||
onDelete={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onSelectAll={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
onToggleGroup={vi.fn()}
|
||||
onToggleSelect={vi.fn()}
|
||||
onViewLogs={vi.fn()}
|
||||
onViewStats={vi.fn()}
|
||||
pageItems={[baseContainer]}
|
||||
pendingAction={null}
|
||||
selectedIds={[]}
|
||||
statsInterval="1h"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: `Copy ${baseContainer.image}` }),
|
||||
);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(baseContainer.image);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,328 +1,425 @@
|
|||
import { ActivityIcon, FileTextIcon, PlayIcon, RotateCwIcon, SquareIcon, Trash2Icon } from "lucide-react";
|
||||
import {
|
||||
ActivityIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
FileTextIcon,
|
||||
PlayIcon,
|
||||
RotateCwIcon,
|
||||
SquareIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
import {
|
||||
formatContainerName,
|
||||
formatCreatedDate,
|
||||
formatUptime,
|
||||
getStateBadgeClass,
|
||||
toTitleCase,
|
||||
} from "./container-utils";
|
||||
|
||||
import type { ContainerInfo } from "../types";
|
||||
|
||||
import type {
|
||||
ContainerActionType,
|
||||
GroupByOption,
|
||||
GroupedContainers,
|
||||
ContainerActionType,
|
||||
GroupByOption,
|
||||
GroupedContainers,
|
||||
StatsInterval,
|
||||
} from "./container-utils";
|
||||
import {
|
||||
formatContainerName,
|
||||
formatCreatedDate,
|
||||
formatUptime,
|
||||
getHistoricalValue,
|
||||
getStateBadgeClass,
|
||||
toTitleCase,
|
||||
} from "./container-utils";
|
||||
|
||||
interface ContainersTableProps {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: unknown;
|
||||
groupBy: GroupByOption;
|
||||
filteredContainers: ContainerInfo[];
|
||||
groupedItems: GroupedContainers[] | null;
|
||||
pageItems: ContainerInfo[];
|
||||
pendingAction: { id: string; type: ContainerActionType } | null;
|
||||
isReadOnly: boolean;
|
||||
onStart: (container: ContainerInfo) => void;
|
||||
onStop: (container: ContainerInfo) => void;
|
||||
onRestart: (container: ContainerInfo) => void;
|
||||
onDelete: (container: ContainerInfo) => void;
|
||||
onViewLogs: (container: ContainerInfo) => void;
|
||||
onViewStats: (container: ContainerInfo) => void;
|
||||
onRetry: () => void;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: unknown;
|
||||
groupBy: GroupByOption;
|
||||
filteredContainers: ContainerInfo[];
|
||||
groupedItems: GroupedContainers[] | null;
|
||||
pageItems: ContainerInfo[];
|
||||
pendingAction: { id: string; type: ContainerActionType } | null;
|
||||
isReadOnly: boolean;
|
||||
expandedGroups: string[];
|
||||
selectedIds: string[];
|
||||
statsInterval: StatsInterval;
|
||||
onToggleSelect: (id: string) => void;
|
||||
onSelectAll: () => void;
|
||||
onToggleGroup: (project: string) => void;
|
||||
onStart: (container: ContainerInfo) => void;
|
||||
onStop: (container: ContainerInfo) => void;
|
||||
onRestart: (container: ContainerInfo) => void;
|
||||
onDelete: (container: ContainerInfo) => void;
|
||||
onViewLogs: (container: ContainerInfo) => void;
|
||||
onViewStats: (container: ContainerInfo) => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export function ContainersTable({
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
groupBy,
|
||||
filteredContainers,
|
||||
groupedItems,
|
||||
pageItems,
|
||||
pendingAction,
|
||||
isReadOnly,
|
||||
onStart,
|
||||
onStop,
|
||||
onRestart,
|
||||
onDelete,
|
||||
onViewLogs,
|
||||
onViewStats,
|
||||
onRetry,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
groupBy,
|
||||
filteredContainers,
|
||||
groupedItems,
|
||||
pageItems,
|
||||
pendingAction,
|
||||
isReadOnly,
|
||||
expandedGroups,
|
||||
selectedIds,
|
||||
statsInterval,
|
||||
onToggleSelect,
|
||||
onSelectAll,
|
||||
onToggleGroup,
|
||||
onStart,
|
||||
onStop,
|
||||
onRestart,
|
||||
onDelete,
|
||||
onViewLogs,
|
||||
onViewStats,
|
||||
onRetry,
|
||||
}: ContainersTableProps) {
|
||||
const isContainerActionPending = (
|
||||
action: ContainerActionType,
|
||||
containerId: string
|
||||
) =>
|
||||
pendingAction?.id === containerId && pendingAction.type === action;
|
||||
const isContainerActionPending = (
|
||||
action: ContainerActionType,
|
||||
containerId: string,
|
||||
) => pendingAction?.id === containerId && pendingAction.type === action;
|
||||
|
||||
const isContainerBusy = (containerId: string) =>
|
||||
pendingAction?.id === containerId;
|
||||
const isContainerBusy = (containerId: string) =>
|
||||
pendingAction?.id === containerId;
|
||||
|
||||
const renderContainerRow = (container: ContainerInfo) => {
|
||||
const state = container.state.toLowerCase();
|
||||
const busy = isContainerBusy(container.id);
|
||||
const startPending = isContainerActionPending("start", container.id);
|
||||
const stopPending = isContainerActionPending("stop", container.id);
|
||||
const restartPending = isContainerActionPending("restart", container.id);
|
||||
const removePending = isContainerActionPending("remove", container.id);
|
||||
const formatHistoricalMetric = (value: number | null) =>
|
||||
value === null ? "Collecting" : `${value.toFixed(1)}%`;
|
||||
|
||||
return (
|
||||
<TableRow key={container.id} className="hover:bg-muted/50">
|
||||
<TableCell className="h-16 px-4 font-medium">
|
||||
{formatContainerName(container.names)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
|
||||
{container.image}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-4">
|
||||
<Badge
|
||||
className={`${getStateBadgeClass(container.state)} border-0`}
|
||||
>
|
||||
{toTitleCase(container.state)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
|
||||
{formatUptime(container.created)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
|
||||
{formatCreatedDate(container.created)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-4 max-w-[300px] text-sm text-muted-foreground">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block cursor-help truncate">
|
||||
{container.command}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-md">
|
||||
{container.command}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-4">
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-1">
|
||||
{state === "exited" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onStart(container)}
|
||||
disabled={busy || isReadOnly}
|
||||
>
|
||||
{startPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<PlayIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isReadOnly ? "Start (Read-only mode)" : "Start"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{state === "running" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onStop(container)}
|
||||
disabled={busy || isReadOnly}
|
||||
>
|
||||
{stopPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<SquareIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isReadOnly ? "Stop (Read-only mode)" : "Stop"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onRestart(container)}
|
||||
disabled={busy || isReadOnly}
|
||||
>
|
||||
{restartPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<RotateCwIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isReadOnly ? "Restart (Read-only mode)" : "Restart"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:bg-destructive hover:text-white"
|
||||
onClick={() => onDelete(container)}
|
||||
disabled={busy || isReadOnly}
|
||||
>
|
||||
{removePending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<Trash2Icon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isReadOnly ? "Delete (Read-only mode)" : "Delete"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onViewLogs(container)}
|
||||
disabled={busy}
|
||||
>
|
||||
<FileTextIcon className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View Logs</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onViewStats(container)}
|
||||
disabled={busy}
|
||||
>
|
||||
<ActivityIcon className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View Stats</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
const renderContainerRow = (container: ContainerInfo) => {
|
||||
const state = container.state.toLowerCase();
|
||||
const busy = isContainerBusy(container.id);
|
||||
const startPending = isContainerActionPending("start", container.id);
|
||||
const stopPending = isContainerActionPending("stop", container.id);
|
||||
const restartPending = isContainerActionPending("restart", container.id);
|
||||
const removePending = isContainerActionPending("remove", container.id);
|
||||
const cpuAverage = getHistoricalValue(container, statsInterval, "cpu");
|
||||
const memoryAverage = getHistoricalValue(
|
||||
container,
|
||||
statsInterval,
|
||||
"memory",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-b">
|
||||
<TableHead className="h-12 px-4 font-medium">Name</TableHead>
|
||||
<TableHead className="h-12 px-4 font-medium">Image</TableHead>
|
||||
<TableHead className="h-12 px-4 font-medium w-[120px]">
|
||||
State
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-4 font-medium">Uptime</TableHead>
|
||||
<TableHead className="h-12 px-4 font-medium">Created</TableHead>
|
||||
<TableHead className="h-12 px-4 font-medium">Command</TableHead>
|
||||
<TableHead className="h-12 px-4 font-medium w-[160px]">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-32">
|
||||
<div className="flex items-center justify-center text-sm text-muted-foreground">
|
||||
<Spinner className="mr-2" />
|
||||
Loading containers…
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : isError ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-32">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(error as Error)?.message || "Unable to load containers."}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={onRetry}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredContainers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-32">
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
No containers found.
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : groupBy === "compose" && groupedItems ? (
|
||||
groupedItems.map((group) => (
|
||||
<Fragment key={group.project}>
|
||||
<TableRow className="bg-muted/30 hover:bg-muted/30">
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="h-10 px-4 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{group.project} · {group.items.length} container
|
||||
{group.items.length === 1 ? "" : "s"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{group.items.map(renderContainerRow)}
|
||||
</Fragment>
|
||||
))
|
||||
) : (
|
||||
pageItems.map(renderContainerRow)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<TableRow key={container.id} className="hover:bg-muted/50">
|
||||
<TableCell className="w-10 px-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(container.id)}
|
||||
onChange={() => onToggleSelect(container.id)}
|
||||
aria-label={`Select ${formatContainerName(container.names)}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-4 font-medium">
|
||||
{formatContainerName(container.names)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="block max-w-[260px] cursor-pointer truncate"
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(container.image);
|
||||
}}
|
||||
title="Click to copy image name"
|
||||
aria-label={`Copy ${container.image}`}
|
||||
>
|
||||
{container.image}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-md break-all">
|
||||
{container.image}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-4">
|
||||
<Badge className={`${getStateBadgeClass(container.state)} border-0`}>
|
||||
{toTitleCase(container.state)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
|
||||
{formatUptime(container.created)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
|
||||
{formatCreatedDate(container.created)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
|
||||
{formatHistoricalMetric(cpuAverage)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
|
||||
{formatHistoricalMetric(memoryAverage)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 max-w-[300px] px-4 text-sm text-muted-foreground">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block max-w-[280px] cursor-help truncate">
|
||||
{container.command}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-md break-all">
|
||||
{container.command}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-4">
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-1">
|
||||
{state === "exited" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onStart(container)}
|
||||
disabled={busy || isReadOnly}
|
||||
aria-label={`Start container ${formatContainerName(container.names)}`}
|
||||
>
|
||||
{startPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<PlayIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isReadOnly ? "Start (Read-only mode)" : "Start"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{state === "running" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onStop(container)}
|
||||
disabled={busy || isReadOnly}
|
||||
aria-label={`Stop container ${formatContainerName(container.names)}`}
|
||||
>
|
||||
{stopPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<SquareIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isReadOnly ? "Stop (Read-only mode)" : "Stop"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onRestart(container)}
|
||||
disabled={busy || isReadOnly}
|
||||
aria-label={`Restart container ${formatContainerName(container.names)}`}
|
||||
>
|
||||
{restartPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<RotateCwIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isReadOnly ? "Restart (Read-only mode)" : "Restart"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:bg-destructive hover:text-white"
|
||||
onClick={() => onDelete(container)}
|
||||
disabled={busy || isReadOnly}
|
||||
aria-label={`Delete container ${formatContainerName(container.names)}`}
|
||||
>
|
||||
{removePending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<Trash2Icon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isReadOnly ? "Delete (Read-only mode)" : "Delete"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onViewLogs(container)}
|
||||
disabled={busy}
|
||||
aria-label={`View logs for container ${formatContainerName(container.names)}`}
|
||||
>
|
||||
<FileTextIcon className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View Logs</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onViewStats(container)}
|
||||
disabled={busy}
|
||||
aria-label={`View stats for container ${formatContainerName(container.names)}`}
|
||||
>
|
||||
<ActivityIcon className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View Stats</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border bg-card">
|
||||
<Table className="min-w-[1180px]">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-b">
|
||||
<TableHead className="h-12 w-10 px-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
pageItems.length > 0 &&
|
||||
selectedIds.length === pageItems.length
|
||||
}
|
||||
onChange={onSelectAll}
|
||||
aria-label="Select all containers on this page"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-4 font-medium">Name</TableHead>
|
||||
<TableHead className="h-12 px-4 font-medium">Image</TableHead>
|
||||
<TableHead className="h-12 px-4 font-medium w-[120px]">
|
||||
State
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-4 font-medium">Uptime</TableHead>
|
||||
<TableHead className="h-12 px-4 font-medium">Created</TableHead>
|
||||
<TableHead className="h-12 px-4 font-medium">
|
||||
CPU {statsInterval}
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-4 font-medium">
|
||||
RAM {statsInterval}
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-4 font-medium">Command</TableHead>
|
||||
<TableHead className="h-12 px-4 font-medium w-[160px]">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="h-32">
|
||||
<div className="flex items-center justify-center text-sm text-muted-foreground">
|
||||
<Spinner className="mr-2" />
|
||||
Loading containers…
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : isError ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="h-32">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(error as Error)?.message || "Unable to load containers."}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={onRetry}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredContainers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="h-32">
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
No containers found.
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : groupBy === "compose" && groupedItems ? (
|
||||
groupedItems.map((group) => (
|
||||
<Fragment key={group.project}>
|
||||
<TableRow className="bg-muted/30 hover:bg-muted/30">
|
||||
<TableCell
|
||||
colSpan={10}
|
||||
className="h-10 px-4 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex max-w-full items-center gap-2 truncate"
|
||||
onClick={() => onToggleGroup(group.project)}
|
||||
>
|
||||
{expandedGroups.includes(group.project) ? (
|
||||
<ChevronDownIcon className="size-4" />
|
||||
) : (
|
||||
<ChevronRightIcon className="size-4" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{group.project} · {group.items.length}{" "}
|
||||
{group.items.length === 1 ? "container" : "containers"}
|
||||
</span>
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expandedGroups.includes(group.project)
|
||||
? group.items.map(renderContainerRow)
|
||||
: null}
|
||||
</Fragment>
|
||||
))
|
||||
) : (
|
||||
pageItems.map(renderContainerRow)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,290 +1,356 @@
|
|||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChevronDownIcon,
|
||||
LogOutIcon,
|
||||
RefreshCcwIcon,
|
||||
SettingsIcon,
|
||||
XIcon
|
||||
CalendarIcon,
|
||||
ChevronDownIcon,
|
||||
LogOutIcon,
|
||||
RefreshCcwIcon,
|
||||
SettingsIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { DateRange } from "react-day-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
|
||||
import type { DockerHost } from "../types";
|
||||
import type {
|
||||
GroupByOption,
|
||||
SortColumn,
|
||||
SortDirection,
|
||||
StatsInterval,
|
||||
} from "./container-utils";
|
||||
import { toTitleCase } from "./container-utils";
|
||||
|
||||
import type { DateRange } from "react-day-picker";
|
||||
import type { GroupByOption, SortDirection } from "./container-utils";
|
||||
import type { DockerHost } from "../types";
|
||||
|
||||
interface ContainersToolbarProps {
|
||||
searchTerm: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
stateFilter: string;
|
||||
onStateFilterChange: (value: string) => void;
|
||||
availableStates: string[];
|
||||
hostFilter: string;
|
||||
onHostFilterChange: (value: string) => void;
|
||||
availableHosts: DockerHost[];
|
||||
sortDirection: SortDirection;
|
||||
onSortDirectionChange: (direction: SortDirection) => void;
|
||||
groupBy: GroupByOption;
|
||||
onGroupByChange: (value: GroupByOption) => void;
|
||||
dateRange: DateRange | undefined;
|
||||
onDateRangeChange: (range: DateRange | undefined) => void;
|
||||
onDateRangeClear: () => void;
|
||||
onRefresh: () => void;
|
||||
isFetching: boolean;
|
||||
searchTerm: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
stateFilter: string;
|
||||
onStateFilterChange: (value: string) => void;
|
||||
availableStates: string[];
|
||||
hostFilter: string;
|
||||
onHostFilterChange: (value: string) => void;
|
||||
availableHosts: DockerHost[];
|
||||
sortDirection: SortDirection;
|
||||
onSortDirectionChange: (direction: SortDirection) => void;
|
||||
sortBy: SortColumn;
|
||||
onSortByChange: (value: SortColumn) => void;
|
||||
groupBy: GroupByOption;
|
||||
onGroupByChange: (value: GroupByOption) => void;
|
||||
statsInterval: StatsInterval;
|
||||
onStatsIntervalChange: (value: StatsInterval) => void;
|
||||
dateRange: DateRange | undefined;
|
||||
onDateRangeChange: (range: DateRange | undefined) => void;
|
||||
onDateRangeClear: () => void;
|
||||
onRefresh: () => void;
|
||||
isFetching: boolean;
|
||||
}
|
||||
|
||||
export function ContainersToolbar({
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
stateFilter,
|
||||
onStateFilterChange,
|
||||
availableStates,
|
||||
hostFilter,
|
||||
onHostFilterChange,
|
||||
availableHosts,
|
||||
sortDirection,
|
||||
onSortDirectionChange,
|
||||
groupBy,
|
||||
onGroupByChange,
|
||||
dateRange,
|
||||
onDateRangeChange,
|
||||
onDateRangeClear,
|
||||
onRefresh,
|
||||
isFetching,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
stateFilter,
|
||||
onStateFilterChange,
|
||||
availableStates,
|
||||
hostFilter,
|
||||
onHostFilterChange,
|
||||
availableHosts,
|
||||
sortDirection,
|
||||
onSortDirectionChange,
|
||||
sortBy,
|
||||
onSortByChange,
|
||||
groupBy,
|
||||
onGroupByChange,
|
||||
statsInterval,
|
||||
onStatsIntervalChange,
|
||||
dateRange,
|
||||
onDateRangeChange,
|
||||
onDateRangeClear,
|
||||
onRefresh,
|
||||
isFetching,
|
||||
}: ContainersToolbarProps) {
|
||||
const { logout, user, isAuthEnabled } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { logout, user, isAuthEnabled } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate({ to: "/login" });
|
||||
};
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate({ to: "/login" });
|
||||
};
|
||||
|
||||
const renderDateRange = () => {
|
||||
if (!dateRange?.from) {
|
||||
return <span>Date range</span>;
|
||||
}
|
||||
const sortLabels: Record<SortColumn, string> = {
|
||||
name: "Name",
|
||||
state: "State",
|
||||
uptime: "Uptime",
|
||||
created: "Created",
|
||||
cpu: "CPU",
|
||||
ram: "RAM",
|
||||
};
|
||||
|
||||
if (dateRange.to) {
|
||||
const from = dateRange.from.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
const to = dateRange.to.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
return (
|
||||
<>
|
||||
{from} - {to}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const renderDateRange = () => {
|
||||
if (!dateRange?.from) {
|
||||
return <span>Date range</span>;
|
||||
}
|
||||
|
||||
return dateRange.from.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
if (dateRange.to) {
|
||||
const from = dateRange.from.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
const to = dateRange.to.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
return (
|
||||
<>
|
||||
{from} - {to}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Input
|
||||
type="search"
|
||||
value={searchTerm}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="Search containers..."
|
||||
className="sm:max-w-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-9">
|
||||
{hostFilter === "all" ? "All hosts" : hostFilter}
|
||||
<ChevronDownIcon className="ml-2 size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup
|
||||
value={hostFilter}
|
||||
onValueChange={onHostFilterChange}
|
||||
>
|
||||
<DropdownMenuRadioItem value="all">
|
||||
All hosts
|
||||
</DropdownMenuRadioItem>
|
||||
{availableHosts.map((host) => (
|
||||
<DropdownMenuRadioItem key={host.Name} value={host.Name}>
|
||||
{host.Name}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
return dateRange.from.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-9">
|
||||
{stateFilter === "all" ? "All states" : toTitleCase(stateFilter)}
|
||||
<ChevronDownIcon className="ml-2 size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup
|
||||
value={stateFilter}
|
||||
onValueChange={onStateFilterChange}
|
||||
>
|
||||
<DropdownMenuRadioItem value="all">
|
||||
All states
|
||||
</DropdownMenuRadioItem>
|
||||
{availableStates.map((state) => (
|
||||
<DropdownMenuRadioItem key={state} value={state}>
|
||||
{toTitleCase(state)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
return (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Input
|
||||
type="search"
|
||||
value={searchTerm}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="Search containers..."
|
||||
className="sm:max-w-sm"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2 md:flex-nowrap">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-9">
|
||||
{hostFilter === "all" ? "All hosts" : hostFilter}
|
||||
<ChevronDownIcon className="ml-2 size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup
|
||||
value={hostFilter}
|
||||
onValueChange={onHostFilterChange}
|
||||
>
|
||||
<DropdownMenuRadioItem value="all">
|
||||
All hosts
|
||||
</DropdownMenuRadioItem>
|
||||
{availableHosts.map((host) => (
|
||||
<DropdownMenuRadioItem key={host.Name} value={host.Name}>
|
||||
{host.Name}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-9">
|
||||
{sortDirection === "desc" ? "Newest" : "Oldest"}
|
||||
<ChevronDownIcon className="ml-2 size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup
|
||||
value={sortDirection}
|
||||
onValueChange={(value) =>
|
||||
onSortDirectionChange(value as SortDirection)
|
||||
}
|
||||
>
|
||||
<DropdownMenuRadioItem value="desc">
|
||||
Newest first
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="asc">
|
||||
Oldest first
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-9">
|
||||
{stateFilter === "all" ? "All states" : toTitleCase(stateFilter)}
|
||||
<ChevronDownIcon className="ml-2 size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup
|
||||
value={stateFilter}
|
||||
onValueChange={onStateFilterChange}
|
||||
>
|
||||
<DropdownMenuRadioItem value="all">
|
||||
All states
|
||||
</DropdownMenuRadioItem>
|
||||
{availableStates.map((state) => (
|
||||
<DropdownMenuRadioItem key={state} value={state}>
|
||||
{toTitleCase(state)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-9">
|
||||
{groupBy === "compose" ? "By project" : "No grouping"}
|
||||
<ChevronDownIcon className="ml-2 size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup
|
||||
value={groupBy}
|
||||
onValueChange={(value) => onGroupByChange(value as GroupByOption)}
|
||||
>
|
||||
<DropdownMenuRadioItem value="none">
|
||||
No grouping
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="compose">
|
||||
By compose project
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-9">
|
||||
Sort: {sortLabels[sortBy]}
|
||||
<ChevronDownIcon className="ml-2 size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup
|
||||
value={sortBy}
|
||||
onValueChange={(value) => onSortByChange(value as SortColumn)}
|
||||
>
|
||||
<DropdownMenuRadioItem value="name">Name</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="state">State</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="uptime">
|
||||
Uptime
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="created">
|
||||
Created
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="cpu">CPU</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="ram">RAM</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={dateRange?.from ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-9 justify-start text-left font-normal"
|
||||
>
|
||||
<CalendarIcon className="mr-2 size-4" />
|
||||
{renderDateRange()}
|
||||
{dateRange?.from && (
|
||||
<XIcon
|
||||
className="ml-2 size-4 hover:text-destructive"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDateRangeClear();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end">
|
||||
<Calendar
|
||||
mode="range"
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={dateRange}
|
||||
onSelect={onDateRangeChange}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-9">
|
||||
{sortDirection === "desc" ? "Desc" : "Asc"}
|
||||
<ChevronDownIcon className="ml-2 size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup
|
||||
value={sortDirection}
|
||||
onValueChange={(value) =>
|
||||
onSortDirectionChange(value as SortDirection)
|
||||
}
|
||||
>
|
||||
<DropdownMenuRadioItem value="desc">
|
||||
Descending
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="asc">
|
||||
Ascending
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
className="h-9 shrink-0"
|
||||
>
|
||||
<RefreshCcwIcon
|
||||
className={`size-4 ${isFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-9">
|
||||
{groupBy === "compose" ? "By project" : "No grouping"}
|
||||
<ChevronDownIcon className="ml-2 size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup
|
||||
value={groupBy}
|
||||
onValueChange={(value) => onGroupByChange(value as GroupByOption)}
|
||||
>
|
||||
<DropdownMenuRadioItem value="none">
|
||||
No grouping
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="compose">
|
||||
By compose project
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-9 shrink-0" asChild>
|
||||
<Link to="/settings" aria-label="Settings">
|
||||
<SettingsIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Settings</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex items-center rounded-md border bg-background p-1">
|
||||
<Button
|
||||
variant={statsInterval === "1h" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => onStatsIntervalChange("1h")}
|
||||
>
|
||||
1h
|
||||
</Button>
|
||||
<Button
|
||||
variant={statsInterval === "12h" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => onStatsIntervalChange("12h")}
|
||||
>
|
||||
12h
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isAuthEnabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="h-9 shrink-0"
|
||||
>
|
||||
<LogOutIcon className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Logout {user?.username ? `(${user.username})` : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={dateRange?.from ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-9 justify-start text-left font-normal"
|
||||
>
|
||||
<CalendarIcon className="mr-2 size-4" />
|
||||
{renderDateRange()}
|
||||
{dateRange?.from && (
|
||||
<XIcon
|
||||
className="ml-2 size-4 hover:text-destructive"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDateRangeClear();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end">
|
||||
<Calendar
|
||||
mode="range"
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={dateRange}
|
||||
onSelect={onDateRangeChange}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
className="h-9 shrink-0"
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCcwIcon
|
||||
className={`size-4 ${isFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-9 shrink-0" asChild>
|
||||
<Link to="/settings" aria-label="Settings">
|
||||
<SettingsIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Settings</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{isAuthEnabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="h-9 shrink-0"
|
||||
aria-label="Logout"
|
||||
>
|
||||
<LogOutIcon className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Logout {user?.username ? `(${user.username})` : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,186 +1,323 @@
|
|||
import {
|
||||
createParser,
|
||||
parseAsInteger,
|
||||
parseAsIsoDateTime,
|
||||
parseAsString,
|
||||
useQueryStates
|
||||
createParser,
|
||||
parseAsArrayOf,
|
||||
parseAsInteger,
|
||||
parseAsIsoDateTime,
|
||||
parseAsString,
|
||||
useQueryStates,
|
||||
} from "nuqs";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import type { DateRange } from "react-day-picker";
|
||||
import type {
|
||||
GroupByOption,
|
||||
SortDirection,
|
||||
GroupByOption,
|
||||
SortColumn,
|
||||
SortDirection,
|
||||
StatsInterval,
|
||||
} from "../components/container-utils";
|
||||
|
||||
// Custom parser for SortDirection
|
||||
const parseAsSortDirection = createParser({
|
||||
parse: (value): SortDirection | null => {
|
||||
if (value === "asc" || value === "desc") {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
serialize: (value: SortDirection) => value,
|
||||
parse: (value): SortDirection | null => {
|
||||
if (value === "asc" || value === "desc") {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
serialize: (value: SortDirection) => value,
|
||||
});
|
||||
|
||||
// Custom parser for GroupByOption
|
||||
const parseAsGroupBy = createParser({
|
||||
parse: (value): GroupByOption | null => {
|
||||
if (value === "none" || value === "compose") {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
serialize: (value: GroupByOption) => value,
|
||||
parse: (value): GroupByOption | null => {
|
||||
if (value === "none" || value === "compose") {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
serialize: (value: GroupByOption) => value,
|
||||
});
|
||||
|
||||
const parseAsStatsInterval = createParser({
|
||||
parse: (value): StatsInterval | null => {
|
||||
if (value === "1h" || value === "12h") {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
serialize: (value: StatsInterval) => value,
|
||||
});
|
||||
|
||||
const parseAsSortColumn = createParser({
|
||||
parse: (value): SortColumn | null => {
|
||||
if (["name", "state", "uptime", "created", "cpu", "ram"].includes(value)) {
|
||||
return value as SortColumn;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
serialize: (value: SortColumn) => value,
|
||||
});
|
||||
|
||||
// Search params configuration with defaults
|
||||
const searchParamsConfig = {
|
||||
search: parseAsString.withDefault(""),
|
||||
state: parseAsString.withDefault("all"),
|
||||
host: parseAsString.withDefault("all"),
|
||||
sort: parseAsSortDirection.withDefault("desc" as SortDirection),
|
||||
group: parseAsGroupBy.withDefault("none" as GroupByOption),
|
||||
page: parseAsInteger.withDefault(1),
|
||||
pageSize: parseAsInteger.withDefault(10),
|
||||
from: parseAsIsoDateTime,
|
||||
to: parseAsIsoDateTime,
|
||||
search: parseAsString.withDefault(""),
|
||||
state: parseAsString.withDefault("all"),
|
||||
host: parseAsString.withDefault("all"),
|
||||
sort: parseAsSortDirection.withDefault("desc" as SortDirection),
|
||||
sortBy: parseAsSortColumn.withDefault("created" as SortColumn),
|
||||
group: parseAsGroupBy.withDefault("none" as GroupByOption),
|
||||
interval: parseAsStatsInterval.withDefault("1h" as StatsInterval),
|
||||
page: parseAsInteger.withDefault(1),
|
||||
pageSize: parseAsInteger.withDefault(10),
|
||||
from: parseAsIsoDateTime,
|
||||
to: parseAsIsoDateTime,
|
||||
expanded: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
};
|
||||
|
||||
export function useContainersDashboardUrlState() {
|
||||
const [params, setParams] = useQueryStates(searchParamsConfig, {
|
||||
history: "replace",
|
||||
});
|
||||
type DashboardUrlParams = {
|
||||
search: string;
|
||||
state: string;
|
||||
host: string;
|
||||
sort: SortDirection;
|
||||
sortBy: SortColumn;
|
||||
group: GroupByOption;
|
||||
interval: StatsInterval;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
from: Date | null;
|
||||
to: Date | null;
|
||||
expanded: string[];
|
||||
};
|
||||
|
||||
const {
|
||||
search: searchTerm,
|
||||
state: stateFilter,
|
||||
host: hostFilter,
|
||||
sort: sortDirection,
|
||||
group: groupBy,
|
||||
page,
|
||||
pageSize,
|
||||
from,
|
||||
to,
|
||||
} = params;
|
||||
function areStringArraysEqual(left: string[], right: string[]) {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert from/to into DateRange format
|
||||
// Supports open-ended ranges: from without to, to without from, or both
|
||||
const dateRange = useMemo((): DateRange | undefined => {
|
||||
if (!from && !to) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { from: from ?? undefined, to: to ?? undefined };
|
||||
}, [from, to]);
|
||||
|
||||
const setSearchTerm = useCallback(
|
||||
(value: string) => {
|
||||
setParams({
|
||||
search: value,
|
||||
page: 1,
|
||||
});
|
||||
},
|
||||
[setParams]
|
||||
);
|
||||
|
||||
const setStateFilter = useCallback(
|
||||
(value: string) => {
|
||||
const normalized = value || "all";
|
||||
setParams({
|
||||
state: normalized,
|
||||
page: 1,
|
||||
});
|
||||
},
|
||||
[setParams]
|
||||
);
|
||||
|
||||
const setHostFilter = useCallback(
|
||||
(value: string) => {
|
||||
const normalized = value || "all";
|
||||
setParams({
|
||||
host: normalized,
|
||||
page: 1,
|
||||
});
|
||||
},
|
||||
[setParams]
|
||||
);
|
||||
|
||||
const setSortDirection = useCallback(
|
||||
(value: SortDirection) => {
|
||||
setParams({
|
||||
sort: value,
|
||||
});
|
||||
},
|
||||
[setParams]
|
||||
);
|
||||
|
||||
const setGroupBy = useCallback(
|
||||
(value: GroupByOption) => {
|
||||
setParams({
|
||||
group: value,
|
||||
page: 1,
|
||||
});
|
||||
},
|
||||
[setParams]
|
||||
);
|
||||
|
||||
const setDateRange = useCallback(
|
||||
(range: DateRange | undefined) => {
|
||||
setParams({
|
||||
from: range?.from ?? null,
|
||||
to: range?.to ?? null,
|
||||
page: 1,
|
||||
});
|
||||
},
|
||||
[setParams]
|
||||
);
|
||||
|
||||
const clearDateRange = useCallback(() => {
|
||||
setParams({
|
||||
from: null,
|
||||
to: null,
|
||||
page: 1,
|
||||
});
|
||||
}, [setParams]);
|
||||
|
||||
const setPage = useCallback(
|
||||
(value: number) => {
|
||||
setParams({
|
||||
page: Math.max(1, Math.floor(value)),
|
||||
});
|
||||
},
|
||||
[setParams]
|
||||
);
|
||||
|
||||
const setPageSize = useCallback(
|
||||
(value: number) => {
|
||||
setParams({
|
||||
pageSize: Math.max(1, Math.floor(value)),
|
||||
page: 1,
|
||||
});
|
||||
},
|
||||
[setParams]
|
||||
);
|
||||
|
||||
return {
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
stateFilter,
|
||||
setStateFilter,
|
||||
hostFilter,
|
||||
setHostFilter,
|
||||
sortDirection,
|
||||
setSortDirection,
|
||||
groupBy,
|
||||
setGroupBy,
|
||||
dateRange,
|
||||
setDateRange,
|
||||
clearDateRange,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
};
|
||||
return left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function areDatesEqual(left: Date | null, right: Date | null) {
|
||||
if (left === right) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!left || !right) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return left.getTime() === right.getTime();
|
||||
}
|
||||
|
||||
function isDashboardParamEqual(
|
||||
currentValue: DashboardUrlParams[keyof DashboardUrlParams],
|
||||
nextValue: DashboardUrlParams[keyof DashboardUrlParams],
|
||||
) {
|
||||
if (Array.isArray(currentValue) && Array.isArray(nextValue)) {
|
||||
return areStringArraysEqual(currentValue, nextValue);
|
||||
}
|
||||
|
||||
if (
|
||||
(currentValue instanceof Date || currentValue === null) &&
|
||||
(nextValue instanceof Date || nextValue === null)
|
||||
) {
|
||||
return areDatesEqual(currentValue, nextValue);
|
||||
}
|
||||
|
||||
return currentValue === nextValue;
|
||||
}
|
||||
|
||||
export function hasDashboardParamChanges(
|
||||
current: DashboardUrlParams,
|
||||
updates: Partial<DashboardUrlParams>,
|
||||
) {
|
||||
return Object.entries(updates).some(([key, value]) => {
|
||||
const typedKey = key as keyof DashboardUrlParams;
|
||||
return !isDashboardParamEqual(
|
||||
current[typedKey],
|
||||
value as DashboardUrlParams[keyof DashboardUrlParams],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function useContainersDashboardUrlState() {
|
||||
const [params, setParams] = useQueryStates(searchParamsConfig, {
|
||||
history: "replace",
|
||||
});
|
||||
|
||||
const {
|
||||
search: searchTerm,
|
||||
state: stateFilter,
|
||||
host: hostFilter,
|
||||
sort: sortDirection,
|
||||
sortBy,
|
||||
group: groupBy,
|
||||
interval: statsInterval,
|
||||
page,
|
||||
pageSize,
|
||||
from,
|
||||
to,
|
||||
expanded: expandedGroups,
|
||||
} = params;
|
||||
|
||||
const updateParams = useCallback(
|
||||
(updates: Partial<DashboardUrlParams>) => {
|
||||
if (hasDashboardParamChanges(params as DashboardUrlParams, updates)) {
|
||||
setParams(updates);
|
||||
}
|
||||
},
|
||||
[params, setParams],
|
||||
);
|
||||
|
||||
// Convert from/to into DateRange format
|
||||
// Supports open-ended ranges: from without to, to without from, or both
|
||||
const dateRange = useMemo((): DateRange | undefined => {
|
||||
if (!from && !to) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { from: from ?? undefined, to: to ?? undefined };
|
||||
}, [from, to]);
|
||||
|
||||
const setSearchTerm = useCallback(
|
||||
(value: string) => {
|
||||
updateParams({
|
||||
search: value,
|
||||
page: 1,
|
||||
});
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const setStateFilter = useCallback(
|
||||
(value: string) => {
|
||||
const normalized = value || "all";
|
||||
updateParams({
|
||||
state: normalized,
|
||||
page: 1,
|
||||
});
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const setHostFilter = useCallback(
|
||||
(value: string) => {
|
||||
const normalized = value || "all";
|
||||
updateParams({
|
||||
host: normalized,
|
||||
page: 1,
|
||||
});
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const setSortDirection = useCallback(
|
||||
(value: SortDirection) => {
|
||||
updateParams({
|
||||
sort: value,
|
||||
});
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const setSortBy = useCallback(
|
||||
(value: SortColumn) => {
|
||||
updateParams({
|
||||
sortBy: value,
|
||||
});
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const setGroupBy = useCallback(
|
||||
(value: GroupByOption) => {
|
||||
updateParams({
|
||||
group: value,
|
||||
page: 1,
|
||||
});
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const setStatsInterval = useCallback(
|
||||
(value: StatsInterval) => {
|
||||
updateParams({
|
||||
interval: value,
|
||||
});
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const setDateRange = useCallback(
|
||||
(range: DateRange | undefined) => {
|
||||
updateParams({
|
||||
from: range?.from ?? null,
|
||||
to: range?.to ?? null,
|
||||
page: 1,
|
||||
});
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const clearDateRange = useCallback(() => {
|
||||
updateParams({
|
||||
from: null,
|
||||
to: null,
|
||||
page: 1,
|
||||
});
|
||||
}, [updateParams]);
|
||||
|
||||
const setPage = useCallback(
|
||||
(value: number) => {
|
||||
updateParams({
|
||||
page: Math.max(1, Math.floor(value)),
|
||||
});
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const setPageSize = useCallback(
|
||||
(value: number) => {
|
||||
updateParams({
|
||||
pageSize: Math.max(1, Math.floor(value)),
|
||||
page: 1,
|
||||
});
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const setExpandedGroups = useCallback(
|
||||
(value: string[]) => {
|
||||
updateParams({
|
||||
expanded: value,
|
||||
});
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
return {
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
stateFilter,
|
||||
setStateFilter,
|
||||
hostFilter,
|
||||
setHostFilter,
|
||||
sortDirection,
|
||||
setSortDirection,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
groupBy,
|
||||
setGroupBy,
|
||||
statsInterval,
|
||||
setStatsInterval,
|
||||
dateRange,
|
||||
setDateRange,
|
||||
clearDateRange,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
expandedGroups,
|
||||
setExpandedGroups,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,12 +14,19 @@ export interface ContainerInfo {
|
|||
status: string
|
||||
labels?: Record<string, string>
|
||||
host: string
|
||||
historical_stats?: {
|
||||
cpu_1h: number
|
||||
memory_1h: number
|
||||
cpu_12h: number
|
||||
memory_12h: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ContainersQueryParams {
|
||||
search?: string
|
||||
state?: string
|
||||
sortCreated?: "asc" | "desc"
|
||||
sortBy?: "name" | "state" | "uptime" | "created" | "cpu" | "ram"
|
||||
groupBy?: "none" | "compose"
|
||||
host?: string
|
||||
}
|
||||
|
|
|
|||
37
frontend/src/features/settings/api/test-bot.ts
Normal file
37
frontend/src/features/settings/api/test-bot.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { authenticatedFetch } from "@/lib/api-client";
|
||||
import { API_BASE_URL } from "@/types/api";
|
||||
|
||||
const ENDPOINT = `${API_BASE_URL}/api/v1/settings/test/bot`;
|
||||
|
||||
export async function testBot(telegramToken: string, allowedChatId: string) {
|
||||
const response = await authenticatedFetch(ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ telegramToken, allowedChatId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || "Failed to test bot");
|
||||
}
|
||||
|
||||
return response.json() as Promise<{ success: boolean; message: string }>;
|
||||
}
|
||||
|
||||
export async function testDiscordBot(botToken: string, allowedChannelId: string) {
|
||||
const response = await authenticatedFetch(
|
||||
`${API_BASE_URL}/api/v1/settings/test/discord-bot`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ botToken, allowedChannelId }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || "Failed to test Discord bot");
|
||||
}
|
||||
|
||||
return response.json() as Promise<{ success: boolean; message: string }>;
|
||||
}
|
||||
34
frontend/src/features/settings/api/update-bot.ts
Normal file
34
frontend/src/features/settings/api/update-bot.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { authenticatedFetch } from "@/lib/api-client";
|
||||
import { API_BASE_URL } from "@/types/api";
|
||||
|
||||
const ENDPOINT = `${API_BASE_URL}/api/v1/settings/bot`;
|
||||
|
||||
export interface UpdateBotPayload {
|
||||
enabled: boolean;
|
||||
mode: "polling" | "jwt-relay";
|
||||
telegramToken: string;
|
||||
allowedChatId: string;
|
||||
discord: {
|
||||
enabled: boolean;
|
||||
botToken: string;
|
||||
applicationId: string;
|
||||
guildId: string;
|
||||
allowedChannelId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateBot(payload: UpdateBotPayload): Promise<string> {
|
||||
const response = await authenticatedFetch(ENDPOINT, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || "Failed to update bot settings");
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { message?: string };
|
||||
return data.message ?? "Bot settings updated";
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { BotSection } from "./bot-section";
|
||||
import type { BotConfig } from "../types";
|
||||
|
||||
vi.mock("../hooks/use-settings", () => ({
|
||||
useUpdateBot: () => ({ isPending: false, mutate: vi.fn() }),
|
||||
useTestBot: () => ({ isPending: false, mutate: vi.fn() }),
|
||||
useTestDiscordBot: () => ({ isPending: false, mutate: vi.fn() }),
|
||||
}));
|
||||
|
||||
const baseConfig: BotConfig = {
|
||||
source: "file",
|
||||
enabled: true,
|
||||
mode: "polling",
|
||||
telegramTokenConfigured: true,
|
||||
allowedChatId: "123",
|
||||
relayPath: "/api/v1/bot/relay/command",
|
||||
relayUsesAuth: true,
|
||||
discord: {
|
||||
enabled: false,
|
||||
botToken: "",
|
||||
applicationId: "",
|
||||
guildId: "",
|
||||
allowedChannelId: "",
|
||||
},
|
||||
};
|
||||
|
||||
describe("BotSection", () => {
|
||||
it("uses a masked token placeholder when the token is configured", () => {
|
||||
render(<BotSection config={baseConfig} />);
|
||||
|
||||
expect((screen.getByLabelText("Telegram token") as HTMLInputElement).value).toBe("••••••••");
|
||||
});
|
||||
|
||||
it("disables controls for mixed env-backed bot config", () => {
|
||||
render(<BotSection config={{ ...baseConfig, source: "mixed" }} />);
|
||||
|
||||
expect((screen.getByLabelText("Telegram token") as HTMLInputElement).disabled).toBe(true);
|
||||
expect(screen.queryByRole("button", { name: /save changes/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
374
frontend/src/features/settings/components/bot-section.tsx
Normal file
374
frontend/src/features/settings/components/bot-section.tsx
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
import {
|
||||
useTestBot,
|
||||
useTestDiscordBot,
|
||||
useUpdateBot,
|
||||
} from "../hooks/use-settings";
|
||||
import type { BotConfig } from "../types";
|
||||
import { EnvBadge } from "./env-badge";
|
||||
|
||||
const MASKED_TOKEN = "••••••••";
|
||||
|
||||
interface BotSectionProps {
|
||||
config: BotConfig;
|
||||
disabled?: boolean;
|
||||
authEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function BotSection({
|
||||
config,
|
||||
disabled = false,
|
||||
authEnabled = false,
|
||||
}: BotSectionProps) {
|
||||
const isEnvBacked = config.source === "env" || config.source === "mixed";
|
||||
const displayTelegramToken = config.telegramTokenConfigured ? MASKED_TOKEN : "";
|
||||
const [enabled, setEnabled] = useState(config.enabled);
|
||||
const [mode, setMode] = useState(config.mode);
|
||||
const [telegramToken, setTelegramToken] = useState(displayTelegramToken);
|
||||
const [allowedChatId, setAllowedChatId] = useState(config.allowedChatId);
|
||||
const [discordEnabled, setDiscordEnabled] = useState(config.discord.enabled);
|
||||
const [discordBotToken, setDiscordBotToken] = useState(config.discord.botToken);
|
||||
const [discordApplicationId, setDiscordApplicationId] = useState(
|
||||
config.discord.applicationId,
|
||||
);
|
||||
const [discordGuildId, setDiscordGuildId] = useState(config.discord.guildId);
|
||||
const [discordAllowedChannelId, setDiscordAllowedChannelId] = useState(
|
||||
config.discord.allowedChannelId,
|
||||
);
|
||||
|
||||
const updateMutation = useUpdateBot();
|
||||
const testMutation = useTestBot();
|
||||
const discordTestMutation = useTestDiscordBot();
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(config.enabled);
|
||||
setMode(config.mode);
|
||||
setTelegramToken(config.telegramTokenConfigured ? MASKED_TOKEN : "");
|
||||
setAllowedChatId(config.allowedChatId);
|
||||
setDiscordEnabled(config.discord.enabled);
|
||||
setDiscordBotToken(config.discord.botToken);
|
||||
setDiscordApplicationId(config.discord.applicationId);
|
||||
setDiscordGuildId(config.discord.guildId);
|
||||
setDiscordAllowedChannelId(config.discord.allowedChannelId);
|
||||
}, [config]);
|
||||
|
||||
const hasChanges =
|
||||
enabled !== config.enabled ||
|
||||
mode !== config.mode ||
|
||||
telegramToken !== displayTelegramToken ||
|
||||
allowedChatId !== config.allowedChatId ||
|
||||
discordEnabled !== config.discord.enabled ||
|
||||
discordBotToken !== config.discord.botToken ||
|
||||
discordApplicationId !== config.discord.applicationId ||
|
||||
discordGuildId !== config.discord.guildId ||
|
||||
discordAllowedChannelId !== config.discord.allowedChannelId;
|
||||
|
||||
const controlsDisabled =
|
||||
disabled ||
|
||||
isEnvBacked ||
|
||||
updateMutation.isPending ||
|
||||
testMutation.isPending ||
|
||||
discordTestMutation.isPending;
|
||||
|
||||
const handleSave = () => {
|
||||
updateMutation.mutate(
|
||||
{
|
||||
enabled,
|
||||
mode,
|
||||
telegramToken,
|
||||
allowedChatId,
|
||||
discord: {
|
||||
enabled: discordEnabled,
|
||||
botToken: discordBotToken,
|
||||
applicationId: discordApplicationId,
|
||||
guildId: discordGuildId,
|
||||
allowedChannelId: discordAllowedChannelId,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (message) => toast.success(message),
|
||||
onError: (error) => toast.error(error.message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleTest = () => {
|
||||
testMutation.mutate(
|
||||
{ telegramToken, allowedChatId },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDiscordTest = () => {
|
||||
discordTestMutation.mutate(
|
||||
{
|
||||
botToken: discordBotToken,
|
||||
allowedChannelId: discordAllowedChannelId,
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle>Telegram Bot</CardTitle>
|
||||
{isEnvBacked && <EnvBadge />}
|
||||
</div>
|
||||
<CardDescription>
|
||||
Configure the Telegram bot for `/help`, `/status`, and `/critical`
|
||||
commands.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
id="bot-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
disabled={controlsDisabled}
|
||||
/>
|
||||
<Label htmlFor="bot-enabled" className="cursor-pointer">
|
||||
{enabled ? "Enabled" : "Disabled"}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="bot-mode">Mode</Label>
|
||||
<Select
|
||||
value={mode}
|
||||
onValueChange={(value) => setMode(value as BotConfig["mode"])}
|
||||
disabled={controlsDisabled}
|
||||
>
|
||||
<SelectTrigger id="bot-mode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="polling">Polling</SelectItem>
|
||||
<SelectItem value="jwt-relay" disabled={!authEnabled}>
|
||||
JWT Relay
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="telegram-token">Telegram token</Label>
|
||||
<Input
|
||||
id="telegram-token"
|
||||
value={telegramToken}
|
||||
onChange={(event) => setTelegramToken(event.target.value)}
|
||||
disabled={controlsDisabled}
|
||||
type="password"
|
||||
placeholder="123456:ABC..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="allowed-chat-id">Allowed chat ID</Label>
|
||||
<Input
|
||||
id="allowed-chat-id"
|
||||
value={allowedChatId}
|
||||
onChange={(event) => setAllowedChatId(event.target.value)}
|
||||
disabled={controlsDisabled}
|
||||
placeholder="123456789"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === "jwt-relay" && (
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-sm">
|
||||
<p className="font-medium">JWT relay</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Relay path:{" "}
|
||||
<span className="font-mono">{config.relayPath}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Protected by existing auth:{" "}
|
||||
{config.relayUsesAuth ? "yes" : "no"}
|
||||
</p>
|
||||
{!authEnabled && (
|
||||
<p className="mt-2 text-destructive">
|
||||
Enable dashboard auth before using JWT relay mode.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEnvBacked && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || controlsDisabled}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<>
|
||||
<Spinner className="size-3" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save changes"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleTest}
|
||||
disabled={controlsDisabled}
|
||||
>
|
||||
{testMutation.isPending ? "Testing..." : "Send test"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle>Discord Bot</CardTitle>
|
||||
{isEnvBacked && <EnvBadge />}
|
||||
</div>
|
||||
<CardDescription>
|
||||
Configure Discord slash commands for `/help`, `/status`, and
|
||||
`/critical`.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
id="discord-bot-enabled"
|
||||
checked={discordEnabled}
|
||||
onCheckedChange={setDiscordEnabled}
|
||||
disabled={controlsDisabled}
|
||||
/>
|
||||
<Label htmlFor="discord-bot-enabled" className="cursor-pointer">
|
||||
{discordEnabled ? "Enabled" : "Disabled"}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="discord-bot-token">Bot token</Label>
|
||||
<Input
|
||||
id="discord-bot-token"
|
||||
value={discordBotToken}
|
||||
onChange={(event) => setDiscordBotToken(event.target.value)}
|
||||
disabled={controlsDisabled}
|
||||
type="password"
|
||||
placeholder="MTA..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="discord-application-id">Application ID</Label>
|
||||
<Input
|
||||
id="discord-application-id"
|
||||
value={discordApplicationId}
|
||||
onChange={(event) => setDiscordApplicationId(event.target.value)}
|
||||
disabled={controlsDisabled}
|
||||
placeholder="123456789012345678"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="discord-guild-id">Guild ID</Label>
|
||||
<Input
|
||||
id="discord-guild-id"
|
||||
value={discordGuildId}
|
||||
onChange={(event) => setDiscordGuildId(event.target.value)}
|
||||
disabled={controlsDisabled}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="discord-channel-id">Allowed channel ID</Label>
|
||||
<Input
|
||||
id="discord-channel-id"
|
||||
value={discordAllowedChannelId}
|
||||
onChange={(event) =>
|
||||
setDiscordAllowedChannelId(event.target.value)
|
||||
}
|
||||
disabled={controlsDisabled}
|
||||
placeholder="123456789012345678"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-sm text-muted-foreground">
|
||||
Slash command responses are ephemeral. Set a guild ID to register
|
||||
commands to one server immediately; leave it blank for global
|
||||
command registration.
|
||||
</div>
|
||||
|
||||
{!isEnvBacked && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || controlsDisabled}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<>
|
||||
<Spinner className="size-3" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save changes"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleDiscordTest}
|
||||
disabled={controlsDisabled}
|
||||
>
|
||||
{discordTestMutation.isPending ? "Testing..." : "Send test"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,49 +2,55 @@ import { Spinner } from "@/components/ui/spinner";
|
|||
|
||||
import { useSettings } from "../hooks/use-settings";
|
||||
import { AuthSection } from "./auth-section";
|
||||
import { BotSection } from "./bot-section";
|
||||
import { CoolifyHostsSection } from "./coolify-hosts-section";
|
||||
import { DockerHostsSection } from "./docker-hosts-section";
|
||||
import { ReadOnlySection } from "./read-only-section";
|
||||
import { ScannerSection } from "./scanner-section";
|
||||
|
||||
export function SettingsPage() {
|
||||
const { data, isLoading, error } = useSettings();
|
||||
const { data, isLoading, error } = useSettings();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto max-w-3xl px-4 py-8">
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load settings: {error.message}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto max-w-3xl px-4 py-8">
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load settings: {error.message}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-3xl px-4 py-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage VPS Monitor configuration. Sections marked as set via environment
|
||||
variable can only be changed by updating the environment and
|
||||
restarting.
|
||||
</p>
|
||||
</div>
|
||||
<DockerHostsSection config={data.dockerHosts} />
|
||||
<CoolifyHostsSection config={data.coolifyHosts} />
|
||||
<ReadOnlySection config={data.readOnly} />
|
||||
<AuthSection config={data.auth} />
|
||||
<ScannerSection disabled={data.readOnly.value} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="container mx-auto max-w-3xl px-4 py-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage VPS Monitor configuration. Sections marked as set via
|
||||
environment variable can only be changed by updating the environment
|
||||
and restarting.
|
||||
</p>
|
||||
</div>
|
||||
<DockerHostsSection config={data.dockerHosts} />
|
||||
<CoolifyHostsSection config={data.coolifyHosts} />
|
||||
<ReadOnlySection config={data.readOnly} />
|
||||
<AuthSection config={data.auth} />
|
||||
<BotSection
|
||||
config={data.bot}
|
||||
disabled={data.readOnly.value}
|
||||
authEnabled={data.auth.enabled}
|
||||
/>
|
||||
<ScannerSection disabled={data.readOnly.value} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,31 +6,37 @@ import { requireAuthIfEnabled } from "@/lib/auth-guard";
|
|||
|
||||
// Define search params schema for the dashboard
|
||||
const dashboardSearchSchema = z
|
||||
.object({
|
||||
search: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
sort: z.enum(["asc", "desc"]).optional(),
|
||||
group: z.enum(["none", "compose"]).optional(),
|
||||
page: z.number().optional(),
|
||||
pageSize: z.number().optional(),
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.catch({});
|
||||
.object({
|
||||
search: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
host: z.string().optional(),
|
||||
sort: z.enum(["asc", "desc"]).optional(),
|
||||
sortBy: z
|
||||
.enum(["name", "state", "uptime", "created", "cpu", "ram"])
|
||||
.optional(),
|
||||
group: z.enum(["none", "compose"]).optional(),
|
||||
interval: z.enum(["1h", "12h"]).optional(),
|
||||
expanded: z.array(z.string()).optional(),
|
||||
page: z.number().optional(),
|
||||
pageSize: z.number().optional(),
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.catch({});
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
validateSearch: dashboardSearchSchema.parse,
|
||||
beforeLoad: async () => {
|
||||
await requireAuthIfEnabled();
|
||||
},
|
||||
component: Index,
|
||||
validateSearch: dashboardSearchSchema.parse,
|
||||
beforeLoad: async () => {
|
||||
await requireAuthIfEnabled();
|
||||
},
|
||||
component: Index,
|
||||
});
|
||||
|
||||
function Index() {
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<ContainersDashboard />
|
||||
</main>
|
||||
);
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<ContainersDashboard />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
19
home/internal/alerts/monitor_test.go
Normal file
19
home/internal/alerts/monitor_test.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package alerts
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hhftechnology/vps-monitor/internal/models"
|
||||
)
|
||||
|
||||
func TestIsCriticalAlertMatchesThresholdAlertsOnly(t *testing.T) {
|
||||
if !isCriticalAlert(models.Alert{Type: models.AlertCPUThreshold}) {
|
||||
t.Fatal("expected CPU threshold alerts to be critical")
|
||||
}
|
||||
if !isCriticalAlert(models.Alert{Type: models.AlertMemoryThreshold}) {
|
||||
t.Fatal("expected memory threshold alerts to be critical")
|
||||
}
|
||||
if isCriticalAlert(models.Alert{Type: models.AlertContainerStopped}) {
|
||||
t.Fatal("expected container stopped alerts to be excluded from critical-only filtering")
|
||||
}
|
||||
}
|
||||
71
home/internal/api/bot_handlers.go
Normal file
71
home/internal/api/bot_handlers.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/hhftechnology/vps-monitor/internal/bot"
|
||||
"github.com/hhftechnology/vps-monitor/internal/config"
|
||||
)
|
||||
|
||||
func (ar *APIRouter) RelayBotCommand(w http.ResponseWriter, r *http.Request) {
|
||||
if ar.botService == nil {
|
||||
http.Error(w, "bot service unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if svc := ar.registry.Auth(); svc == nil || svc.IsDisabled() {
|
||||
http.Error(w, "auth must be enabled before using bot relay mode", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
if ar.registry.Config().Bot.Mode != config.BotModeJWTRelay {
|
||||
http.Error(w, "bot relay mode is disabled", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Text string `json:"text"`
|
||||
ChatID string `json:"chatId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
req.Text = strings.TrimSpace(req.Text)
|
||||
if req.Text == "" {
|
||||
http.Error(w, "text is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
reply, err := ar.botService.RelayCommand(r.Context(), req.ChatID, req.Text)
|
||||
if err != nil {
|
||||
status, message := relayBotCommandErrorResponse(err)
|
||||
log.Printf("bot relay command failed: %v", err)
|
||||
http.Error(w, message, status)
|
||||
return
|
||||
}
|
||||
|
||||
WriteJsonResponse(w, http.StatusOK, map[string]any{
|
||||
"message": "Command relayed",
|
||||
"reply": reply,
|
||||
})
|
||||
}
|
||||
|
||||
func relayBotCommandErrorResponse(err error) (int, string) {
|
||||
switch {
|
||||
case errors.Is(err, bot.ErrRelayDisabled):
|
||||
return http.StatusConflict, "bot relay mode is disabled"
|
||||
case errors.Is(err, bot.ErrRelayNotConfigured):
|
||||
return http.StatusConflict, "bot relay is not configured"
|
||||
case errors.Is(err, bot.ErrRelayChatNotAllowed):
|
||||
return http.StatusForbidden, "chat id is not allowed"
|
||||
case errors.Is(err, bot.ErrTelegramSendFailed):
|
||||
return http.StatusBadGateway, "failed to send Telegram message"
|
||||
default:
|
||||
return http.StatusBadRequest, "invalid bot relay command"
|
||||
}
|
||||
}
|
||||
180
home/internal/api/bot_handlers_test.go
Normal file
180
home/internal/api/bot_handlers_test.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hhftechnology/vps-monitor/internal/auth"
|
||||
"github.com/hhftechnology/vps-monitor/internal/bot"
|
||||
"github.com/hhftechnology/vps-monitor/internal/config"
|
||||
"github.com/hhftechnology/vps-monitor/internal/services"
|
||||
)
|
||||
|
||||
type fakeBotRelayService struct {
|
||||
reply string
|
||||
err error
|
||||
gotText string
|
||||
gotChat string
|
||||
callCount int
|
||||
}
|
||||
|
||||
func (f *fakeBotRelayService) RelayCommand(_ context.Context, chatID, text string) (string, error) {
|
||||
f.callCount++
|
||||
f.gotChat = chatID
|
||||
f.gotText = text
|
||||
return f.reply, f.err
|
||||
}
|
||||
|
||||
func TestRelayBotCommandRejectsWhenAuthDisabled(t *testing.T) {
|
||||
router := &APIRouter{
|
||||
registry: services.NewRegistry(nil, nil, auth.NewDisabledService(), &config.Config{
|
||||
Bot: config.BotConfig{Enabled: true, Mode: config.BotModeJWTRelay},
|
||||
}, nil),
|
||||
botService: &fakeBotRelayService{},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/bot/relay/command", strings.NewReader(`{"text":"/help"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
router.RelayBotCommand(rec, req)
|
||||
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("expected %d, got %d", http.StatusConflict, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayBotCommandReturnsReply(t *testing.T) {
|
||||
relay := &fakeBotRelayService{reply: "ok"}
|
||||
router := &APIRouter{
|
||||
registry: services.NewRegistry(nil, nil, newUsableAuthService(t), &config.Config{
|
||||
Bot: config.BotConfig{Enabled: true, Mode: config.BotModeJWTRelay},
|
||||
}, nil),
|
||||
botService: relay,
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/bot/relay/command", strings.NewReader(`{"text":" /help ","chatId":"123"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
router.RelayBotCommand(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected %d, got %d", http.StatusOK, rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Fatalf("expected application/json content type, got %q", ct)
|
||||
}
|
||||
var body struct {
|
||||
Message string `json:"message"`
|
||||
Reply string `json:"reply"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if body.Message != "Command relayed" || body.Reply != "ok" {
|
||||
t.Fatalf("unexpected response body: %+v", body)
|
||||
}
|
||||
if relay.gotText != "/help" || relay.gotChat != "123" {
|
||||
t.Fatalf("expected trimmed command and chat id, got text=%q chat=%q", relay.gotText, relay.gotChat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayBotCommandRejectsNilBotService(t *testing.T) {
|
||||
router := &APIRouter{}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/bot/relay/command", strings.NewReader(`{"text":"/help"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
router.RelayBotCommand(rec, req)
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected %d, got %d", http.StatusServiceUnavailable, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayBotCommandRejectsWhenModeIsNotRelay(t *testing.T) {
|
||||
router := &APIRouter{
|
||||
registry: services.NewRegistry(nil, nil, newUsableAuthService(t), &config.Config{
|
||||
Bot: config.BotConfig{Enabled: true, Mode: config.BotModePolling},
|
||||
}, nil),
|
||||
botService: &fakeBotRelayService{},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/bot/relay/command", strings.NewReader(`{"text":"/help"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
router.RelayBotCommand(rec, req)
|
||||
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("expected %d, got %d", http.StatusConflict, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayBotCommandRejectsInvalidRequestBody(t *testing.T) {
|
||||
router := newRelayBotCommandTestRouter(t, &fakeBotRelayService{})
|
||||
|
||||
for _, body := range []string{`{`, `{"text":""}`, `{"text":" "}`} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/bot/relay/command", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
router.RelayBotCommand(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected %d for body %q, got %d", http.StatusBadRequest, body, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayBotCommandMapsRelayErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want int
|
||||
body string
|
||||
}{
|
||||
{name: "unknown", err: errors.New("internal detail"), want: http.StatusBadRequest, body: "invalid bot relay command"},
|
||||
{name: "disabled", err: bot.ErrRelayDisabled, want: http.StatusConflict, body: "bot relay mode is disabled"},
|
||||
{name: "not configured", err: bot.ErrRelayNotConfigured, want: http.StatusConflict, body: "bot relay is not configured"},
|
||||
{name: "chat not allowed", err: bot.ErrRelayChatNotAllowed, want: http.StatusForbidden, body: "chat id is not allowed"},
|
||||
{name: "send failed", err: bot.ErrTelegramSendFailed, want: http.StatusBadGateway, body: "failed to send Telegram message"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
router := newRelayBotCommandTestRouter(t, &fakeBotRelayService{err: tt.err})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/bot/relay/command", strings.NewReader(`{"text":"/help"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
router.RelayBotCommand(rec, req)
|
||||
|
||||
if rec.Code != tt.want {
|
||||
t.Fatalf("expected %d, got %d", tt.want, rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), tt.body) {
|
||||
t.Fatalf("expected sanitized body %q, got %q", tt.body, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newRelayBotCommandTestRouter(t *testing.T, relay *fakeBotRelayService) *APIRouter {
|
||||
t.Helper()
|
||||
return &APIRouter{
|
||||
registry: services.NewRegistry(nil, nil, newUsableAuthService(t), &config.Config{
|
||||
Bot: config.BotConfig{Enabled: true, Mode: config.BotModeJWTRelay},
|
||||
}, nil),
|
||||
botService: relay,
|
||||
}
|
||||
}
|
||||
|
||||
func newUsableAuthService(t *testing.T) *auth.Service {
|
||||
t.Helper()
|
||||
hash, err := auth.HashPassword("secret")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword() error = %v", err)
|
||||
}
|
||||
return auth.NewServiceFromFileConfig(&config.FileAuthConfig{
|
||||
Enabled: true,
|
||||
JWTSecret: "jwt-secret",
|
||||
AdminUsername: "admin",
|
||||
AdminPasswordHash: hash,
|
||||
})
|
||||
}
|
||||
145
home/internal/api/container_handlers_test.go
Normal file
145
home/internal/api/container_handlers_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/hhftechnology/vps-monitor/internal/config"
|
||||
"github.com/hhftechnology/vps-monitor/internal/models"
|
||||
"github.com/hhftechnology/vps-monitor/internal/scanner"
|
||||
"github.com/hhftechnology/vps-monitor/internal/services"
|
||||
)
|
||||
|
||||
func TestGetContainerHistoricalStatsReturnsPersistedDataWithoutAlerts(t *testing.T) {
|
||||
db := newTestAPIScanDB(t)
|
||||
now := time.Now().UTC()
|
||||
|
||||
for _, sample := range []models.ContainerStats{
|
||||
{
|
||||
ContainerID: "container-1",
|
||||
Host: "host-a",
|
||||
CPUPercent: 12,
|
||||
MemoryPercent: 34,
|
||||
MemoryUsage: 512,
|
||||
MemoryLimit: 1024,
|
||||
Timestamp: now.Add(-30 * time.Minute).Unix(),
|
||||
},
|
||||
{
|
||||
ContainerID: "container-1",
|
||||
Host: "host-a",
|
||||
CPUPercent: 20,
|
||||
MemoryPercent: 40,
|
||||
MemoryUsage: 768,
|
||||
MemoryLimit: 1024,
|
||||
Timestamp: now.Add(-15 * time.Minute).Unix(),
|
||||
},
|
||||
} {
|
||||
if err := db.InsertContainerStat(sample); err != nil {
|
||||
t.Fatalf("InsertContainerStat() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
router := &APIRouter{
|
||||
registry: services.NewRegistry(nil, nil, nil, &config.Config{}, nil),
|
||||
statsDB: db,
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/containers/container-1/stats/history?host=host-a", nil)
|
||||
req = withURLParam(req, "id", "container-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.GetContainerHistoricalStats(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var history models.HistoricalAverages
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &history); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
if !history.HasData {
|
||||
t.Fatal("expected historical data")
|
||||
}
|
||||
if len(history.Samples) != 2 {
|
||||
t.Fatalf("expected 2 bootstrap samples, got %d", len(history.Samples))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetContainerHistoricalStatsValidatesHostBeforeStatsDB(t *testing.T) {
|
||||
router := &APIRouter{
|
||||
registry: services.NewRegistry(nil, nil, nil, &config.Config{}, nil),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/containers/container-1/stats/history", nil)
|
||||
req = withURLParam(req, "id", "container-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.GetContainerHistoricalStats(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected %d, got %d: %s", http.StatusBadRequest, rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.String() != "host parameter is required\n" {
|
||||
t.Fatalf("unexpected body: %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichContainersWithHistoricalStatsUsesDatabase(t *testing.T) {
|
||||
db := newTestAPIScanDB(t)
|
||||
now := time.Now().UTC()
|
||||
|
||||
if err := db.InsertContainerStat(models.ContainerStats{
|
||||
ContainerID: "container-1",
|
||||
Host: "host-a",
|
||||
CPUPercent: 18,
|
||||
MemoryPercent: 42,
|
||||
Timestamp: now.Add(-20 * time.Minute).Unix(),
|
||||
}); err != nil {
|
||||
t.Fatalf("InsertContainerStat() error = %v", err)
|
||||
}
|
||||
|
||||
router := &APIRouter{statsDB: db}
|
||||
containers := []models.ContainerInfo{
|
||||
{
|
||||
ID: "container-1",
|
||||
Host: "host-a",
|
||||
Names: []string{"/api"},
|
||||
},
|
||||
}
|
||||
|
||||
router.enrichContainersWithHistoricalStats(containers)
|
||||
|
||||
if containers[0].HistoricalStats == nil {
|
||||
t.Fatal("expected historical stats to be attached")
|
||||
}
|
||||
if containers[0].HistoricalStats.CPU1h != 18 || containers[0].HistoricalStats.Memory1h != 42 {
|
||||
t.Fatalf("unexpected historical stats: %+v", containers[0].HistoricalStats)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestAPIScanDB(t *testing.T) *scanner.ScanDB {
|
||||
t.Helper()
|
||||
|
||||
db, err := scanner.NewScanDB(t.TempDir() + "/scan.db")
|
||||
if err != nil {
|
||||
t.Fatalf("NewScanDB() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = db.Close()
|
||||
})
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func withURLParam(req *http.Request, key, value string) *http.Request {
|
||||
routeContext := chi.NewRouteContext()
|
||||
routeContext.URLParams.Add(key, value)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeContext))
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
166
home/internal/bot/command.go
Normal file
166
home/internal/bot/command.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hhftechnology/vps-monitor/internal/models"
|
||||
"github.com/hhftechnology/vps-monitor/internal/services"
|
||||
)
|
||||
|
||||
type commandHandler struct {
|
||||
registry *services.Registry
|
||||
}
|
||||
|
||||
func newCommandHandler(registry *services.Registry) *commandHandler {
|
||||
return &commandHandler{registry: registry}
|
||||
}
|
||||
|
||||
func (h *commandHandler) handle(text string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(text, "/help"), strings.HasPrefix(text, "/start"):
|
||||
return "Available commands:\n/status - current container health with history\n/critical - latest critical alerts\n/help - command list"
|
||||
case strings.HasPrefix(text, "/critical"):
|
||||
return h.buildCriticalMessage()
|
||||
case strings.HasPrefix(text, "/status"):
|
||||
return h.buildStatusMessage()
|
||||
default:
|
||||
return "Unknown command. Use /help."
|
||||
}
|
||||
}
|
||||
|
||||
func (h *commandHandler) buildCriticalMessage() string {
|
||||
if h.registry == nil {
|
||||
return "Alert monitoring is disabled."
|
||||
}
|
||||
|
||||
monitor := h.registry.Alerts()
|
||||
if monitor == nil {
|
||||
return "Alert monitoring is disabled."
|
||||
}
|
||||
|
||||
alertsList := monitor.GetHistory().GetAll()
|
||||
critical := make([]models.Alert, 0, len(alertsList))
|
||||
for _, alert := range alertsList {
|
||||
if alert.Type == models.AlertCPUThreshold || alert.Type == models.AlertMemoryThreshold {
|
||||
critical = append(critical, alert)
|
||||
}
|
||||
}
|
||||
|
||||
if len(critical) == 0 {
|
||||
return "No critical alerts."
|
||||
}
|
||||
|
||||
sort.SliceStable(critical, func(i, j int) bool {
|
||||
return critical[i].Timestamp > critical[j].Timestamp
|
||||
})
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, "Critical alerts:")
|
||||
for _, alert := range critical[:min(5, len(critical))] {
|
||||
lines = append(lines, fmt.Sprintf("- %s on %s (%s)", alert.ContainerName, alert.Host, alert.Type))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (h *commandHandler) buildStatusMessage() string {
|
||||
if h.registry == nil {
|
||||
return "Docker client unavailable."
|
||||
}
|
||||
|
||||
dockerClient, release := h.registry.AcquireDocker()
|
||||
defer release()
|
||||
if dockerClient == nil {
|
||||
return "Docker client unavailable."
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
containersMap, _, err := dockerClient.ListContainersAllHosts(ctx)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Failed to list containers: %v", err)
|
||||
}
|
||||
|
||||
type containerLine struct {
|
||||
name string
|
||||
cpu float64
|
||||
line string
|
||||
}
|
||||
|
||||
var lines []containerLine
|
||||
var linesMu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
total := 0
|
||||
running := 0
|
||||
history := h.registry.Alerts()
|
||||
var historyManager interface {
|
||||
Get1hAverages(string, string) (float64, float64, bool)
|
||||
Get12hAverages(string, string) (float64, float64, bool)
|
||||
}
|
||||
if history != nil {
|
||||
historyManager = history.GetStatsHistory()
|
||||
}
|
||||
|
||||
for hostName, containers := range containersMap {
|
||||
for _, ctr := range containers {
|
||||
total++
|
||||
if ctr.State != "running" {
|
||||
continue
|
||||
}
|
||||
running++
|
||||
|
||||
wg.Add(1)
|
||||
go func(hName string, c models.ContainerInfo) {
|
||||
defer wg.Done()
|
||||
stats, err := dockerClient.GetContainerStatsOnce(ctx, hName, c.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
name := c.ID[:12]
|
||||
if len(c.Names) > 0 {
|
||||
name = strings.TrimPrefix(c.Names[0], "/")
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("- %s@%s CPU %.1f%% MEM %.1f%%", name, hName, stats.CPUPercent, stats.MemoryPercent)
|
||||
if historyManager != nil {
|
||||
cpu1h, mem1h, has1h := historyManager.Get1hAverages(hName, c.ID)
|
||||
cpu12h, mem12h, has12h := historyManager.Get12hAverages(hName, c.ID)
|
||||
line = appendHistoryAverages(line, cpu1h, mem1h, has1h, cpu12h, mem12h, has12h)
|
||||
}
|
||||
|
||||
linesMu.Lock()
|
||||
lines = append(lines, containerLine{name: name, cpu: stats.CPUPercent, line: line})
|
||||
linesMu.Unlock()
|
||||
}(hostName, ctr)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
sort.SliceStable(lines, func(i, j int) bool {
|
||||
return lines[i].cpu > lines[j].cpu
|
||||
})
|
||||
|
||||
message := []string{
|
||||
fmt.Sprintf("Containers: %d total, %d running", total, running),
|
||||
}
|
||||
for _, line := range lines[:min(5, len(lines))] {
|
||||
message = append(message, line.line)
|
||||
}
|
||||
return strings.Join(message, "\n")
|
||||
}
|
||||
|
||||
func appendHistoryAverages(line string, cpu1h, mem1h float64, has1h bool, cpu12h, mem12h float64, has12h bool) string {
|
||||
if has1h {
|
||||
line += fmt.Sprintf(" | 1h %.1f/%.1f", cpu1h, mem1h)
|
||||
}
|
||||
if has12h {
|
||||
line += fmt.Sprintf(" | 12h %.1f/%.1f", cpu12h, mem12h)
|
||||
}
|
||||
return line
|
||||
}
|
||||
24
home/internal/bot/command_test.go
Normal file
24
home/internal/bot/command_test.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package bot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppendHistoryAveragesIncludesOnlyAvailableWindows(t *testing.T) {
|
||||
line := appendHistoryAverages("container", 0, 0, false, 12, 34, true)
|
||||
if strings.Contains(line, "1h") {
|
||||
t.Fatalf("did not expect 1h segment, got %q", line)
|
||||
}
|
||||
if !strings.Contains(line, "12h 12.0/34.0") {
|
||||
t.Fatalf("expected 12h segment, got %q", line)
|
||||
}
|
||||
|
||||
line = appendHistoryAverages("container", 10, 20, true, 0, 0, false)
|
||||
if !strings.Contains(line, "1h 10.0/20.0") {
|
||||
t.Fatalf("expected 1h segment, got %q", line)
|
||||
}
|
||||
if strings.Contains(line, "12h") {
|
||||
t.Fatalf("did not expect 12h segment, got %q", line)
|
||||
}
|
||||
}
|
||||
485
home/internal/bot/discord.go
Normal file
485
home/internal/bot/discord.go
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
package bot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/hhftechnology/vps-monitor/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
discordAPIBase = "https://discord.com/api/v10"
|
||||
|
||||
discordOpDispatch = 0
|
||||
discordOpHeartbeat = 1
|
||||
discordOpIdentify = 2
|
||||
discordOpResume = 6
|
||||
discordOpReconnect = 7
|
||||
discordOpInvalidSession = 9
|
||||
discordOpHello = 10
|
||||
discordOpHeartbeatACK = 11
|
||||
|
||||
discordInteractionApplicationCommand = 2
|
||||
discordResponseDeferredMessage = 5
|
||||
discordMessageFlagEphemeral = 64
|
||||
)
|
||||
|
||||
type websocketDialer interface {
|
||||
Dial(urlStr string, requestHeader http.Header) (*websocket.Conn, *http.Response, error)
|
||||
}
|
||||
|
||||
type discordGatewayResponse struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type discordGatewayPayload struct {
|
||||
Op int `json:"op"`
|
||||
D json.RawMessage `json:"d"`
|
||||
S *int64 `json:"s,omitempty"`
|
||||
T string `json:"t,omitempty"`
|
||||
}
|
||||
|
||||
type discordHelloPayload struct {
|
||||
HeartbeatInterval int `json:"heartbeat_interval"`
|
||||
}
|
||||
|
||||
type discordReadyPayload struct {
|
||||
SessionID string `json:"session_id"`
|
||||
ResumeGatewayURL string `json:"resume_gateway_url"`
|
||||
}
|
||||
|
||||
type discordInteraction struct {
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token"`
|
||||
Type int `json:"type"`
|
||||
GuildID string `json:"guild_id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Data struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type discordCommand struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Type int `json:"type"`
|
||||
}
|
||||
|
||||
func (s *Service) startDiscordLocked() {
|
||||
if s.discordRunning || !isDiscordConfigured(s.cfg.Discord) {
|
||||
return
|
||||
}
|
||||
|
||||
cfg := s.cfg
|
||||
s.discordStopCh = make(chan struct{})
|
||||
s.discordDoneCh = make(chan struct{})
|
||||
s.discordRunning = true
|
||||
|
||||
go s.discordLoop(cfg, s.discordStopCh, s.discordDoneCh)
|
||||
}
|
||||
|
||||
func (s *Service) stopDiscord() {
|
||||
s.mu.Lock()
|
||||
if !s.discordRunning {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
stopCh := s.discordStopCh
|
||||
doneCh := s.discordDoneCh
|
||||
s.discordRunning = false
|
||||
s.discordStopCh = nil
|
||||
s.discordDoneCh = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
close(stopCh)
|
||||
<-doneCh
|
||||
}
|
||||
|
||||
func (s *Service) discordLoop(cfg config.BotConfig, stopCh <-chan struct{}, doneCh chan<- struct{}) {
|
||||
defer close(doneCh)
|
||||
|
||||
if err := s.registerDiscordCommands(context.Background(), cfg.Discord); err != nil {
|
||||
log.Printf("discord bot command registration failed: %v", err)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stopCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if err := s.runDiscordConnection(cfg, stopCh); err != nil {
|
||||
log.Printf("discord bot gateway failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
case <-stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) runDiscordConnection(cfg config.BotConfig, stopCh <-chan struct{}) error {
|
||||
gatewayURL, err := s.discordGatewayURL(context.Background(), cfg.Discord.BotToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessionID, resumeURL, _ := s.discordSession()
|
||||
connectURL := gatewayURL
|
||||
resuming := sessionID != "" && resumeURL != ""
|
||||
if resuming {
|
||||
connectURL = resumeURL
|
||||
}
|
||||
if !strings.Contains(connectURL, "?") {
|
||||
connectURL += "?v=10&encoding=json"
|
||||
}
|
||||
|
||||
conn, _, err := s.discordDialer.Dial(connectURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
stopped := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-stopCh:
|
||||
_ = conn.Close()
|
||||
case <-stopped:
|
||||
}
|
||||
}()
|
||||
defer close(stopped)
|
||||
|
||||
var writeMu sync.Mutex
|
||||
ackMu := sync.Mutex{}
|
||||
heartbeatAcked := true
|
||||
heartbeatStop := make(chan struct{})
|
||||
defer close(heartbeatStop)
|
||||
|
||||
for {
|
||||
var payload discordGatewayPayload
|
||||
if err := conn.ReadJSON(&payload); err != nil {
|
||||
select {
|
||||
case <-stopCh:
|
||||
return nil
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if payload.S != nil {
|
||||
s.setDiscordSeq(*payload.S)
|
||||
}
|
||||
|
||||
switch payload.Op {
|
||||
case discordOpHello:
|
||||
var hello discordHelloPayload
|
||||
if err := json.Unmarshal(payload.D, &hello); err != nil {
|
||||
return err
|
||||
}
|
||||
if hello.HeartbeatInterval <= 0 {
|
||||
return fmt.Errorf("discord gateway hello missing heartbeat interval")
|
||||
}
|
||||
go s.discordHeartbeatLoop(conn, &writeMu, &ackMu, &heartbeatAcked, time.Duration(hello.HeartbeatInterval)*time.Millisecond, heartbeatStop)
|
||||
if resuming {
|
||||
if err := s.discordResume(conn, &writeMu, cfg.Discord.BotToken, sessionID); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := s.discordIdentify(conn, &writeMu, cfg.Discord.BotToken); err != nil {
|
||||
return err
|
||||
}
|
||||
case discordOpHeartbeatACK:
|
||||
ackMu.Lock()
|
||||
heartbeatAcked = true
|
||||
ackMu.Unlock()
|
||||
case discordOpHeartbeat:
|
||||
if err := s.discordSendHeartbeat(conn, &writeMu); err != nil {
|
||||
return err
|
||||
}
|
||||
case discordOpReconnect:
|
||||
return fmt.Errorf("discord gateway requested reconnect")
|
||||
case discordOpInvalidSession:
|
||||
s.clearDiscordSession()
|
||||
return fmt.Errorf("discord gateway invalidated session")
|
||||
case discordOpDispatch:
|
||||
if payload.T == "READY" {
|
||||
var ready discordReadyPayload
|
||||
if err := json.Unmarshal(payload.D, &ready); err != nil {
|
||||
return err
|
||||
}
|
||||
s.setDiscordSession(ready.SessionID, ready.ResumeGatewayURL)
|
||||
}
|
||||
if payload.T == "INTERACTION_CREATE" {
|
||||
var interaction discordInteraction
|
||||
if err := json.Unmarshal(payload.D, &interaction); err != nil {
|
||||
log.Printf("discord interaction decode failed: %v", err)
|
||||
continue
|
||||
}
|
||||
go s.handleDiscordInteraction(context.Background(), cfg.Discord, interaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) discordHeartbeatLoop(conn *websocket.Conn, writeMu, ackMu *sync.Mutex, acked *bool, interval time.Duration, stopCh <-chan struct{}) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
if err := s.discordSendHeartbeat(conn, writeMu); err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
ackMu.Lock()
|
||||
*acked = false
|
||||
ackMu.Unlock()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
ackMu.Lock()
|
||||
if !*acked {
|
||||
ackMu.Unlock()
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
*acked = false
|
||||
ackMu.Unlock()
|
||||
|
||||
if err := s.discordSendHeartbeat(conn, writeMu); err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
case <-stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) discordIdentify(conn *websocket.Conn, writeMu *sync.Mutex, token string) error {
|
||||
payload := map[string]any{
|
||||
"op": discordOpIdentify,
|
||||
"d": map[string]any{
|
||||
"token": token,
|
||||
"intents": 0,
|
||||
"properties": map[string]string{
|
||||
"os": "linux",
|
||||
"browser": "vps-monitor",
|
||||
"device": "vps-monitor",
|
||||
},
|
||||
},
|
||||
}
|
||||
return discordWriteJSON(conn, writeMu, payload)
|
||||
}
|
||||
|
||||
func (s *Service) discordResume(conn *websocket.Conn, writeMu *sync.Mutex, token, sessionID string) error {
|
||||
payload := map[string]any{
|
||||
"op": discordOpResume,
|
||||
"d": map[string]any{
|
||||
"token": token,
|
||||
"session_id": sessionID,
|
||||
"seq": s.discordSeq(),
|
||||
},
|
||||
}
|
||||
return discordWriteJSON(conn, writeMu, payload)
|
||||
}
|
||||
|
||||
func (s *Service) discordSendHeartbeat(conn *websocket.Conn, writeMu *sync.Mutex) error {
|
||||
return discordWriteJSON(conn, writeMu, map[string]any{
|
||||
"op": discordOpHeartbeat,
|
||||
"d": s.discordSeq(),
|
||||
})
|
||||
}
|
||||
|
||||
func discordWriteJSON(conn *websocket.Conn, writeMu *sync.Mutex, payload any) error {
|
||||
writeMu.Lock()
|
||||
defer writeMu.Unlock()
|
||||
return conn.WriteJSON(payload)
|
||||
}
|
||||
|
||||
func (s *Service) discordGatewayURL(ctx context.Context, token string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.discordAPIBase+"/gateway/bot", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bot "+token)
|
||||
|
||||
res, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("discord gateway lookup returned status %d", res.StatusCode)
|
||||
}
|
||||
|
||||
var payload discordGatewayResponse
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if payload.URL == "" {
|
||||
return "", fmt.Errorf("discord gateway lookup returned empty url")
|
||||
}
|
||||
return payload.URL, nil
|
||||
}
|
||||
|
||||
func (s *Service) registerDiscordCommands(ctx context.Context, cfg config.DiscordBotConfig) error {
|
||||
commands := []discordCommand{
|
||||
{Name: "help", Description: "Show VPS Monitor bot commands", Type: 1},
|
||||
{Name: "status", Description: "Show current container health with history", Type: 1},
|
||||
{Name: "critical", Description: "Show latest critical alerts", Type: 1},
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/applications/%s/commands", s.discordAPIBase, cfg.ApplicationID)
|
||||
if cfg.GuildID != "" {
|
||||
endpoint = fmt.Sprintf("%s/applications/%s/guilds/%s/commands", s.discordAPIBase, cfg.ApplicationID, cfg.GuildID)
|
||||
}
|
||||
|
||||
return s.doDiscordJSON(ctx, http.MethodPut, endpoint, cfg.BotToken, commands, nil)
|
||||
}
|
||||
|
||||
func (s *Service) handleDiscordInteraction(ctx context.Context, cfg config.DiscordBotConfig, interaction discordInteraction) {
|
||||
if interaction.Type != discordInteractionApplicationCommand {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deferDiscordInteraction(ctx, cfg, interaction); err != nil {
|
||||
log.Printf("discord interaction defer failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
reply := s.discordInteractionReply(cfg, interaction)
|
||||
if reply == "" {
|
||||
reply = "No response."
|
||||
}
|
||||
if err := s.editDiscordInteractionResponse(ctx, cfg, interaction.Token, reply); err != nil {
|
||||
log.Printf("discord interaction response failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) discordInteractionReply(cfg config.DiscordBotConfig, interaction discordInteraction) string {
|
||||
if cfg.GuildID != "" && interaction.GuildID != cfg.GuildID {
|
||||
return "This Discord server is not allowed."
|
||||
}
|
||||
if cfg.AllowedChannelID != "" && interaction.ChannelID != cfg.AllowedChannelID {
|
||||
return "This Discord channel is not allowed."
|
||||
}
|
||||
|
||||
switch interaction.Data.Name {
|
||||
case "help", "status", "critical":
|
||||
return s.commands.handle("/" + interaction.Data.Name)
|
||||
default:
|
||||
return "Unknown command. Use /help."
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) deferDiscordInteraction(ctx context.Context, cfg config.DiscordBotConfig, interaction discordInteraction) error {
|
||||
endpoint := fmt.Sprintf("%s/interactions/%s/%s/callback", s.discordAPIBase, interaction.ID, interaction.Token)
|
||||
payload := map[string]any{
|
||||
"type": discordResponseDeferredMessage,
|
||||
"data": map[string]any{"flags": discordMessageFlagEphemeral},
|
||||
}
|
||||
return s.doDiscordJSON(ctx, http.MethodPost, endpoint, cfg.BotToken, payload, nil)
|
||||
}
|
||||
|
||||
func (s *Service) editDiscordInteractionResponse(ctx context.Context, cfg config.DiscordBotConfig, token, content string) error {
|
||||
endpoint := fmt.Sprintf("%s/webhooks/%s/%s/messages/@original", s.discordAPIBase, cfg.ApplicationID, token)
|
||||
payload := map[string]any{
|
||||
"content": content,
|
||||
"flags": discordMessageFlagEphemeral,
|
||||
}
|
||||
return s.doDiscordJSON(ctx, http.MethodPatch, endpoint, cfg.BotToken, payload, nil)
|
||||
}
|
||||
|
||||
func (s *Service) SendDiscordTestMessage(ctx context.Context, token, channelID string) error {
|
||||
if strings.TrimSpace(token) == "" || strings.TrimSpace(channelID) == "" {
|
||||
return fmt.Errorf("discord bot token and channel id are required")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/channels/%s/messages", s.discordAPIBase, strings.TrimSpace(channelID))
|
||||
payload := map[string]string{"content": "VPS Monitor Discord bot test successful."}
|
||||
return s.doDiscordJSON(ctx, http.MethodPost, endpoint, strings.TrimSpace(token), payload, nil)
|
||||
}
|
||||
|
||||
func (s *Service) doDiscordJSON(ctx context.Context, method, endpoint, token string, payload, out any) error {
|
||||
var body *bytes.Reader
|
||||
if payload == nil {
|
||||
body = bytes.NewReader(nil)
|
||||
} else {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bot "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
res, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode >= 300 {
|
||||
return fmt.Errorf("discord %s returned status %d", method, res.StatusCode)
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(res.Body).Decode(out)
|
||||
}
|
||||
|
||||
func (s *Service) discordSession() (string, string, int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.discordSessionID, s.discordResumeURL, s.discordLastSeq
|
||||
}
|
||||
|
||||
func (s *Service) setDiscordSession(sessionID, resumeURL string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.discordSessionID = sessionID
|
||||
s.discordResumeURL = resumeURL
|
||||
}
|
||||
|
||||
func (s *Service) clearDiscordSession() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.discordSessionID = ""
|
||||
s.discordResumeURL = ""
|
||||
s.discordLastSeq = 0
|
||||
}
|
||||
|
||||
func (s *Service) discordSeq() int64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.discordLastSeq
|
||||
}
|
||||
|
||||
func (s *Service) setDiscordSeq(seq int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.discordLastSeq = seq
|
||||
}
|
||||
|
||||
func isDiscordConfigured(cfg config.DiscordBotConfig) bool {
|
||||
return cfg.Enabled &&
|
||||
strings.TrimSpace(cfg.BotToken) != "" &&
|
||||
strings.TrimSpace(cfg.ApplicationID) != "" &&
|
||||
strings.TrimSpace(cfg.AllowedChannelID) != ""
|
||||
}
|
||||
310
home/internal/bot/telegram.go
Normal file
310
home/internal/bot/telegram.go
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/hhftechnology/vps-monitor/internal/config"
|
||||
"github.com/hhftechnology/vps-monitor/internal/services"
|
||||
)
|
||||
|
||||
const telegramAPIBase = "https://api.telegram.org"
|
||||
|
||||
var (
|
||||
ErrRelayDisabled = errors.New("bot relay mode is disabled")
|
||||
ErrRelayNotConfigured = errors.New("telegram token and allowed chat id are required")
|
||||
ErrRelayChatNotAllowed = errors.New("chat id is not allowed")
|
||||
ErrTelegramSendFailed = errors.New("telegram send failed")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
restartMu sync.Mutex
|
||||
registry *services.Registry
|
||||
commands *commandHandler
|
||||
client *http.Client
|
||||
discordAPIBase string
|
||||
discordDialer websocketDialer
|
||||
cfg config.BotConfig
|
||||
running bool
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
offset int64
|
||||
discordRunning bool
|
||||
discordStopCh chan struct{}
|
||||
discordDoneCh chan struct{}
|
||||
discordSessionID string
|
||||
discordResumeURL string
|
||||
discordLastSeq int64
|
||||
}
|
||||
|
||||
type telegramUpdateResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Result []telegramUpdate `json:"result"`
|
||||
}
|
||||
|
||||
type telegramUpdate struct {
|
||||
UpdateID int64 `json:"update_id"`
|
||||
Message *telegramMessage `json:"message"`
|
||||
}
|
||||
|
||||
type telegramMessage struct {
|
||||
MessageID int64 `json:"message_id"`
|
||||
Text string `json:"text"`
|
||||
Chat telegramChat `json:"chat"`
|
||||
}
|
||||
|
||||
type telegramChat struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func NewService(registry *services.Registry, cfg config.BotConfig) *Service {
|
||||
return &Service{
|
||||
registry: registry,
|
||||
client: &http.Client{
|
||||
Timeout: 35 * time.Second,
|
||||
},
|
||||
commands: newCommandHandler(registry),
|
||||
discordAPIBase: discordAPIBase,
|
||||
discordDialer: websocket.DefaultDialer,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Start() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.running && isConfigured(s.cfg) && s.cfg.Mode == config.BotModePolling {
|
||||
s.stopCh = make(chan struct{})
|
||||
s.doneCh = make(chan struct{})
|
||||
s.running = true
|
||||
|
||||
go s.pollLoop(s.cfg, s.stopCh, s.doneCh)
|
||||
}
|
||||
|
||||
s.startDiscordLocked()
|
||||
}
|
||||
|
||||
func (s *Service) Stop() {
|
||||
s.stopDiscord()
|
||||
|
||||
s.mu.Lock()
|
||||
if !s.running {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
stopCh := s.stopCh
|
||||
doneCh := s.doneCh
|
||||
s.running = false
|
||||
s.stopCh = nil
|
||||
s.doneCh = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
close(stopCh)
|
||||
<-doneCh
|
||||
}
|
||||
|
||||
func (s *Service) UpdateConfig(cfg config.BotConfig) {
|
||||
s.restartMu.Lock()
|
||||
defer s.restartMu.Unlock()
|
||||
|
||||
s.Stop()
|
||||
|
||||
s.mu.Lock()
|
||||
s.cfg = cfg
|
||||
s.offset = 0
|
||||
s.discordLastSeq = 0
|
||||
s.discordSessionID = ""
|
||||
s.discordResumeURL = ""
|
||||
s.mu.Unlock()
|
||||
|
||||
s.Start()
|
||||
}
|
||||
|
||||
func (s *Service) RelayCommand(ctx context.Context, chatID, text string) (string, error) {
|
||||
s.mu.Lock()
|
||||
cfg := s.cfg
|
||||
s.mu.Unlock()
|
||||
|
||||
if cfg.Mode != config.BotModeJWTRelay {
|
||||
return "", ErrRelayDisabled
|
||||
}
|
||||
if !isConfigured(cfg) {
|
||||
return "", ErrRelayNotConfigured
|
||||
}
|
||||
|
||||
text = strings.TrimSpace(text)
|
||||
targetChatID := strings.TrimSpace(chatID)
|
||||
if targetChatID == "" {
|
||||
targetChatID = cfg.AllowedChatID
|
||||
}
|
||||
if cfg.AllowedChatID != "" && targetChatID != cfg.AllowedChatID {
|
||||
return "", ErrRelayChatNotAllowed
|
||||
}
|
||||
|
||||
reply := s.commands.handle(text)
|
||||
if reply == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if err := s.sendMessage(ctx, cfg.TelegramToken, targetChatID, reply); err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrTelegramSendFailed, err)
|
||||
}
|
||||
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func (s *Service) SendTestMessage(ctx context.Context, token, chatID string) error {
|
||||
if strings.TrimSpace(token) == "" || strings.TrimSpace(chatID) == "" {
|
||||
return fmt.Errorf("telegram token and chat id are required")
|
||||
}
|
||||
|
||||
return s.sendMessage(ctx, token, chatID, "VPS Monitor bot test successful.")
|
||||
}
|
||||
|
||||
func (s *Service) pollLoop(cfg config.BotConfig, stopCh <-chan struct{}, doneCh chan<- struct{}) {
|
||||
defer close(doneCh)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() {
|
||||
select {
|
||||
case <-stopCh:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if err := s.pollOnce(ctx, cfg); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
log.Printf("telegram bot poll failed: %v", err)
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) pollOnce(ctx context.Context, cfg config.BotConfig) error {
|
||||
params := url.Values{}
|
||||
params.Set("timeout", strconv.Itoa(int(cfg.PollInterval.Seconds())))
|
||||
|
||||
s.mu.Lock()
|
||||
offset := s.offset
|
||||
s.mu.Unlock()
|
||||
if offset > 0 {
|
||||
params.Set("offset", strconv.FormatInt(offset, 10))
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.apiURL(cfg.TelegramToken, "getUpdates", params), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return sanitizeError(err, cfg.TelegramToken)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
return fmt.Errorf("telegram getUpdates returned status %d", res.StatusCode)
|
||||
}
|
||||
|
||||
var payload telegramUpdateResponse
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, update := range payload.Result {
|
||||
s.mu.Lock()
|
||||
if update.UpdateID >= s.offset {
|
||||
s.offset = update.UpdateID + 1
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if update.Message == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if cfg.AllowedChatID != "" && strconv.FormatInt(update.Message.Chat.ID, 10) != cfg.AllowedChatID {
|
||||
continue
|
||||
}
|
||||
|
||||
reply := s.commands.handle(strings.TrimSpace(update.Message.Text))
|
||||
if reply == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.sendMessage(ctx, cfg.TelegramToken, strconv.FormatInt(update.Message.Chat.ID, 10), reply); err != nil {
|
||||
log.Printf("telegram bot send failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) sendMessage(ctx context.Context, token, chatID, text string) error {
|
||||
form := url.Values{}
|
||||
form.Set("chat_id", chatID)
|
||||
form.Set("text", text)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.apiURL(token, "sendMessage", nil), strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
res, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return sanitizeError(err, token)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode >= 300 {
|
||||
return fmt.Errorf("telegram sendMessage returned status %d", res.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) apiURL(token, method string, params url.Values) string {
|
||||
base := fmt.Sprintf("%s/bot%s/%s", telegramAPIBase, token, method)
|
||||
if params == nil || len(params) == 0 {
|
||||
return base
|
||||
}
|
||||
return base + "?" + params.Encode()
|
||||
}
|
||||
|
||||
func isConfigured(cfg config.BotConfig) bool {
|
||||
return cfg.Enabled && strings.TrimSpace(cfg.TelegramToken) != "" && strings.TrimSpace(cfg.AllowedChatID) != ""
|
||||
}
|
||||
|
||||
func sanitizeError(err error, token string) error {
|
||||
if err == nil || token == "" {
|
||||
return err
|
||||
}
|
||||
return errors.New(strings.ReplaceAll(err.Error(), token, "***"))
|
||||
}
|
||||
179
home/internal/bot/telegram_test.go
Normal file
179
home/internal/bot/telegram_test.go
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hhftechnology/vps-monitor/internal/config"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
func TestStartSkipsPollingInRelayMode(t *testing.T) {
|
||||
svc := NewService(nil, config.BotConfig{
|
||||
Enabled: true,
|
||||
Mode: config.BotModeJWTRelay,
|
||||
TelegramToken: "token",
|
||||
AllowedChatID: "chat",
|
||||
})
|
||||
|
||||
svc.Start()
|
||||
|
||||
if svc.running {
|
||||
t.Fatal("expected relay mode to skip polling startup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayCommandRejectsUnexpectedChat(t *testing.T) {
|
||||
svc := NewService(nil, config.BotConfig{
|
||||
Enabled: true,
|
||||
Mode: config.BotModeJWTRelay,
|
||||
TelegramToken: "token",
|
||||
AllowedChatID: "chat-1",
|
||||
})
|
||||
|
||||
_, err := svc.RelayCommand(context.Background(), "chat-2", "/help")
|
||||
if !errors.Is(err, ErrRelayChatNotAllowed) {
|
||||
t.Fatalf("expected not allowed error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayCommandSendsReplyViaTelegram(t *testing.T) {
|
||||
var gotChatID string
|
||||
var gotText string
|
||||
|
||||
svc := NewService(nil, config.BotConfig{
|
||||
Enabled: true,
|
||||
Mode: config.BotModeJWTRelay,
|
||||
TelegramToken: "token",
|
||||
AllowedChatID: "chat-1",
|
||||
})
|
||||
svc.client = &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gotChatID = values.Get("chat_id")
|
||||
gotText = values.Get("text")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"ok":true}`)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
reply, err := svc.RelayCommand(context.Background(), "", "/help")
|
||||
if err != nil {
|
||||
t.Fatalf("RelayCommand returned error: %v", err)
|
||||
}
|
||||
if gotChatID != "chat-1" {
|
||||
t.Fatalf("expected chat-1, got %q", gotChatID)
|
||||
}
|
||||
if gotText == "" || gotText != reply {
|
||||
t.Fatalf("expected reply to be sent, got text=%q reply=%q", gotText, reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollOnceUsesCancellableContext(t *testing.T) {
|
||||
svc := NewService(nil, config.BotConfig{
|
||||
Enabled: true,
|
||||
Mode: config.BotModePolling,
|
||||
TelegramToken: "token",
|
||||
AllowedChatID: "chat-1",
|
||||
})
|
||||
svc.client = &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
<-req.Context().Done()
|
||||
return nil, req.Context().Err()
|
||||
}),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := svc.pollOnce(ctx, svc.cfg)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected context canceled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedCommandHandlerKeepsTelegramHelpReply(t *testing.T) {
|
||||
reply := newCommandHandler(nil).handle("/help")
|
||||
if !strings.Contains(reply, "/status") || !strings.Contains(reply, "/critical") {
|
||||
t.Fatalf("expected help reply to list existing commands, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscordInteractionReplyRejectsUnexpectedChannel(t *testing.T) {
|
||||
svc := NewService(nil, config.BotConfig{})
|
||||
var interaction discordInteraction
|
||||
interaction.Type = discordInteractionApplicationCommand
|
||||
interaction.ChannelID = "channel-2"
|
||||
interaction.Data.Name = "help"
|
||||
|
||||
reply := svc.discordInteractionReply(config.DiscordBotConfig{
|
||||
Enabled: true,
|
||||
BotToken: "token",
|
||||
ApplicationID: "app",
|
||||
AllowedChannelID: "channel-1",
|
||||
}, interaction)
|
||||
if !strings.Contains(reply, "channel is not allowed") {
|
||||
t.Fatalf("expected channel rejection, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendDiscordTestMessagePostsToChannel(t *testing.T) {
|
||||
var gotAuth string
|
||||
var gotPath string
|
||||
var gotContent string
|
||||
|
||||
svc := NewService(nil, config.BotConfig{})
|
||||
svc.client = &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
gotAuth = req.Header.Get("Authorization")
|
||||
gotPath = req.URL.Path
|
||||
var body struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gotContent = body.Content
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"id":"message-1"}`)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
svc.discordAPIBase = "https://discord.test"
|
||||
|
||||
if err := svc.SendDiscordTestMessage(context.Background(), "token-1", "channel-1"); err != nil {
|
||||
t.Fatalf("SendDiscordTestMessage returned error: %v", err)
|
||||
}
|
||||
if gotAuth != "Bot token-1" {
|
||||
t.Fatalf("unexpected auth header %q", gotAuth)
|
||||
}
|
||||
if gotPath != "/channels/channel-1/messages" {
|
||||
t.Fatalf("unexpected path %q", gotPath)
|
||||
}
|
||||
if !strings.Contains(gotContent, "VPS Monitor Discord bot test successful") {
|
||||
t.Fatalf("unexpected content %q", gotContent)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestErrEnvironmentConfiguredSentinel ensures the sentinel error is defined and
|
||||
|
|
@ -713,4 +714,112 @@ func TestNewManagerSetsEnvSnapshotForResourceLimitVars(t *testing.T) {
|
|||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBotConfigPersistsAndMerges(t *testing.T) {
|
||||
m := &Manager{
|
||||
envSnapshot: EnvSnapshot{},
|
||||
envConfig: NewConfig(),
|
||||
filePath: filepath.Join(t.TempDir(), "config.json"),
|
||||
}
|
||||
m.merged, m.sources = m.merge()
|
||||
|
||||
enabled := true
|
||||
if err := m.UpdateBotConfig(&FileBotConfig{
|
||||
Enabled: &enabled,
|
||||
Mode: BotModeJWTRelay,
|
||||
TelegramToken: "token-1",
|
||||
AllowedChatID: "chat-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateBotConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
merged := m.Config()
|
||||
if !merged.Bot.Enabled || merged.Bot.Mode != BotModeJWTRelay || merged.Bot.TelegramToken != "token-1" || merged.Bot.AllowedChatID != "chat-1" {
|
||||
t.Fatalf("unexpected merged bot config: %+v", merged.Bot)
|
||||
}
|
||||
if m.Sources().Bot != SourceFile {
|
||||
t.Fatalf("expected bot source to be file, got %s", m.Sources().Bot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscordBotEnvConfigParsesAndDisablesWhenIncomplete(t *testing.T) {
|
||||
t.Setenv("BOT_DISCORD_ENABLED", "true")
|
||||
t.Setenv("BOT_DISCORD_TOKEN", "discord-token")
|
||||
t.Setenv("BOT_DISCORD_APPLICATION_ID", "app-1")
|
||||
t.Setenv("BOT_DISCORD_ALLOWED_CHANNEL_ID", "")
|
||||
|
||||
cfg := NewConfig()
|
||||
if cfg.Bot.Discord.Enabled {
|
||||
t.Fatalf("expected incomplete discord bot config to be disabled: %+v", cfg.Bot.Discord)
|
||||
}
|
||||
|
||||
t.Setenv("BOT_DISCORD_ALLOWED_CHANNEL_ID", "channel-1")
|
||||
cfg = NewConfig()
|
||||
if !cfg.Bot.Discord.Enabled {
|
||||
t.Fatalf("expected complete discord bot config to be enabled: %+v", cfg.Bot.Discord)
|
||||
}
|
||||
if cfg.Bot.Discord.BotToken != "discord-token" || cfg.Bot.Discord.ApplicationID != "app-1" || cfg.Bot.Discord.AllowedChannelID != "channel-1" {
|
||||
t.Fatalf("unexpected discord bot config: %+v", cfg.Bot.Discord)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertFilterNormalizesSupportedValues(t *testing.T) {
|
||||
t.Setenv("ALERTS_FILTER", " CRITICAL ")
|
||||
cfg := NewConfig()
|
||||
if cfg.Alerts.AlertsFilter != "critical" {
|
||||
t.Fatalf("expected critical filter, got %q", cfg.Alerts.AlertsFilter)
|
||||
}
|
||||
|
||||
t.Setenv("ALERTS_FILTER", "unexpected")
|
||||
cfg = NewConfig()
|
||||
if cfg.Alerts.AlertsFilter != "all" {
|
||||
t.Fatalf("expected invalid filter to default to all, got %q", cfg.Alerts.AlertsFilter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatsSampleIntervalFallsBackToAlertsInterval(t *testing.T) {
|
||||
t.Setenv("ALERTS_CHECK_INTERVAL", "45s")
|
||||
t.Setenv("STATS_SAMPLE_INTERVAL", "")
|
||||
cfg := NewConfig()
|
||||
if cfg.Stats.SampleInterval != 45*time.Second {
|
||||
t.Fatalf("expected stats interval to fall back to alerts interval, got %s", cfg.Stats.SampleInterval)
|
||||
}
|
||||
|
||||
t.Setenv("STATS_SAMPLE_INTERVAL", "2m")
|
||||
cfg = NewConfig()
|
||||
if cfg.Stats.SampleInterval != 2*time.Minute {
|
||||
t.Fatalf("expected stats interval override, got %s", cfg.Stats.SampleInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBotConfigPersistsAndMergesDiscord(t *testing.T) {
|
||||
m := &Manager{
|
||||
envSnapshot: EnvSnapshot{},
|
||||
envConfig: NewConfig(),
|
||||
filePath: filepath.Join(t.TempDir(), "config.json"),
|
||||
}
|
||||
m.merged, m.sources = m.merge()
|
||||
|
||||
enabled := true
|
||||
if err := m.UpdateBotConfig(&FileBotConfig{
|
||||
Discord: &FileDiscordBotConfig{
|
||||
Enabled: &enabled,
|
||||
BotToken: "discord-token",
|
||||
ApplicationID: "app-1",
|
||||
GuildID: "guild-1",
|
||||
AllowedChannelID: "channel-1",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateBotConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
merged := m.Config()
|
||||
if !merged.Bot.Discord.Enabled ||
|
||||
merged.Bot.Discord.BotToken != "discord-token" ||
|
||||
merged.Bot.Discord.ApplicationID != "app-1" ||
|
||||
merged.Bot.Discord.GuildID != "guild-1" ||
|
||||
merged.Bot.Discord.AllowedChannelID != "channel-1" {
|
||||
t.Fatalf("unexpected merged discord bot config: %+v", merged.Bot.Discord)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
108
home/internal/containerstats/collector.go
Normal file
108
home/internal/containerstats/collector.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package containerstats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hhftechnology/vps-monitor/internal/models"
|
||||
"github.com/hhftechnology/vps-monitor/internal/services"
|
||||
)
|
||||
|
||||
type statsStore interface {
|
||||
InsertContainerStat(stat models.ContainerStats) error
|
||||
PruneContainerStatsOlderThan(cutoff time.Time) error
|
||||
}
|
||||
|
||||
// Collector periodically samples running container stats and stores them in SQLite.
|
||||
type Collector struct {
|
||||
registry *services.Registry
|
||||
store statsStore
|
||||
interval time.Duration
|
||||
retention time.Duration
|
||||
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
lastPrune time.Time
|
||||
}
|
||||
|
||||
func NewCollector(registry *services.Registry, store statsStore, interval, retention time.Duration) *Collector {
|
||||
return &Collector{
|
||||
registry: registry,
|
||||
store: store,
|
||||
interval: interval,
|
||||
retention: retention,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) Start() {
|
||||
if c.registry == nil || c.store == nil || c.interval <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
c.wg.Add(1)
|
||||
go c.loop()
|
||||
}
|
||||
|
||||
func (c *Collector) Stop() {
|
||||
select {
|
||||
case <-c.stopCh:
|
||||
return
|
||||
default:
|
||||
close(c.stopCh)
|
||||
}
|
||||
c.wg.Wait()
|
||||
}
|
||||
|
||||
func (c *Collector) loop() {
|
||||
defer c.wg.Done()
|
||||
|
||||
c.collectOnce()
|
||||
|
||||
ticker := time.NewTicker(c.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
c.collectOnce()
|
||||
case <-c.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) collectOnce() {
|
||||
dockerClient, releaseDocker := c.registry.AcquireDocker()
|
||||
if dockerClient == nil {
|
||||
releaseDocker()
|
||||
return
|
||||
}
|
||||
defer releaseDocker()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for _, host := range dockerClient.GetHosts() {
|
||||
stats, err := dockerClient.GetAllContainersStats(ctx, host.Name)
|
||||
if err != nil {
|
||||
log.Printf("container stats collector: failed to sample host %s: %v", host.Name, err)
|
||||
continue
|
||||
}
|
||||
for _, stat := range stats {
|
||||
if err := c.store.InsertContainerStat(stat); err != nil {
|
||||
log.Printf("container stats collector: failed to persist sample for %s on %s: %v", stat.ContainerID, stat.Host, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if c.retention > 0 && (c.lastPrune.IsZero() || time.Since(c.lastPrune) >= time.Hour) {
|
||||
if err := c.store.PruneContainerStatsOlderThan(time.Now().Add(-c.retention)); err != nil {
|
||||
log.Printf("container stats collector: failed to prune old samples: %v", err)
|
||||
} else {
|
||||
c.lastPrune = time.Now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
171
home/internal/scanner/db_container_stats_test.go
Normal file
171
home/internal/scanner/db_container_stats_test.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
package scanner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hhftechnology/vps-monitor/internal/models"
|
||||
)
|
||||
|
||||
func TestNewScanDBCreatesContainerStatsTable(t *testing.T) {
|
||||
db := newTestScanDB(t)
|
||||
|
||||
var tableName string
|
||||
if err := db.db.QueryRow(
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'container_stats'`,
|
||||
).Scan(&tableName); err != nil {
|
||||
t.Fatalf("expected container_stats table to exist: %v", err)
|
||||
}
|
||||
|
||||
if tableName != "container_stats" {
|
||||
t.Fatalf("unexpected table name: %q", tableName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerStatsAveragesUseConfiguredWindows(t *testing.T) {
|
||||
db := newTestScanDB(t)
|
||||
now := time.Unix(1_700_000_000, 0).UTC()
|
||||
|
||||
samples := []models.ContainerStats{
|
||||
{
|
||||
ContainerID: "container-1",
|
||||
Host: "host-a",
|
||||
CPUPercent: 90,
|
||||
MemoryPercent: 95,
|
||||
Timestamp: now.Add(-13 * time.Hour).Unix(),
|
||||
},
|
||||
{
|
||||
ContainerID: "container-1",
|
||||
Host: "host-a",
|
||||
CPUPercent: 40,
|
||||
MemoryPercent: 60,
|
||||
Timestamp: now.Add(-2 * time.Hour).Unix(),
|
||||
},
|
||||
{
|
||||
ContainerID: "container-1",
|
||||
Host: "host-a",
|
||||
CPUPercent: 10,
|
||||
MemoryPercent: 20,
|
||||
Timestamp: now.Add(-30 * time.Minute).Unix(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, sample := range samples {
|
||||
if err := db.InsertContainerStat(sample); err != nil {
|
||||
t.Fatalf("InsertContainerStat() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
history, err := db.GetContainerHistoricalAverages("host-a", "container-1", now)
|
||||
if err != nil {
|
||||
t.Fatalf("GetContainerHistoricalAverages() error = %v", err)
|
||||
}
|
||||
|
||||
if !history.HasData {
|
||||
t.Fatal("expected historical data")
|
||||
}
|
||||
if history.CPU1h != 10 || history.Memory1h != 20 {
|
||||
t.Fatalf("unexpected 1h averages: cpu=%v memory=%v", history.CPU1h, history.Memory1h)
|
||||
}
|
||||
if history.CPU12h != 25 || history.Memory12h != 40 {
|
||||
t.Fatalf("unexpected 12h averages: cpu=%v memory=%v", history.CPU12h, history.Memory12h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRecentContainerStatsReturnsAscendingSeries(t *testing.T) {
|
||||
db := newTestScanDB(t)
|
||||
now := time.Unix(1_700_000_000, 0).UTC()
|
||||
|
||||
for _, sample := range []models.ContainerStats{
|
||||
{ContainerID: "container-1", Host: "host-a", CPUPercent: 10, Timestamp: now.Add(-3 * time.Minute).Unix()},
|
||||
{ContainerID: "container-1", Host: "host-a", CPUPercent: 20, Timestamp: now.Add(-2 * time.Minute).Unix()},
|
||||
{ContainerID: "container-1", Host: "host-a", CPUPercent: 30, Timestamp: now.Add(-1 * time.Minute).Unix()},
|
||||
} {
|
||||
if err := db.InsertContainerStat(sample); err != nil {
|
||||
t.Fatalf("InsertContainerStat() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
series, err := db.GetRecentContainerStats("host-a", "container-1", now.Add(-12*time.Hour), 2)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecentContainerStats() error = %v", err)
|
||||
}
|
||||
|
||||
if len(series) != 2 {
|
||||
t.Fatalf("expected 2 samples, got %d", len(series))
|
||||
}
|
||||
if series[0].CPUPercent != 20 || series[1].CPUPercent != 30 {
|
||||
t.Fatalf("expected ascending latest samples, got %+v", series)
|
||||
}
|
||||
if series[0].Timestamp >= series[1].Timestamp {
|
||||
t.Fatalf("expected ascending timestamps, got %d then %d", series[0].Timestamp, series[1].Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRecentContainerStatsClampsLimit(t *testing.T) {
|
||||
db := newTestScanDB(t)
|
||||
now := time.Unix(1_700_000_000, 0).UTC()
|
||||
|
||||
for i := 0; i < maxContainerStatsLimit+5; i++ {
|
||||
if err := db.InsertContainerStat(models.ContainerStats{
|
||||
ContainerID: "container-1",
|
||||
Host: "host-a",
|
||||
CPUPercent: float64(i),
|
||||
Timestamp: now.Add(time.Duration(i) * time.Second).Unix(),
|
||||
}); err != nil {
|
||||
t.Fatalf("InsertContainerStat() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
series, err := db.GetRecentContainerStats("host-a", "container-1", now.Add(-time.Hour), maxContainerStatsLimit+1000)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecentContainerStats() error = %v", err)
|
||||
}
|
||||
|
||||
if len(series) != maxContainerStatsLimit {
|
||||
t.Fatalf("expected clamped sample count %d, got %d", maxContainerStatsLimit, len(series))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneContainerStatsOlderThanRemovesExpiredRows(t *testing.T) {
|
||||
db := newTestScanDB(t)
|
||||
now := time.Unix(1_700_000_000, 0).UTC()
|
||||
|
||||
oldSample := models.ContainerStats{
|
||||
ContainerID: "container-1",
|
||||
Host: "host-a",
|
||||
CPUPercent: 10,
|
||||
MemoryPercent: 10,
|
||||
Timestamp: now.Add(-31 * 24 * time.Hour).Unix(),
|
||||
}
|
||||
newSample := models.ContainerStats{
|
||||
ContainerID: "container-1",
|
||||
Host: "host-a",
|
||||
CPUPercent: 20,
|
||||
MemoryPercent: 20,
|
||||
Timestamp: now.Add(-1 * time.Hour).Unix(),
|
||||
}
|
||||
|
||||
if err := db.InsertContainerStat(oldSample); err != nil {
|
||||
t.Fatalf("InsertContainerStat(oldSample) error = %v", err)
|
||||
}
|
||||
if err := db.InsertContainerStat(newSample); err != nil {
|
||||
t.Fatalf("InsertContainerStat(newSample) error = %v", err)
|
||||
}
|
||||
|
||||
if err := db.PruneContainerStatsOlderThan(now.Add(-30 * 24 * time.Hour)); err != nil {
|
||||
t.Fatalf("PruneContainerStatsOlderThan() error = %v", err)
|
||||
}
|
||||
|
||||
series, err := db.GetRecentContainerStats("host-a", "container-1", now.Add(-90*24*time.Hour), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecentContainerStats() error = %v", err)
|
||||
}
|
||||
|
||||
if len(series) != 1 {
|
||||
t.Fatalf("expected 1 sample after pruning, got %d", len(series))
|
||||
}
|
||||
if series[0].Timestamp != newSample.Timestamp {
|
||||
t.Fatalf("expected recent sample to remain, got %+v", series[0])
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
105
home/internal/stats/history.go
Normal file
105
home/internal/stats/history.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
package stats
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hhftechnology/vps-monitor/internal/models"
|
||||
)
|
||||
|
||||
const defaultMaxDataPoints = 1440
|
||||
|
||||
type DataPoint struct {
|
||||
CPUPercent float64
|
||||
MemoryPercent float64
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
type ContainerHistory struct {
|
||||
dataPoints []DataPoint
|
||||
}
|
||||
|
||||
type HistoryManager struct {
|
||||
mu sync.RWMutex
|
||||
containers map[string]*ContainerHistory
|
||||
maxSize int
|
||||
}
|
||||
|
||||
func NewHistoryManager() *HistoryManager {
|
||||
return &HistoryManager{
|
||||
containers: make(map[string]*ContainerHistory),
|
||||
maxSize: defaultMaxDataPoints,
|
||||
}
|
||||
}
|
||||
|
||||
func ContainerKey(host, containerID string) string {
|
||||
return host + ":" + containerID
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) RecordStats(host, containerID string, stats models.ContainerStats) {
|
||||
hm.mu.Lock()
|
||||
defer hm.mu.Unlock()
|
||||
|
||||
key := ContainerKey(host, containerID)
|
||||
history, exists := hm.containers[key]
|
||||
if !exists {
|
||||
history = &ContainerHistory{
|
||||
dataPoints: make([]DataPoint, 0, hm.maxSize),
|
||||
}
|
||||
hm.containers[key] = history
|
||||
}
|
||||
|
||||
history.dataPoints = append(history.dataPoints, DataPoint{
|
||||
CPUPercent: stats.CPUPercent,
|
||||
MemoryPercent: stats.MemoryPercent,
|
||||
Timestamp: time.Unix(stats.Timestamp, 0),
|
||||
})
|
||||
|
||||
if len(history.dataPoints) > hm.maxSize {
|
||||
history.dataPoints = history.dataPoints[len(history.dataPoints)-hm.maxSize:]
|
||||
}
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) GetAverages(host, containerID string, duration time.Duration) (cpuAvg, memAvg float64, hasData bool) {
|
||||
hm.mu.RLock()
|
||||
defer hm.mu.RUnlock()
|
||||
|
||||
history, exists := hm.containers[ContainerKey(host, containerID)]
|
||||
if !exists || len(history.dataPoints) == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(-duration)
|
||||
var cpuSum, memSum float64
|
||||
var count int
|
||||
|
||||
for i := len(history.dataPoints) - 1; i >= 0; i-- {
|
||||
point := history.dataPoints[i]
|
||||
if point.Timestamp.Before(cutoff) {
|
||||
break
|
||||
}
|
||||
cpuSum += point.CPUPercent
|
||||
memSum += point.MemoryPercent
|
||||
count++
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
return cpuSum / float64(count), memSum / float64(count), true
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) Get1hAverages(host, containerID string) (cpuAvg, memAvg float64, hasData bool) {
|
||||
return hm.GetAverages(host, containerID, time.Hour)
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) Get12hAverages(host, containerID string) (cpuAvg, memAvg float64, hasData bool) {
|
||||
return hm.GetAverages(host, containerID, 12*time.Hour)
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) CleanupContainer(host, containerID string) {
|
||||
hm.mu.Lock()
|
||||
defer hm.mu.Unlock()
|
||||
delete(hm.containers, ContainerKey(host, containerID))
|
||||
}
|
||||
71
home/internal/stats/history_test.go
Normal file
71
home/internal/stats/history_test.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package stats
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hhftechnology/vps-monitor/internal/models"
|
||||
)
|
||||
|
||||
func TestHistoryManagerUsesHostScopedKeys(t *testing.T) {
|
||||
manager := NewHistoryManager()
|
||||
now := time.Now().Unix()
|
||||
|
||||
manager.RecordStats("host-a", "container-1", models.ContainerStats{
|
||||
CPUPercent: 10,
|
||||
MemoryPercent: 20,
|
||||
Timestamp: now,
|
||||
})
|
||||
manager.RecordStats("host-b", "container-1", models.ContainerStats{
|
||||
CPUPercent: 50,
|
||||
MemoryPercent: 60,
|
||||
Timestamp: now,
|
||||
})
|
||||
|
||||
cpuA, memA, okA := manager.Get1hAverages("host-a", "container-1")
|
||||
cpuB, memB, okB := manager.Get1hAverages("host-b", "container-1")
|
||||
|
||||
if !okA || !okB {
|
||||
t.Fatalf("expected both host/container pairs to have data")
|
||||
}
|
||||
if cpuA != 10 || memA != 20 {
|
||||
t.Fatalf("unexpected host-a averages: cpu=%v mem=%v", cpuA, memA)
|
||||
}
|
||||
if cpuB != 50 || memB != 60 {
|
||||
t.Fatalf("unexpected host-b averages: cpu=%v mem=%v", cpuB, memB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryManagerExcludesExpiredPoints(t *testing.T) {
|
||||
manager := NewHistoryManager()
|
||||
now := time.Now()
|
||||
|
||||
manager.RecordStats("host-a", "container-1", models.ContainerStats{
|
||||
CPUPercent: 90,
|
||||
MemoryPercent: 90,
|
||||
Timestamp: now.Add(-13 * time.Hour).Unix(),
|
||||
})
|
||||
manager.RecordStats("host-a", "container-1", models.ContainerStats{
|
||||
CPUPercent: 80,
|
||||
MemoryPercent: 80,
|
||||
Timestamp: now.Add(-12 * time.Hour).Add(-1 * time.Second).Unix(),
|
||||
})
|
||||
manager.RecordStats("host-a", "container-1", models.ContainerStats{
|
||||
CPUPercent: 10,
|
||||
MemoryPercent: 20,
|
||||
Timestamp: now.Add(-12 * time.Hour).Add(1 * time.Second).Unix(),
|
||||
})
|
||||
manager.RecordStats("host-a", "container-1", models.ContainerStats{
|
||||
CPUPercent: 30,
|
||||
MemoryPercent: 40,
|
||||
Timestamp: now.Unix(),
|
||||
})
|
||||
|
||||
cpu, mem, ok := manager.Get12hAverages("host-a", "container-1")
|
||||
if !ok {
|
||||
t.Fatal("expected in-window data")
|
||||
}
|
||||
if cpu != 20 || mem != 30 {
|
||||
t.Fatalf("expected only in-window samples, got cpu=%v mem=%v", cpu, mem)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue