Bug Fixes

###  Bug Fixes
This commit is contained in:
hhftechnologies 2026-04-21 19:08:55 +05:30
parent 4c6f0ef9cd
commit aaf2d8b7fc
34 changed files with 3381 additions and 1731 deletions

View file

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

View file

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

View file

@ -0,0 +1,24 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
import type { ContainerInfo } from "../types";
export interface ContainerHistoryStats
extends NonNullable<ContainerInfo["historical_stats"]> {
has_data: boolean;
}
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}`);
}
return response.json() as Promise<ContainerHistoryStats>;
}

View file

@ -1,13 +1,13 @@
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";
@ -15,322 +15,360 @@ 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 { 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;
// Update containerId when container changes
const effectiveContainerId = container?.id ?? containerId;
const {
stats,
history,
isConnected,
error,
connect,
disconnect,
clearHistory,
} = useContainerStats({
containerId: effectiveContainerId,
host,
enabled: isOpen && activeTab === "stats",
});
const {
stats,
history,
isConnected,
error,
connect,
disconnect,
clearHistory,
} = useContainerStats({
containerId: effectiveContainerId,
host,
enabled: isOpen && activeTab === "stats",
});
const handleContainerIdChange = useCallback((newId: string) => {
setContainerId(newId);
}, []);
const handleContainerIdChange = useCallback((newId: string) => {
setContainerId(newId);
}, []);
const handleToggleStats = useCallback(() => {
if (isConnected) {
disconnect();
} else {
clearHistory();
connect();
}
}, [isConnected, disconnect, clearHistory, connect]);
const handleToggleStats = useCallback(() => {
if (isConnected) {
disconnect();
} else {
clearHistory();
connect();
}
}, [isConnected, disconnect, clearHistory, connect]);
const statsCards = useMemo(() => {
if (!stats) return null;
const statsCards = useMemo(() => {
if (!stats) 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]);
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]);
if (!container) return null;
if (!container) return null;
const containerName = container.names?.[0]?.replace(/^\//, "") || container.id.slice(0, 12);
const isRunning = container.state.toLowerCase() === "running";
const containerName =
container.names?.[0]?.replace(/^\//, "") || container.id.slice(0, 12);
const isRunning = container.state.toLowerCase() === "running";
const historyStats = container.historical_stats;
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>
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>
<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>
<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>
<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>
<TabsContent value="stats" className="space-y-6 mt-4">
{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.toFixed(1)}%
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-3 text-sm">
1h RAM
<div className="font-semibold">
{historyStats.memory_1h.toFixed(1)}%
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-3 text-sm">
12h CPU
<div className="font-semibold">
{historyStats.cpu_12h.toFixed(1)}%
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-3 text-sm">
12h RAM
<div className="font-semibold">
{historyStats.memory_12h.toFixed(1)}%
</div>
</CardContent>
</Card>
</div>
)}
{/* 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>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
{!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>
)}
{!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>
)}
{isRunning && isConnected && !stats && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Spinner className="mr-2 size-4" />
Connecting...
</div>
)}
{isRunning && isConnected && !stats && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Spinner className="mr-2 size-4" />
Connecting...
</div>
)}
{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>
)}
{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-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>
)}
{/* 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>
)}
{/* Stats Charts */}
<ContainerStatsCharts history={history} />
</TabsContent>
{/* Stats Charts */}
<ContainerStatsCharts history={history} />
</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="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>
);
<TabsContent value="env" className="mt-4">
<EnvironmentVariables
containerId={effectiveContainerId}
containerHost={host}
isReadOnly={isReadOnly}
onContainerIdChange={handleContainerIdChange}
/>
</TabsContent>
</Tabs>
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { 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",
]);
});
});

View file

@ -3,111 +3,143 @@ 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;
}
if (interval === "1h") {
return metric === "cpu" ? stats.cpu_1h : stats.memory_1h;
}
return metric === "cpu" ? stats.cpu_12h : stats.memory_12h;
}
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 +147,10 @@ export function getInitialStateCounts(): StateCounts {
* Falls back to container ID if no name is available
*/
export function getContainerUrlIdentifier(container: ContainerInfo): string {
if (container.names && container.names.length > 0) {
const name = container.names[0];
return name.startsWith("/") ? name.slice(1) : name;
}
// Fallback to short ID if no name
return container.id.substring(0, 12);
if (container.names && container.names.length > 0) {
const name = container.names[0];
return name.startsWith("/") ? name.slice(1) : name;
}
// Fallback to short ID if no name
return container.id.substring(0, 12);
}

View file

@ -1,328 +1,401 @@
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 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 (
<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>
);
};
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 cursor-pointer"
onClick={() => {
navigator.clipboard?.writeText(container.image);
}}
title="Click to copy image name"
>
{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 text-sm text-muted-foreground">
{cpuAverage === null ? "—" : `${cpuAverage.toFixed(1)}%`}
</TableCell>
<TableCell className="h-16 px-4 text-sm text-muted-foreground">
{memoryAverage === null ? "—" : `${memoryAverage.toFixed(1)}%`}
</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>
);
};
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 (
<div className="rounded-lg border bg-card">
<Table>
<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 items-center gap-2"
onClick={() => onToggleGroup(group.project)}
>
{expandedGroups.includes(group.project) ? (
<ChevronDownIcon className="size-4" />
) : (
<ChevronRightIcon className="size-4" />
)}
{group.project} · {group.items.length} container
</button>
{group.items.length === 1 ? "" : "s"}
</TableCell>
</TableRow>
{expandedGroups.includes(group.project)
? group.items.map(renderContainerRow)
: null}
</Fragment>
))
) : (
pageItems.map(renderContainerRow)
)}
</TableBody>
</Table>
</div>
);
}

View file

@ -1,290 +1,354 @@
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 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>
<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"
>
<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"
>
<LogOutIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Logout {user?.username ? `(${user.username})` : ""}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
);
}

View file

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

View file

@ -1,186 +1,248 @@
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",
});
const [params, setParams] = useQueryStates(searchParamsConfig, {
history: "replace",
});
const {
search: searchTerm,
state: stateFilter,
host: hostFilter,
sort: sortDirection,
group: groupBy,
page,
pageSize,
from,
to,
} = params;
const {
search: searchTerm,
state: stateFilter,
host: hostFilter,
sort: sortDirection,
sortBy,
group: groupBy,
interval: statsInterval,
page,
pageSize,
from,
to,
expanded: expandedGroups,
} = params;
// 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;
}
// 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]);
return { from: from ?? undefined, to: to ?? undefined };
}, [from, to]);
const setSearchTerm = useCallback(
(value: string) => {
setParams({
search: value,
page: 1,
});
},
[setParams]
);
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 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 setHostFilter = useCallback(
(value: string) => {
const normalized = value || "all";
setParams({
host: normalized,
page: 1,
});
},
[setParams],
);
const setSortDirection = useCallback(
(value: SortDirection) => {
setParams({
sort: value,
});
},
[setParams]
);
const setSortDirection = useCallback(
(value: SortDirection) => {
setParams({
sort: value,
});
},
[setParams],
);
const setGroupBy = useCallback(
(value: GroupByOption) => {
setParams({
group: value,
page: 1,
});
},
[setParams]
);
const setSortBy = useCallback(
(value: SortColumn) => {
setParams({
sortBy: value,
});
},
[setParams],
);
const setDateRange = useCallback(
(range: DateRange | undefined) => {
setParams({
from: range?.from ?? null,
to: range?.to ?? null,
page: 1,
});
},
[setParams]
);
const setGroupBy = useCallback(
(value: GroupByOption) => {
setParams({
group: value,
page: 1,
});
},
[setParams],
);
const clearDateRange = useCallback(() => {
setParams({
from: null,
to: null,
page: 1,
});
}, [setParams]);
const setStatsInterval = useCallback(
(value: StatsInterval) => {
setParams({
interval: value,
});
},
[setParams],
);
const setPage = useCallback(
(value: number) => {
setParams({
page: Math.max(1, Math.floor(value)),
});
},
[setParams]
);
const setDateRange = useCallback(
(range: DateRange | undefined) => {
setParams({
from: range?.from ?? null,
to: range?.to ?? null,
page: 1,
});
},
[setParams],
);
const setPageSize = useCallback(
(value: number) => {
setParams({
pageSize: Math.max(1, Math.floor(value)),
page: 1,
});
},
[setParams]
);
const clearDateRange = useCallback(() => {
setParams({
from: null,
to: null,
page: 1,
});
}, [setParams]);
return {
searchTerm,
setSearchTerm,
stateFilter,
setStateFilter,
hostFilter,
setHostFilter,
sortDirection,
setSortDirection,
groupBy,
setGroupBy,
dateRange,
setDateRange,
clearDateRange,
page,
setPage,
pageSize,
setPageSize,
};
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],
);
const setExpandedGroups = useCallback(
(value: string[]) => {
setParams({
expanded: value,
});
},
[setParams],
);
return {
searchTerm,
setSearchTerm,
stateFilter,
setStateFilter,
hostFilter,
setHostFilter,
sortDirection,
setSortDirection,
sortBy,
setSortBy,
groupBy,
setGroupBy,
statsInterval,
setStatsInterval,
dateRange,
setDateRange,
clearDateRange,
page,
setPage,
pageSize,
setPageSize,
expandedGroups,
setExpandedGroups,
};
}

View file

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

View file

@ -0,0 +1,19 @@
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 }>;
}

View file

@ -0,0 +1,26 @@
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;
telegramToken: string;
allowedChatId: string;
}
export async function updateBot(payload: UpdateBotPayload): Promise<string> {
const response = await authenticatedFetch(ENDPOINT, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Failed to update bot settings");
}
const data = (await response.json()) as { message?: string };
return data.message ?? "Bot settings updated";
}

View file

@ -0,0 +1,157 @@
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 { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { useTestBot, useUpdateBot } from "../hooks/use-settings";
import type { BotConfig } from "../types";
import { EnvBadge } from "./env-badge";
interface BotSectionProps {
config: BotConfig;
disabled?: boolean;
}
export function BotSection({ config, disabled = false }: BotSectionProps) {
const isEnv = config.source === "env";
const [enabled, setEnabled] = useState(config.enabled);
const [telegramToken, setTelegramToken] = useState(config.telegramToken);
const [allowedChatId, setAllowedChatId] = useState(config.allowedChatId);
const updateMutation = useUpdateBot();
const testMutation = useTestBot();
useEffect(() => {
setEnabled(config.enabled);
setTelegramToken(config.telegramToken);
setAllowedChatId(config.allowedChatId);
}, [config]);
const hasChanges =
enabled !== config.enabled ||
telegramToken !== config.telegramToken ||
allowedChatId !== config.allowedChatId;
const controlsDisabled =
disabled || isEnv || updateMutation.isPending || testMutation.isPending;
const handleSave = () => {
updateMutation.mutate(
{
enabled,
telegramToken,
allowedChatId,
},
{
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),
},
);
};
return (
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<CardTitle>Telegram Bot</CardTitle>
{isEnv && <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="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>
{!isEnv && (
<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>
);
}

View file

@ -2,49 +2,51 @@ 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} />
<ScannerSection disabled={data.readOnly.value} />
</div>
);
}

View file

@ -1,80 +1,107 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getSettings } from "../api/get-settings";
import { testBot } 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),
});
}

View file

@ -1,49 +1,57 @@
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;
telegramToken: string;
allowedChatId: string;
}
export interface SettingsResponse {
dockerHosts: DockerHostsConfig;
coolifyHosts: CoolifyHostsConfig;
readOnly: ReadOnlyConfig;
auth: AuthConfig;
dockerHosts: DockerHostsConfig;
coolifyHosts: CoolifyHostsConfig;
readOnly: ReadOnlyConfig;
auth: AuthConfig;
bot: BotConfig;
}
export interface TestConnectionResult {
success: boolean;
message: string;
dockerVersion?: string;
success: boolean;
message: string;
dockerVersion?: string;
}

View file

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

View file

@ -8,6 +8,7 @@ import (
"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/coolify"
"github.com/hhftechnology/vps-monitor/internal/docker"
@ -84,6 +85,10 @@ func main() {
registry := services.NewRegistry(multiHostClient, coolifyClient, authService, cfg, alertMonitor)
telegramBot := bot.NewService(registry, cfg.Bot)
telegramBot.Start()
defer telegramBot.Stop()
// Scanner database
dbPath := "/data/scanner.db"
if v := os.Getenv("SCANNER_DB_PATH"); v != "" {
@ -162,6 +167,8 @@ func main() {
autoScanner.Stop()
}
telegramBot.UpdateConfig(newCfg.Bot)
log.Println("Configuration reloaded successfully")
})

View file

@ -12,6 +12,7 @@ import (
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/docker"
"github.com/hhftechnology/vps-monitor/internal/models"
"github.com/hhftechnology/vps-monitor/internal/stats"
)
// Monitor handles background monitoring and alerting
@ -20,6 +21,7 @@ type Monitor struct {
dockerMu sync.RWMutex
config *config.AlertConfig
history *AlertHistory
stats *stats.HistoryManager
stopCh chan struct{}
wg sync.WaitGroup
@ -34,6 +36,7 @@ func NewMonitor(dockerClient *docker.MultiHostClient, alertConfig *config.AlertC
docker: dockerClient,
config: alertConfig,
history: NewAlertHistory(100), // Keep last 100 alerts
stats: stats.NewHistoryManager(),
stopCh: make(chan struct{}),
containerStates: make(map[string]string),
}
@ -77,6 +80,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 +178,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 +208,7 @@ func (m *Monitor) checkResourceThresholds(ctx context.Context) {
if err != nil {
continue
}
m.stats.RecordStats(hostName, ctr.ID, *stats)
containerName := ctr.ID[:12]
if len(ctr.Names) > 0 {
@ -245,6 +257,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 +271,7 @@ func (m *Monitor) triggerAlert(alert models.Alert) {
}()
}
}
func isCriticalAlert(alert models.Alert) bool {
return alert.Type == models.AlertCPUThreshold || alert.Type == models.AlertMemoryThreshold
}

View file

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

View file

@ -77,6 +77,22 @@ func (ar *APIRouter) GetContainers(w http.ResponseWriter, r *http.Request) {
allContainers = append(allContainers, containers...)
}
if alertMonitor := ar.registry.Alerts(); alertMonitor != nil {
history := alertMonitor.GetStatsHistory()
for i := range allContainers {
cpu1h, memory1h, has1h := history.Get1hAverages(allContainers[i].Host, allContainers[i].ID)
cpu12h, memory12h, has12h := history.Get12hAverages(allContainers[i].Host, allContainers[i].ID)
if has1h || has12h {
allContainers[i].HistoricalStats = &models.HistoricalStats{
CPU1h: cpu1h,
Memory1h: memory1h,
CPU12h: cpu12h,
Memory12h: memory12h,
}
}
}
}
// Build host errors list for the frontend (graceful partial results)
hostErrorMessages := make([]map[string]string, 0, len(hostErrors))
for _, he := range hostErrors {
@ -214,13 +230,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",
})
go ar.runAsyncContainerAction(host, id, "stop", func(ctx context.Context) error {
return dockerClient.StopContainer(ctx, host, id)
})
}
@ -240,13 +256,13 @@ func (ar *APIRouter) RestartContainer(w http.ResponseWriter, r *http.Request) {
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",
})
go ar.runAsyncContainerAction(host, id, "restart", func(ctx context.Context) error {
return dockerClient.RestartContainer(ctx, host, id)
})
}
@ -266,16 +282,53 @@ func (ar *APIRouter) RemoveContainer(w http.ResponseWriter, r *http.Request) {
return
}
err := dockerClient.RemoveContainer(r.Context(), host, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
WriteJsonResponse(w, http.StatusAccepted, map[string]any{
"message": "Container remove initiated",
"status": "pending",
})
go ar.runAsyncContainerAction(host, id, "remove", func(ctx context.Context) error {
return dockerClient.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")
alertMonitor := ar.registry.Alerts()
if alertMonitor == nil {
http.Error(w, "stats history not available", http.StatusServiceUnavailable)
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"message": "Container removed",
if host == "" {
http.Error(w, "host parameter is required", http.StatusBadRequest)
return
}
history := alertMonitor.GetStatsHistory()
cpu1h, memory1h, has1h := history.Get1hAverages(host, id)
cpu12h, memory12h, has12h := history.Get12hAverages(host, id)
WriteJsonResponse(w, http.StatusOK, models.HistoricalAverages{
CPU1h: cpu1h,
Memory1h: memory1h,
CPU12h: cpu12h,
Memory12h: memory12h,
HasData: has1h || has12h,
})
}
func (ar *APIRouter) runAsyncContainerAction(host, id, action string, fn func(context.Context) error) {
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := fn(ctx); err != nil {
log.Printf("failed to %s container %s on host %s: %v", action, id, host, err)
}
}
func (ar *APIRouter) GetContainerLogsParsed(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
host := r.URL.Query().Get("host")

View file

@ -68,6 +68,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 +77,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,
})
}
@ -154,6 +156,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) {
@ -253,6 +256,8 @@ func (ar *APIRouter) registerSettingsRoutes(r chi.Router) {
r.Put("/coolify-hosts", ar.UpdateCoolifyHosts)
r.Put("/read-only", ar.UpdateReadOnly)
r.Put("/auth", ar.UpdateAuth)
r.Put("/bot", ar.UpdateBot)
r.Post("/test/bot", ar.TestBot)
r.Post("/test/docker-host", ar.TestDockerHost)
r.Post("/test/coolify-host", ar.TestCoolifyHost)
if ar.scanHandlers != nil {

View file

@ -12,6 +12,7 @@ import (
"time"
"github.com/hhftechnology/vps-monitor/internal/auth"
"github.com/hhftechnology/vps-monitor/internal/bot"
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/coolify"
"github.com/hhftechnology/vps-monitor/internal/docker"
@ -74,6 +75,18 @@ 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,
"telegramToken": "",
"allowedChatId": cfg.Bot.AllowedChatID,
}
if sources.Bot == config.SourceEnv && cfg.Bot.TelegramToken != "" {
botResp["telegramToken"] = secretMask
} else if fc.Bot != nil && fc.Bot.TelegramToken != "" {
botResp["telegramToken"] = secretMask
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"dockerHosts": map[string]any{
"source": sources.DockerHosts,
@ -88,6 +101,7 @@ func (ar *APIRouter) GetSettings(w http.ResponseWriter, r *http.Request) {
"value": cfg.ReadOnly,
},
"auth": authResp,
"bot": botResp,
})
}
@ -271,6 +285,78 @@ 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"`
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)
chatID := strings.TrimSpace(req.AllowedChatID)
if token == secretMask {
fc := ar.manager.FileConfigSnapshot()
if fc.Bot != nil {
token = fc.Bot.TelegramToken
}
}
if req.Enabled && (token == "" || chatID == "") {
http.Error(w, "telegramToken and allowedChatId are required when enabling bot", http.StatusBadRequest)
return
}
err := ar.manager.UpdateBotConfig(&config.FileBotConfig{
Enabled: &req.Enabled,
TelegramToken: token,
AllowedChatID: chatID,
})
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 {
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",
})
}
// TestDockerHost handles POST /api/v1/settings/test/docker-host.
func (ar *APIRouter) TestDockerHost(w http.ResponseWriter, r *http.Request) {
var req struct {

View file

@ -0,0 +1,359 @@
package bot
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/models"
"github.com/hhftechnology/vps-monitor/internal/services"
)
const telegramAPIBase = "https://api.telegram.org"
type Service struct {
mu sync.Mutex
registry *services.Registry
client *http.Client
cfg config.BotConfig
running bool
stopCh chan struct{}
doneCh chan struct{}
offset 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,
},
cfg: cfg,
}
}
func (s *Service) Start() {
s.mu.Lock()
defer s.mu.Unlock()
if s.running || !isConfigured(s.cfg) {
return
}
s.stopCh = make(chan struct{})
s.doneCh = make(chan struct{})
s.running = true
go s.pollLoop(s.cfg, s.stopCh, s.doneCh)
}
func (s *Service) Stop() {
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.Stop()
s.mu.Lock()
s.cfg = cfg
s.offset = 0
s.mu.Unlock()
s.Start()
}
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)
for {
select {
case <-stopCh:
return
default:
}
if err := s.pollOnce(cfg); err != nil {
log.Printf("telegram bot poll failed: %v", err)
select {
case <-time.After(5 * time.Second):
case <-stopCh:
return
}
}
}
}
func (s *Service) pollOnce(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.NewRequest(http.MethodGet, s.apiURL(cfg.TelegramToken, "getUpdates", params), nil)
if err != nil {
return err
}
res, err := s.client.Do(req)
if err != nil {
return err
}
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.handleCommand(strings.TrimSpace(update.Message.Text))
if reply == "" {
continue
}
if err := s.sendMessage(context.Background(), 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) handleCommand(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 s.buildCriticalMessage()
case strings.HasPrefix(text, "/status"):
return s.buildStatusMessage()
default:
return "Unknown command. Use /help."
}
}
func (s *Service) buildCriticalMessage() string {
monitor := s.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 (s *Service) buildStatusMessage() string {
dockerClient, release := s.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
total := 0
running := 0
history := s.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++
stats, err := dockerClient.GetContainerStatsOnce(ctx, hostName, ctr.ID)
if err != nil {
continue
}
name := ctr.ID[:12]
if len(ctr.Names) > 0 {
name = strings.TrimPrefix(ctr.Names[0], "/")
}
line := fmt.Sprintf("- %s@%s CPU %.1f%% MEM %.1f%%", name, hostName, stats.CPUPercent, stats.MemoryPercent)
if historyManager != nil {
cpu1h, mem1h, has1h := historyManager.Get1hAverages(hostName, ctr.ID)
cpu12h, mem12h, has12h := historyManager.Get12hAverages(hostName, ctr.ID)
if has1h || has12h {
line += fmt.Sprintf(" | 1h %.1f/%.1f", cpu1h, mem1h)
if has12h {
line += fmt.Sprintf(" | 12h %.1f/%.1f", cpu12h, mem12h)
}
}
}
lines = append(lines, containerLine{name: name, cpu: stats.CPUPercent, line: line})
}
}
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 (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 err
}
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 min(a, b int) int {
if a < b {
return a
}
return b
}

View file

@ -37,6 +37,14 @@ 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 BotConfig struct {
Enabled bool
TelegramToken string
AllowedChatID string
PollInterval time.Duration
}
// ScannerConfig holds configuration for vulnerability scanning
@ -68,6 +76,7 @@ type Config struct {
DockerHosts []DockerHost
CoolifyHosts []CoolifyHostConfig
Alerts AlertConfig
Bot BotConfig
Scanner ScannerConfig
}
@ -77,6 +86,7 @@ func NewConfig() *Config {
dockerHosts := parseDockerHosts()
coolifyHosts := parseCoolifyHostConfigs()
alertConfig := parseAlertConfig()
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 +113,7 @@ func NewConfig() *Config {
DockerHosts: dockerHosts,
CoolifyHosts: coolifyHosts,
Alerts: alertConfig,
Bot: botConfig,
Scanner: scannerConfig,
}
}
@ -114,6 +125,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 +146,34 @@ func parseAlertConfig() AlertConfig {
}
}
if filter := strings.TrimSpace(os.Getenv("ALERTS_FILTER")); filter != "" {
config.AlertsFilter = filter
}
return config
}
func parseBotConfig() BotConfig {
cfg := BotConfig{
Enabled: os.Getenv("BOT_ENABLED") == "true",
TelegramToken: strings.TrimSpace(os.Getenv("BOT_TELEGRAM_TOKEN")),
AllowedChatID: strings.TrimSpace(os.Getenv("BOT_ALLOWED_CHAT_ID")),
PollInterval: 15 * time.Second,
}
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
}
return cfg
}
func parseCoolifyHostConfigs() []CoolifyHostConfig {
// Format: COOLIFY_CONFIGS=hostA|https://coolify-a.com|tokenA,hostB|https://coolify-b.com|tokenB
raw := os.Getenv("COOLIFY_CONFIGS")

View file

@ -39,12 +39,19 @@ type FileNotificationConfig struct {
MinSeverity string `json:"minSeverity,omitempty"`
}
type FileBotConfig struct {
Enabled *bool `json:"enabled,omitempty"`
TelegramToken string `json:"telegramToken,omitempty"`
AllowedChatID string `json:"allowedChatId,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 +73,7 @@ type EnvSnapshot struct {
CoolifySet bool
ReadOnlySet bool
AuthSet bool
BotSet bool
ScannerSet bool
}
@ -88,6 +96,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 +113,10 @@ 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_POLL_INTERVAL") != "",
ScannerSet: os.Getenv("SCANNER_GRYPE_IMAGE") != "" ||
os.Getenv("SCANNER_TRIVY_IMAGE") != "" ||
os.Getenv("SCANNER_SYFT_IMAGE") != "" ||
@ -144,6 +157,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()
@ -475,6 +507,31 @@ 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.TelegramToken != "" {
cfg.Bot.TelegramToken = fc.TelegramToken
}
if fc.AllowedChatID != "" {
cfg.Bot.AllowedChatID = fc.AllowedChatID
}
}
if cfg.Bot.TelegramToken == "" || cfg.Bot.AllowedChatID == "" {
cfg.Bot.Enabled = false
}
if m.envSnapshot.BotSet && m.fileConfig.Bot != nil {
sources.Bot = SourceMixed
} else if m.envSnapshot.BotSet {
sources.Bot = SourceEnv
} else if m.fileConfig.Bot != nil {
sources.Bot = SourceFile
} else {
sources.Bot = SourceDefault
}
// Scanner: start with env config, override with file config where set
cfg.Scanner = m.envConfig.Scanner
if fc := m.fileConfig.Scanner; fc != nil {

View file

@ -713,4 +713,30 @@ 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,
TelegramToken: "token-1",
AllowedChatID: "chat-1",
}); err != nil {
t.Fatalf("UpdateBotConfig returned error: %v", err)
}
merged := m.Config()
if !merged.Bot.Enabled || 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)
}
}

View file

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

View file

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

View file

@ -15,3 +15,11 @@ 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"`
}

View file

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

View file

@ -0,0 +1,60 @@
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()
manager.RecordStats("host-a", "container-1", models.ContainerStats{
CPUPercent: 90,
MemoryPercent: 90,
Timestamp: time.Now().Add(-13 * time.Hour).Unix(),
})
manager.RecordStats("host-a", "container-1", models.ContainerStats{
CPUPercent: 30,
MemoryPercent: 40,
Timestamp: time.Now().Unix(),
})
cpu, mem, ok := manager.Get12hAverages("host-a", "container-1")
if !ok {
t.Fatal("expected in-window data")
}
if cpu != 30 || mem != 40 {
t.Fatalf("expected only recent sample, got cpu=%v mem=%v", cpu, mem)
}
}