mirror of
https://github.com/hhftechnology/vps-monitor.git
synced 2026-04-28 03:29:55 +00:00
Bug Fixes
### Bug Fixes
This commit is contained in:
parent
4c6f0ef9cd
commit
aaf2d8b7fc
34 changed files with 3381 additions and 1731 deletions
|
|
@ -0,0 +1,45 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { restartContainer } from "./container-actions";
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
describe("container-actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
storage.clear();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("marks 202 responses as pending", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
message: "Container restart initiated",
|
||||
status: "pending",
|
||||
}),
|
||||
{
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await restartContainer("abc123", "host-a");
|
||||
expect(result).toEqual({
|
||||
message: "Container restart initiated",
|
||||
isPending: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -6,48 +6,54 @@ const BASE_URL = `${API_BASE_URL}/api/v1/containers`;
|
|||
type ContainerAction = "start" | "stop" | "restart" | "remove";
|
||||
|
||||
interface ActionResponse {
|
||||
message?: string;
|
||||
message?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ActionResult {
|
||||
message: string;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
async function performContainerAction(
|
||||
id: string,
|
||||
action: ContainerAction,
|
||||
host: string
|
||||
): Promise<string> {
|
||||
const endpoint = `${BASE_URL}/${encodeURIComponent(id)}/${action}?host=${encodeURIComponent(host)}`;
|
||||
const response = await authenticatedFetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
id: string,
|
||||
action: ContainerAction,
|
||||
host: string,
|
||||
): Promise<ActionResult> {
|
||||
const endpoint = `${BASE_URL}/${encodeURIComponent(id)}/${action}?host=${encodeURIComponent(host)}`;
|
||||
const response = await authenticatedFetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `Failed to ${action} container`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `Failed to ${action} container`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ActionResponse | undefined;
|
||||
const isPending = response.status === 202;
|
||||
const data = (await response.json()) as ActionResponse | undefined;
|
||||
|
||||
if (data && typeof data.message === "string") {
|
||||
return data.message;
|
||||
}
|
||||
|
||||
return "Action completed successfully";
|
||||
return {
|
||||
message: data?.message || "Action completed successfully",
|
||||
isPending,
|
||||
};
|
||||
}
|
||||
|
||||
export function startContainer(id: string, host: string) {
|
||||
return performContainerAction(id, "start", host);
|
||||
return performContainerAction(id, "start", host);
|
||||
}
|
||||
|
||||
export function stopContainer(id: string, host: string) {
|
||||
return performContainerAction(id, "stop", host);
|
||||
return performContainerAction(id, "stop", host);
|
||||
}
|
||||
|
||||
export function restartContainer(id: string, host: string) {
|
||||
return performContainerAction(id, "restart", host);
|
||||
return performContainerAction(id, "restart", host);
|
||||
}
|
||||
|
||||
export function removeContainer(id: string, host: string) {
|
||||
return performContainerAction(id, "remove", host);
|
||||
return performContainerAction(id, "remove", host);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
19
frontend/src/features/settings/api/test-bot.ts
Normal file
19
frontend/src/features/settings/api/test-bot.ts
Normal 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 }>;
|
||||
}
|
||||
26
frontend/src/features/settings/api/update-bot.ts
Normal file
26
frontend/src/features/settings/api/update-bot.ts
Normal 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";
|
||||
}
|
||||
157
frontend/src/features/settings/components/bot-section.tsx
Normal file
157
frontend/src/features/settings/components/bot-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
19
home/internal/alerts/monitor_test.go
Normal file
19
home/internal/alerts/monitor_test.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package alerts
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hhftechnology/vps-monitor/internal/models"
|
||||
)
|
||||
|
||||
func TestIsCriticalAlertMatchesThresholdAlertsOnly(t *testing.T) {
|
||||
if !isCriticalAlert(models.Alert{Type: models.AlertCPUThreshold}) {
|
||||
t.Fatal("expected CPU threshold alerts to be critical")
|
||||
}
|
||||
if !isCriticalAlert(models.Alert{Type: models.AlertMemoryThreshold}) {
|
||||
t.Fatal("expected memory threshold alerts to be critical")
|
||||
}
|
||||
if isCriticalAlert(models.Alert{Type: models.AlertContainerStopped}) {
|
||||
t.Fatal("expected container stopped alerts to be excluded from critical-only filtering")
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
359
home/internal/bot/telegram.go
Normal file
359
home/internal/bot/telegram.go
Normal 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
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,4 +31,5 @@ type AlertConfigResponse struct {
|
|||
MemoryThreshold float64 `json:"memory_threshold"`
|
||||
CheckInterval string `json:"check_interval"`
|
||||
WebhookEnabled bool `json:"webhook_enabled"`
|
||||
AlertsFilter string `json:"alerts_filter"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,24 @@ package models
|
|||
|
||||
// ContainerInfo represents the minimal container information exposed by the API
|
||||
type ContainerInfo struct {
|
||||
ID string `json:"id"`
|
||||
Names []string `json:"names"`
|
||||
Image string `json:"image"`
|
||||
ImageID string `json:"image_id"`
|
||||
Command string `json:"command"`
|
||||
Created int64 `json:"created"`
|
||||
State string `json:"state"`
|
||||
Status string `json:"status"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Host string `json:"host"`
|
||||
ID string `json:"id"`
|
||||
Names []string `json:"names"`
|
||||
Image string `json:"image"`
|
||||
ImageID string `json:"image_id"`
|
||||
Command string `json:"command"`
|
||||
Created int64 `json:"created"`
|
||||
State string `json:"state"`
|
||||
Status string `json:"status"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Host string `json:"host"`
|
||||
HistoricalStats *HistoricalStats `json:"historical_stats,omitempty"`
|
||||
}
|
||||
|
||||
type HistoricalStats struct {
|
||||
CPU1h float64 `json:"cpu_1h"`
|
||||
Memory1h float64 `json:"memory_1h"`
|
||||
CPU12h float64 `json:"cpu_12h"`
|
||||
Memory12h float64 `json:"memory_12h"`
|
||||
}
|
||||
|
||||
// LogOptions represents options for fetching container logs
|
||||
|
|
|
|||
|
|
@ -15,3 +15,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"`
|
||||
}
|
||||
|
|
|
|||
105
home/internal/stats/history.go
Normal file
105
home/internal/stats/history.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
package stats
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hhftechnology/vps-monitor/internal/models"
|
||||
)
|
||||
|
||||
const defaultMaxDataPoints = 1440
|
||||
|
||||
type DataPoint struct {
|
||||
CPUPercent float64
|
||||
MemoryPercent float64
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
type ContainerHistory struct {
|
||||
dataPoints []DataPoint
|
||||
}
|
||||
|
||||
type HistoryManager struct {
|
||||
mu sync.RWMutex
|
||||
containers map[string]*ContainerHistory
|
||||
maxSize int
|
||||
}
|
||||
|
||||
func NewHistoryManager() *HistoryManager {
|
||||
return &HistoryManager{
|
||||
containers: make(map[string]*ContainerHistory),
|
||||
maxSize: defaultMaxDataPoints,
|
||||
}
|
||||
}
|
||||
|
||||
func ContainerKey(host, containerID string) string {
|
||||
return host + ":" + containerID
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) RecordStats(host, containerID string, stats models.ContainerStats) {
|
||||
hm.mu.Lock()
|
||||
defer hm.mu.Unlock()
|
||||
|
||||
key := ContainerKey(host, containerID)
|
||||
history, exists := hm.containers[key]
|
||||
if !exists {
|
||||
history = &ContainerHistory{
|
||||
dataPoints: make([]DataPoint, 0, hm.maxSize),
|
||||
}
|
||||
hm.containers[key] = history
|
||||
}
|
||||
|
||||
history.dataPoints = append(history.dataPoints, DataPoint{
|
||||
CPUPercent: stats.CPUPercent,
|
||||
MemoryPercent: stats.MemoryPercent,
|
||||
Timestamp: time.Unix(stats.Timestamp, 0),
|
||||
})
|
||||
|
||||
if len(history.dataPoints) > hm.maxSize {
|
||||
history.dataPoints = history.dataPoints[len(history.dataPoints)-hm.maxSize:]
|
||||
}
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) GetAverages(host, containerID string, duration time.Duration) (cpuAvg, memAvg float64, hasData bool) {
|
||||
hm.mu.RLock()
|
||||
defer hm.mu.RUnlock()
|
||||
|
||||
history, exists := hm.containers[ContainerKey(host, containerID)]
|
||||
if !exists || len(history.dataPoints) == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(-duration)
|
||||
var cpuSum, memSum float64
|
||||
var count int
|
||||
|
||||
for i := len(history.dataPoints) - 1; i >= 0; i-- {
|
||||
point := history.dataPoints[i]
|
||||
if point.Timestamp.Before(cutoff) {
|
||||
break
|
||||
}
|
||||
cpuSum += point.CPUPercent
|
||||
memSum += point.MemoryPercent
|
||||
count++
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
return cpuSum / float64(count), memSum / float64(count), true
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) Get1hAverages(host, containerID string) (cpuAvg, memAvg float64, hasData bool) {
|
||||
return hm.GetAverages(host, containerID, time.Hour)
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) Get12hAverages(host, containerID string) (cpuAvg, memAvg float64, hasData bool) {
|
||||
return hm.GetAverages(host, containerID, 12*time.Hour)
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) CleanupContainer(host, containerID string) {
|
||||
hm.mu.Lock()
|
||||
defer hm.mu.Unlock()
|
||||
delete(hm.containers, ContainerKey(host, containerID))
|
||||
}
|
||||
60
home/internal/stats/history_test.go
Normal file
60
home/internal/stats/history_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue