Add settings pages, APIs, and read-only mode

Introduce a full Settings feature: frontend settings pages, components, hooks, API clients and types to manage Docker hosts, Coolify hosts, authentication, and read-only mode. Backend support added (settings handlers, config manager, Coolify client, registry service and related updates) and get-containers now returns hostErrors and coolifyConfigured so the UI can surface unavailable hosts. UI improvements include a Radix Switch component, a Settings button in the containers toolbar, and a host error banner on the dashboard. Environment, compose and packaging updates add DOCKER_HOSTS, COOLIFY_CONFIGS, READONLY_MODE docs and docker-compose volume; also remove an old auth handler file.
This commit is contained in:
hhftechnologies 2026-04-02 13:44:28 +05:30
parent 064b76effa
commit 8e02d66da3
42 changed files with 2968 additions and 244 deletions

View file

@ -39,3 +39,24 @@ FRONTEND_PORT=2345
# Docker host (uncomment to override default)
DOCKER_HOST=unix:///var/run/docker.sock
# Multiple Docker hosts (comma-separated, format: name|host)
# Can also be configured via the Settings page at runtime.
# DOCKER_HOSTS=local|unix:///var/run/docker.sock,remote|ssh://root@10.0.0.1
# =============================================================================
# Coolify Integration (Optional)
# =============================================================================
# Persist environment variable changes across Coolify redeployments.
# Format: hostName|apiURL|apiToken (comma-separated for multiple hosts)
# Can also be configured via the Settings page at runtime.
# COOLIFY_CONFIGS=prod|https://coolify.example.com|your-api-token
# =============================================================================
# Read-Only Mode (Optional)
# =============================================================================
# When true, disables all container actions (start/stop/restart/remove).
# Can also be toggled via the Settings page at runtime.
READONLY_MODE=false

View file

@ -11,7 +11,14 @@ services:
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- ADMIN_PASSWORD_SALT=${ADMIN_PASSWORD_SALT}
- DOCKER_HOST=${DOCKER_HOST:-unix:///var/run/docker.sock}
- DOCKER_HOSTS=${DOCKER_HOSTS:-}
- COOLIFY_CONFIGS=${COOLIFY_CONFIGS:-}
- READONLY_MODE=${READONLY_MODE:-false}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /root/.ssh:/root/.ssh:ro
- /proc:/host/proc:ro
- vps-monitor-data:/data
volumes:
vps-monitor-data:

View file

@ -24,6 +24,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.0.6",

View file

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer inline-flex h-5 w-9 shrink-0 items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View file

@ -5,10 +5,17 @@ import type { ContainerInfo, DockerHost } from "../types";
const CONTAINERS_ENDPOINT = `${API_BASE_URL}/api/v1/containers`;
export interface HostError {
host: string;
message: string;
}
export interface GetContainersResponse {
containers: ContainerInfo[];
readOnly: boolean;
hosts: DockerHost[];
hostErrors?: HostError[];
coolifyConfigured?: boolean;
}
export async function getContainers(): Promise<GetContainersResponse> {
@ -28,6 +35,9 @@ export async function getContainers(): Promise<GetContainersResponse> {
const containers = (data as { containers?: unknown }).containers;
const readOnly = (data as { readOnly?: boolean }).readOnly ?? false;
const hosts = (data as { hosts?: unknown }).hosts;
const hostErrors = (data as { hostErrors?: unknown }).hostErrors;
const coolifyConfigured =
(data as { coolifyConfigured?: boolean }).coolifyConfigured ?? false;
if (!Array.isArray(containers)) {
throw new Error("Unexpected response format");
@ -41,5 +51,7 @@ export async function getContainers(): Promise<GetContainersResponse> {
containers: containers as ContainerInfo[],
readOnly,
hosts: hosts as DockerHost[],
hostErrors: (Array.isArray(hostErrors) ? hostErrors : []) as HostError[],
coolifyConfigured,
};
}

View file

@ -55,6 +55,7 @@ export function ContainersDashboard() {
const containers = data?.containers ?? [];
const isReadOnly = data?.readOnly ?? false;
const hosts = data?.hosts ?? [];
const hostErrors = data?.hostErrors ?? [];
const hostInfo = useMemo(
() => ({
@ -390,6 +391,21 @@ export function ContainersDashboard() {
systemUsage={systemUsage}
/>
{hostErrors.length > 0 && (
<div className="rounded-lg border border-yellow-500/50 bg-yellow-50 dark:bg-yellow-900/10 p-3 text-sm">
<p className="font-medium text-yellow-800 dark:text-yellow-200">
Some Docker hosts are unavailable
</p>
<ul className="mt-1 text-yellow-700 dark:text-yellow-300">
{hostErrors.map((he) => (
<li key={he.host}>
{he.host}: {he.message}
</li>
))}
</ul>
</div>
)}
<section className="space-y-4">
<ContainersToolbar
searchTerm={searchTerm}

View file

@ -1,9 +1,10 @@
import { useNavigate } from "@tanstack/react-router";
import { Link, useNavigate } from "@tanstack/react-router";
import {
CalendarIcon,
ChevronDownIcon,
LogOutIcon,
RefreshCcwIcon,
SettingsIcon,
XIcon
} from "lucide-react";
@ -255,6 +256,17 @@ export function ContainersToolbar({
/>
</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>

View file

@ -0,0 +1,17 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
import type { SettingsResponse } from "../types";
const SETTINGS_ENDPOINT = `${API_BASE_URL}/api/v1/settings`;
export async function getSettings(): Promise<SettingsResponse> {
const response = await authenticatedFetch(SETTINGS_ENDPOINT);
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with status ${response.status}`);
}
return (await response.json()) as SettingsResponse;
}

View file

@ -0,0 +1,25 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
import type { TestConnectionResult } from "../types";
const ENDPOINT = `${API_BASE_URL}/api/v1/settings/test/coolify-host`;
export async function testCoolifyHost(
hostName: string,
apiURL: string,
apiToken: string,
): Promise<TestConnectionResult> {
const response = await authenticatedFetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hostName, apiURL, apiToken }),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Failed to test Coolify host");
}
return (await response.json()) as TestConnectionResult;
}

View file

@ -0,0 +1,24 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
import type { TestConnectionResult } from "../types";
const ENDPOINT = `${API_BASE_URL}/api/v1/settings/test/docker-host`;
export async function testDockerHost(
name: string,
host: string,
): Promise<TestConnectionResult> {
const response = await authenticatedFetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, host }),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Failed to test Docker host");
}
return (await response.json()) as TestConnectionResult;
}

View file

@ -0,0 +1,26 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
const ENDPOINT = `${API_BASE_URL}/api/v1/settings/auth`;
export interface UpdateAuthPayload {
enabled: boolean;
adminUsername: string;
newPassword?: string;
}
export async function updateAuth(payload: UpdateAuthPayload): 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 auth settings");
}
const data = (await response.json()) as { message?: string };
return data.message ?? "Auth settings updated";
}

View file

@ -0,0 +1,22 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
const ENDPOINT = `${API_BASE_URL}/api/v1/settings/coolify-hosts`;
export async function updateCoolifyHosts(
hosts: { hostName: string; apiURL: string; apiToken: string }[],
): Promise<string> {
const response = await authenticatedFetch(ENDPOINT, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hosts }),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Failed to update Coolify hosts");
}
const data = (await response.json()) as { message?: string };
return data.message ?? "Coolify hosts updated";
}

View file

@ -0,0 +1,20 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
const ENDPOINT = `${API_BASE_URL}/api/v1/settings/docker-hosts`;
export async function updateDockerHosts(hosts: { name: string; host: string }[]): Promise<string> {
const response = await authenticatedFetch(ENDPOINT, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hosts }),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Failed to update Docker hosts");
}
const data = (await response.json()) as { message?: string };
return data.message ?? "Docker hosts updated";
}

View file

@ -0,0 +1,20 @@
import { authenticatedFetch } from "@/lib/api-client";
import { API_BASE_URL } from "@/types/api";
const ENDPOINT = `${API_BASE_URL}/api/v1/settings/read-only`;
export async function updateReadOnly(value: boolean): Promise<string> {
const response = await authenticatedFetch(ENDPOINT, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value }),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Failed to update read-only mode");
}
const data = (await response.json()) as { message?: string };
return data.message ?? "Read-only mode updated";
}

View file

@ -0,0 +1,138 @@
import { 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 { useUpdateAuth } from "../hooks/use-settings";
import type { AuthConfig } from "../types";
import { EnvBadge } from "./env-badge";
interface AuthSectionProps {
config: AuthConfig;
}
export function AuthSection({ config }: AuthSectionProps) {
const isEnv = config.source === "env";
const [enabled, setEnabled] = useState(config.enabled);
const [username, setUsername] = useState(config.adminUsername ?? "");
const [password, setPassword] = useState("");
const mutation = useUpdateAuth();
const hasChanges =
enabled !== config.enabled ||
username !== (config.adminUsername ?? "") ||
password.length > 0;
function handleSave() {
if (enabled && !username.trim()) {
toast.error("Username is required when enabling auth");
return;
}
if (enabled && !config.enabled && !password) {
toast.error("Password is required when first enabling auth");
return;
}
mutation.mutate(
{
enabled,
adminUsername: username,
...(password ? { newPassword: password } : {}),
},
{
onSuccess: (msg) => {
toast.success(msg);
setPassword("");
},
onError: (err) => toast.error(err.message),
},
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<CardTitle>Authentication</CardTitle>
{isEnv && <EnvBadge />}
</div>
<CardDescription>
Protect the dashboard with username and password authentication.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
<Switch
id="auth-enabled"
checked={enabled}
onCheckedChange={(checked) => {
setEnabled(checked);
if (!checked) setPassword("");
}}
disabled={isEnv}
/>
<Label htmlFor="auth-enabled" className="cursor-pointer">
{enabled ? "Enabled" : "Disabled"}
</Label>
</div>
{enabled && (
<div className="space-y-3 max-w-sm">
<div className="space-y-1.5">
<Label htmlFor="admin-username">Admin username</Label>
<Input
id="admin-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isEnv}
placeholder="admin"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="admin-password">
{config.enabled ? "New password (leave blank to keep current)" : "Password"}
</Label>
<Input
id="admin-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isEnv}
placeholder={config.enabled ? "Leave blank to keep current" : "Enter password"}
/>
</div>
</div>
)}
{hasChanges && !isEnv && (
<Button
size="sm"
disabled={mutation.isPending}
onClick={handleSave}
>
{mutation.isPending ? (
<>
<Spinner className="size-3" />
Saving...
</>
) : (
"Save changes"
)}
</Button>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,354 @@
import { useMemo, 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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
useTestCoolifyHost,
useUpdateCoolifyHosts,
} from "../hooks/use-settings";
import type { CoolifyHostsConfig } from "../types";
import { EnvBadge } from "./env-badge";
interface CoolifyHostsSectionProps {
config: CoolifyHostsConfig;
}
interface EditingHost {
hostName: string;
apiURL: string;
apiToken: string;
}
const EMPTY_HOST: EditingHost = { hostName: "", apiURL: "", apiToken: "" };
export function CoolifyHostsSection({ config }: CoolifyHostsSectionProps) {
const envHosts = config.hosts.filter((h) => h.source === "env");
const [fileHosts, setFileHosts] = useState<EditingHost[]>(
config.hosts
.filter((h) => h.source !== "env")
.map(({ hostName, apiURL, apiToken }) => ({ hostName, apiURL, apiToken })),
);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [editingHost, setEditingHost] = useState<EditingHost>(EMPTY_HOST);
const [isAdding, setIsAdding] = useState(false);
const [newHost, setNewHost] = useState<EditingHost>(EMPTY_HOST);
const [testResults, setTestResults] = useState<
Record<string, { success: boolean; message: string }>
>({});
const [testingKey, setTestingKey] = useState<string | null>(null);
const updateMutation = useUpdateCoolifyHosts();
const testMutation = useTestCoolifyHost();
const originalFileHosts = useMemo(
() => config.hosts.filter((h) => h.source !== "env").map(({ hostName, apiURL, apiToken }) => ({ hostName, apiURL, apiToken })),
[config.hosts],
);
const hasChanges = fileHosts.length !== originalFileHosts.length ||
fileHosts.some((h, i) => h.hostName !== originalFileHosts[i]?.hostName || h.apiURL !== originalFileHosts[i]?.apiURL || h.apiToken !== originalFileHosts[i]?.apiToken);
function handleSave() {
updateMutation.mutate(fileHosts, {
onSuccess: (msg) => toast.success(msg),
onError: (err) => toast.error(err.message),
});
}
function handleRemove(index: number) {
setFileHosts((prev) => prev.filter((_, i) => i !== index));
setTestResults({});
}
function handleStartEdit(index: number) {
setEditingIndex(index);
setEditingHost({ ...fileHosts[index] });
}
function handleCancelEdit() {
setEditingIndex(null);
setEditingHost(EMPTY_HOST);
}
function handleSaveEdit() {
if (editingIndex === null) return;
if (!editingHost.hostName.trim() || !editingHost.apiURL.trim() || !editingHost.apiToken.trim()) {
toast.error("Host name, API URL, and API token are all required");
return;
}
const trimmedName = editingHost.hostName.trim();
if (envHosts.some((h) => h.hostName === trimmedName)) {
toast.error(`Host name "${trimmedName}" is defined via environment variable`);
return;
}
if (fileHosts.some((h, i) => i !== editingIndex && h.hostName === trimmedName)) {
toast.error(`Host name "${trimmedName}" already exists`);
return;
}
const next = [...fileHosts];
next[editingIndex] = { ...editingHost };
setFileHosts(next);
setEditingIndex(null);
setEditingHost(EMPTY_HOST);
setTestResults({});
}
function handleAddHost() {
if (!newHost.hostName.trim() || !newHost.apiURL.trim() || !newHost.apiToken.trim()) {
toast.error("Host name, API URL, and API token are all required");
return;
}
const trimmedName = newHost.hostName.trim();
if (envHosts.some((h) => h.hostName === trimmedName)) {
toast.error(`Host name "${trimmedName}" is already defined via environment variable`);
return;
}
if (fileHosts.some((h) => h.hostName === trimmedName)) {
toast.error(`Host name "${trimmedName}" already exists`);
return;
}
setFileHosts([...fileHosts, { hostName: trimmedName, apiURL: newHost.apiURL.trim(), apiToken: newHost.apiToken.trim() }]);
setNewHost(EMPTY_HOST);
setIsAdding(false);
setTestResults({});
}
function handleTest(key: string, h: { hostName: string; apiURL: string; apiToken: string }) {
setTestingKey(key);
testMutation.mutate(
{ hostName: h.hostName, apiURL: h.apiURL, apiToken: h.apiToken },
{
onSuccess: (result) => {
setTestResults((prev) => ({
...prev,
[key]: { success: result.success, message: result.message },
}));
setTestingKey(null);
},
onError: (err) => {
setTestResults((prev) => ({
...prev,
[key]: { success: false, message: err.message },
}));
setTestingKey(null);
},
},
);
}
function renderRow(
h: { hostName: string; apiURL: string; apiToken: string },
isEnvRow: boolean,
fileIndex?: number,
) {
const testKey = `${isEnvRow ? "env" : "file"}-${h.hostName}`;
const isEditing = !isEnvRow && editingIndex === fileIndex;
if (isEditing && fileIndex !== undefined) {
return (
<TableRow key={testKey}>
<TableCell>
<Input
value={editingHost.hostName}
onChange={(e) => setEditingHost((prev) => ({ ...prev, hostName: e.target.value }))}
className="h-8"
/>
</TableCell>
<TableCell>
<Input
value={editingHost.apiURL}
onChange={(e) => setEditingHost((prev) => ({ ...prev, apiURL: e.target.value }))}
className="h-8"
placeholder="https://coolify.example.com"
/>
</TableCell>
<TableCell>
<Input
value={editingHost.apiToken}
onChange={(e) => setEditingHost((prev) => ({ ...prev, apiToken: e.target.value }))}
className="h-8"
placeholder="Leave masked to keep existing"
type="password"
/>
</TableCell>
<TableCell />
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={handleSaveEdit}>Done</Button>
<Button variant="ghost" size="sm" onClick={handleCancelEdit}>Cancel</Button>
</div>
</TableCell>
</TableRow>
);
}
return (
<TableRow key={testKey}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{h.hostName}
{isEnvRow && <EnvBadge />}
</div>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{h.apiURL}</TableCell>
<TableCell className="text-muted-foreground">{h.apiToken}</TableCell>
<TableCell>
{testResults[testKey] && (
<span
className={`text-xs ${testResults[testKey].success ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}`}
>
{testResults[testKey].message}
</span>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
disabled={testingKey === testKey}
onClick={() => handleTest(testKey, h)}
>
{testingKey === testKey ? <Spinner className="size-3" /> : "Test"}
</Button>
{!isEnvRow && fileIndex !== undefined && (
<>
<Button variant="ghost" size="sm" disabled={editingIndex !== null} onClick={() => handleStartEdit(fileIndex)}>Edit</Button>
<Button
variant="ghost"
size="sm"
disabled={editingIndex !== null}
onClick={() => handleRemove(fileIndex)}
className="text-destructive hover:text-destructive"
>
Remove
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
);
}
const allHosts = [
...envHosts.map((h) => ({ ...h, isEnv: true as const })),
...fileHosts.map((h, i) => ({ ...h, source: "file" as const, isEnv: false as const, fileIndex: i })),
];
return (
<Card>
<CardHeader>
<CardTitle>Coolify Hosts</CardTitle>
<CardDescription>
Connect to Coolify instances to persist environment variable changes across redeployments.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{allHosts.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>API URL</TableHead>
<TableHead>Token</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allHosts.map((h) =>
renderRow(
{ hostName: h.hostName, apiURL: h.apiURL, apiToken: h.apiToken },
h.isEnv,
h.isEnv ? undefined : h.fileIndex,
),
)}
</TableBody>
</Table>
)}
{allHosts.length === 0 && !isAdding && (
<p className="text-sm text-muted-foreground">No Coolify hosts configured.</p>
)}
{isAdding && (
<div className="flex items-end gap-3 border rounded-md p-3">
<div className="space-y-1.5 flex-1">
<Label htmlFor="new-coolify-name">Name</Label>
<Input
id="new-coolify-name"
value={newHost.hostName}
onChange={(e) => setNewHost((prev) => ({ ...prev, hostName: e.target.value }))}
placeholder="production"
className="h-8"
/>
</div>
<div className="space-y-1.5 flex-1">
<Label htmlFor="new-coolify-url">API URL</Label>
<Input
id="new-coolify-url"
value={newHost.apiURL}
onChange={(e) => setNewHost((prev) => ({ ...prev, apiURL: e.target.value }))}
placeholder="https://coolify.example.com"
className="h-8"
/>
</div>
<div className="space-y-1.5 flex-1">
<Label htmlFor="new-coolify-token">API Token</Label>
<Input
id="new-coolify-token"
value={newHost.apiToken}
onChange={(e) => setNewHost((prev) => ({ ...prev, apiToken: e.target.value }))}
placeholder="Token"
type="password"
className="h-8"
/>
</div>
<div className="flex gap-1">
<Button size="sm" onClick={handleAddHost}>Add</Button>
<Button size="sm" variant="ghost" onClick={() => { setIsAdding(false); setNewHost(EMPTY_HOST); }}>
Cancel
</Button>
</div>
</div>
)}
<div className="flex items-center gap-2">
{!isAdding && (
<Button variant="outline" size="sm" onClick={() => setIsAdding(true)}>
Add host
</Button>
)}
{hasChanges && (
<Button size="sm" disabled={updateMutation.isPending} onClick={handleSave}>
{updateMutation.isPending ? (
<><Spinner className="size-3" /> Saving...</>
) : (
"Save changes"
)}
</Button>
)}
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,386 @@
import { useMemo, 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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useTestDockerHost, useUpdateDockerHosts } from "../hooks/use-settings";
import type { DockerHostsConfig } from "../types";
import { EnvBadge } from "./env-badge";
interface DockerHostsSectionProps {
config: DockerHostsConfig;
}
interface EditingHost {
name: string;
host: string;
}
export function DockerHostsSection({ config }: DockerHostsSectionProps) {
const envHosts = config.hosts.filter((h) => h.source === "env");
const [fileHosts, setFileHosts] = useState<EditingHost[]>(
config.hosts.filter((h) => h.source !== "env").map(({ name, host }) => ({ name, host })),
);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [editingHost, setEditingHost] = useState<EditingHost>({
name: "",
host: "",
});
const [isAdding, setIsAdding] = useState(false);
const [newHost, setNewHost] = useState<EditingHost>({
name: "",
host: "",
});
const [testResults, setTestResults] = useState<
Record<string, { success: boolean; message: string }>
>({});
const [testingKey, setTestingKey] = useState<string | null>(null);
const updateMutation = useUpdateDockerHosts();
const testMutation = useTestDockerHost();
const originalFileHosts = useMemo(
() => config.hosts.filter((h) => h.source !== "env").map(({ name, host }) => ({ name, host })),
[config.hosts],
);
const hasChanges = fileHosts.length !== originalFileHosts.length ||
fileHosts.some((h, i) => h.name !== originalFileHosts[i]?.name || h.host !== originalFileHosts[i]?.host);
function handleSave() {
updateMutation.mutate(
fileHosts.map(({ name, host }) => ({ name, host })),
{
onSuccess: (msg) => toast.success(msg),
onError: (err) => toast.error(err.message),
},
);
}
function handleRemove(index: number) {
setFileHosts((prev) => prev.filter((_, i) => i !== index));
setTestResults({});
}
function handleStartEdit(index: number) {
setEditingIndex(index);
setEditingHost({ ...fileHosts[index] });
}
function handleCancelEdit() {
setEditingIndex(null);
setEditingHost({ name: "", host: "" });
}
function handleSaveEdit() {
if (editingIndex === null) return;
if (!editingHost.name.trim() || !editingHost.host.trim()) {
toast.error("Name and host are required");
return;
}
const trimmedName = editingHost.name.trim();
if (envHosts.some((h) => h.name === trimmedName)) {
toast.error(`Host name "${trimmedName}" is defined via environment variable`);
return;
}
if (fileHosts.some((h, i) => i !== editingIndex && h.name === trimmedName)) {
toast.error(`Host name "${trimmedName}" already exists`);
return;
}
const next = [...fileHosts];
next[editingIndex] = { ...editingHost };
setFileHosts(next);
setEditingIndex(null);
setEditingHost({ name: "", host: "" });
setTestResults({});
}
function handleAddHost() {
if (!newHost.name.trim() || !newHost.host.trim()) {
toast.error("Name and host are required");
return;
}
const trimmedName = newHost.name.trim();
if (envHosts.some((h) => h.name === trimmedName)) {
toast.error(`Host name "${trimmedName}" is already defined via environment variable`);
return;
}
if (fileHosts.some((h) => h.name === trimmedName)) {
toast.error(`Host name "${trimmedName}" already exists`);
return;
}
setFileHosts([...fileHosts, { name: trimmedName, host: newHost.host.trim() }]);
setNewHost({ name: "", host: "" });
setIsAdding(false);
setTestResults({});
}
function handleTest(key: string, hostUrl: string) {
setTestingKey(key);
setTestResults((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
testMutation.mutate(
{ name: key, host: hostUrl },
{
onSuccess: (result) => {
setTestResults((prev) => ({
...prev,
[key]: {
success: result.success,
message: result.success
? `Connected (Docker ${result.dockerVersion ?? ""})`
: result.message,
},
}));
setTestingKey(null);
},
onError: (err) => {
setTestResults((prev) => ({
...prev,
[key]: { success: false, message: err.message },
}));
setTestingKey(null);
},
},
);
}
function renderRow(
h: { name: string; host: string },
isEnvRow: boolean,
fileIndex?: number,
) {
const testKey = `${isEnvRow ? "env" : "file"}-${h.name}`;
const isEditing = !isEnvRow && editingIndex === fileIndex;
if (isEditing && fileIndex !== undefined) {
return (
<TableRow key={testKey}>
<TableCell>
<Input
value={editingHost.name}
onChange={(e) =>
setEditingHost((prev) => ({ ...prev, name: e.target.value }))
}
className="h-8"
/>
</TableCell>
<TableCell>
<Input
value={editingHost.host}
onChange={(e) =>
setEditingHost((prev) => ({ ...prev, host: e.target.value }))
}
className="h-8"
placeholder="unix:///var/run/docker.sock"
/>
</TableCell>
<TableCell />
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={handleSaveEdit}>
Done
</Button>
<Button variant="ghost" size="sm" onClick={handleCancelEdit}>
Cancel
</Button>
</div>
</TableCell>
</TableRow>
);
}
return (
<TableRow key={testKey}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{h.name}
{isEnvRow && <EnvBadge />}
</div>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{h.host}
</TableCell>
<TableCell>
{testResults[testKey] && (
<span
className={`text-xs ${testResults[testKey].success ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}`}
>
{testResults[testKey].message}
</span>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
disabled={testingKey === testKey}
onClick={() => handleTest(testKey, h.host)}
>
{testingKey === testKey ? <Spinner className="size-3" /> : "Test"}
</Button>
{!isEnvRow && fileIndex !== undefined && (
<>
<Button
variant="ghost"
size="sm"
disabled={editingIndex !== null}
onClick={() => handleStartEdit(fileIndex)}
>
Edit
</Button>
<Button
variant="ghost"
size="sm"
disabled={editingIndex !== null}
onClick={() => handleRemove(fileIndex)}
className="text-destructive hover:text-destructive"
>
Remove
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
);
}
const allHosts = [
...envHosts.map((h) => ({ ...h, isEnv: true as const })),
...fileHosts.map((h, i) => ({ ...h, source: "file" as const, isEnv: false as const, fileIndex: i })),
];
return (
<Card>
<CardHeader>
<CardTitle>Docker Hosts</CardTitle>
<CardDescription>
Configure which Docker daemon sockets or remote hosts to connect to.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{allHosts.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Host</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allHosts.map((h) =>
renderRow(
{ name: h.name, host: h.host },
h.isEnv,
h.isEnv ? undefined : h.fileIndex,
),
)}
</TableBody>
</Table>
)}
{allHosts.length === 0 && !isAdding && (
<p className="text-sm text-muted-foreground">
No Docker hosts configured.
</p>
)}
{isAdding && (
<div className="flex items-end gap-3 border rounded-md p-3">
<div className="space-y-1.5 flex-1">
<Label htmlFor="new-docker-name">Name</Label>
<Input
id="new-docker-name"
value={newHost.name}
onChange={(e) =>
setNewHost((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="my-server"
className="h-8"
/>
</div>
<div className="space-y-1.5 flex-[2]">
<Label htmlFor="new-docker-host">Host</Label>
<Input
id="new-docker-host"
value={newHost.host}
onChange={(e) =>
setNewHost((prev) => ({ ...prev, host: e.target.value }))
}
placeholder="ssh://root@10.0.0.1"
className="h-8"
/>
</div>
<div className="flex gap-1">
<Button size="sm" onClick={handleAddHost}>
Add
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setIsAdding(false);
setNewHost({ name: "", host: "" });
}}
>
Cancel
</Button>
</div>
</div>
)}
<div className="flex items-center gap-2">
{!isAdding && (
<Button
variant="outline"
size="sm"
onClick={() => setIsAdding(true)}
>
Add host
</Button>
)}
{hasChanges && (
<Button
size="sm"
disabled={updateMutation.isPending}
onClick={handleSave}
>
{updateMutation.isPending ? (
<>
<Spinner className="size-3" />
Saving...
</>
) : (
"Save changes"
)}
</Button>
)}
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,9 @@
import { Badge } from "@/components/ui/badge";
export function EnvBadge() {
return (
<Badge variant="secondary" className="text-xs font-normal">
Set via environment variable
</Badge>
);
}

View file

@ -0,0 +1,59 @@
import { toast } from "sonner";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useUpdateReadOnly } from "../hooks/use-settings";
import type { ReadOnlyConfig } from "../types";
import { EnvBadge } from "./env-badge";
interface ReadOnlySectionProps {
config: ReadOnlyConfig;
}
export function ReadOnlySection({ config }: ReadOnlySectionProps) {
const isEnv = config.source === "env";
const mutation = useUpdateReadOnly();
function handleToggle(checked: boolean) {
mutation.mutate(checked, {
onSuccess: (msg) => toast.success(msg),
onError: (err) => toast.error(err.message),
});
}
return (
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<CardTitle>Read-Only Mode</CardTitle>
{isEnv && <EnvBadge />}
</div>
<CardDescription>
When enabled, container actions (start, stop, restart, remove) are
disabled. Log viewing is unaffected.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3">
<Switch
id="read-only"
checked={config.value}
onCheckedChange={handleToggle}
disabled={isEnv || mutation.isPending}
/>
<Label htmlFor="read-only" className="cursor-pointer">
{config.value ? "Enabled" : "Disabled"}
</Label>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,48 @@
import { Spinner } from "@/components/ui/spinner";
import { useSettings } from "../hooks/use-settings";
import { AuthSection } from "./auth-section";
import { CoolifyHostsSection } from "./coolify-hosts-section";
import { DockerHostsSection } from "./docker-hosts-section";
import { ReadOnlySection } from "./read-only-section";
export function SettingsPage() {
const { data, isLoading, error } = useSettings();
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 (!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 key={JSON.stringify(data.dockerHosts)} config={data.dockerHosts} />
<CoolifyHostsSection key={JSON.stringify(data.coolifyHosts)} config={data.coolifyHosts} />
<ReadOnlySection config={data.readOnly} />
<AuthSection key={`${data.auth.enabled}-${data.auth.adminUsername}`} config={data.auth} />
</div>
);
}

View file

@ -0,0 +1,80 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getSettings } from "../api/get-settings";
import { testCoolifyHost } from "../api/test-coolify-host";
import { testDockerHost } from "../api/test-docker-host";
import { updateAuth, type UpdateAuthPayload } from "../api/update-auth";
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,
});
}
export function useUpdateDockerHosts() {
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 });
},
});
}
export function useUpdateReadOnly() {
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 });
},
});
}
export function useTestDockerHost() {
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),
});
}

View file

@ -0,0 +1,48 @@
export type ConfigSource = "file" | "env" | "default" | "mixed";
export interface DockerHost {
name: string;
host: string;
source: ConfigSource;
}
export interface CoolifyHost {
hostName: string;
apiURL: string;
apiToken: string;
source: ConfigSource;
}
export interface DockerHostsConfig {
source: ConfigSource;
hosts: DockerHost[];
}
export interface CoolifyHostsConfig {
source: ConfigSource;
hosts: CoolifyHost[];
}
export interface ReadOnlyConfig {
source: ConfigSource;
value: boolean;
}
export interface AuthConfig {
source: ConfigSource;
enabled: boolean;
adminUsername?: string;
}
export interface SettingsResponse {
dockerHosts: DockerHostsConfig;
coolifyHosts: CoolifyHostsConfig;
readOnly: ReadOnlyConfig;
auth: AuthConfig;
}
export interface TestConnectionResult {
success: boolean;
message: string;
dockerVersion?: string;
}

View file

@ -9,6 +9,7 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as LoginRouteImport } from './routes/login'
import { Route as IndexRouteImport } from './routes/index'
import { Route as StatsIndexRouteImport } from './routes/stats/index'
@ -18,6 +19,11 @@ import { Route as AlertsIndexRouteImport } from './routes/alerts/index'
import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-query'
import { Route as ContainersContainerIdLogsRouteImport } from './routes/containers/$containerId/logs'
const SettingsRoute = SettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
@ -63,6 +69,7 @@ const ContainersContainerIdLogsRoute =
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/settings': typeof SettingsRoute
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
'/alerts': typeof AlertsIndexRoute
'/images': typeof ImagesIndexRoute
@ -73,6 +80,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/settings': typeof SettingsRoute
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
'/alerts': typeof AlertsIndexRoute
'/images': typeof ImagesIndexRoute
@ -84,6 +92,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/settings': typeof SettingsRoute
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
'/alerts/': typeof AlertsIndexRoute
'/images/': typeof ImagesIndexRoute
@ -96,6 +105,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/login'
| '/settings'
| '/demo/tanstack-query'
| '/alerts'
| '/images'
@ -106,6 +116,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/login'
| '/settings'
| '/demo/tanstack-query'
| '/alerts'
| '/images'
@ -116,6 +127,7 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/login'
| '/settings'
| '/demo/tanstack-query'
| '/alerts/'
| '/images/'
@ -127,6 +139,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
LoginRoute: typeof LoginRoute
SettingsRoute: typeof SettingsRoute
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
AlertsIndexRoute: typeof AlertsIndexRoute
ImagesIndexRoute: typeof ImagesIndexRoute
@ -137,6 +150,13 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/settings': {
id: '/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
@ -199,6 +219,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
LoginRoute: LoginRoute,
SettingsRoute: SettingsRoute,
DemoTanstackQueryRoute: DemoTanstackQueryRoute,
AlertsIndexRoute: AlertsIndexRoute,
ImagesIndexRoute: ImagesIndexRoute,

View file

@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { SettingsPage } from "@/features/settings/components/settings-page";
export const Route = createFileRoute("/settings")({
component: SettingsPage,
});

View file

@ -1,33 +1,42 @@
package main
import (
"fmt"
"log"
"net/http"
"time"
"github.com/hhftechnology/vps-monitor/internal/alerts"
"github.com/hhftechnology/vps-monitor/internal/api"
"github.com/hhftechnology/vps-monitor/internal/auth"
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/coolify"
"github.com/hhftechnology/vps-monitor/internal/docker"
"github.com/hhftechnology/vps-monitor/internal/services"
"github.com/hhftechnology/vps-monitor/internal/system"
)
func main() {
system.Init()
cfg := config.NewConfig()
fmt.Println("Config", cfg)
manager := config.NewManager()
cfg := manager.Config()
multiHostClient, err := docker.NewMultiHostClient(cfg.DockerHosts)
if err != nil {
panic(err)
log.Fatalf("Failed to create Docker client: %v", err)
}
// Auth: env-based first, then file-based fallback.
authService, err := auth.NewService()
if err != nil {
log.Fatalf("Failed to initialize auth service: %v\nPlease ensure ALL auth environment variables are set: JWT_SECRET, ADMIN_USERNAME, and ADMIN_PASSWORD.", err)
}
if authService == nil {
fc := manager.FileConfigSnapshot()
if fc.Auth != nil && fc.Auth.Enabled {
authService = auth.NewServiceFromFileConfig(fc.Auth)
}
}
if authService == nil {
log.Println("Authentication is DISABLED - no auth environment variables detected")
@ -43,7 +52,15 @@ func main() {
log.Println("Read-only mode is DISABLED - all operations are allowed")
}
// Initialize alert monitor if enabled
// Coolify client
coolifyClient := coolify.NewMultiClient(cfg.CoolifyHosts)
if coolifyClient != nil {
log.Printf("Coolify integration is ENABLED (%d host configs)", len(cfg.CoolifyHosts))
} else {
log.Println("Coolify integration is DISABLED")
}
// Alert monitor (vps-monitor specific)
var alertMonitor *alerts.Monitor
if cfg.Alerts.Enabled {
alertMonitor = alerts.NewMonitor(multiHostClient, &cfg.Alerts)
@ -60,10 +77,40 @@ func main() {
log.Println(" To enable alerts, set: ALERTS_ENABLED=true")
}
registry := services.NewRegistry(multiHostClient, coolifyClient, authService, cfg, alertMonitor)
// Hot-reload callback
manager.OnChange(func(newCfg *config.Config) {
registry.UpdateConfig(newCfg)
// Recreate Docker clients
newDocker, err := docker.NewMultiHostClient(newCfg.DockerHosts)
if err != nil {
log.Printf("Warning: failed to recreate Docker clients after config change: %v", err)
} else {
old := registry.SwapDocker(newDocker)
go func() {
time.Sleep(30 * time.Second)
old.Close()
}()
}
// Recreate Coolify clients
registry.SwapCoolify(coolify.NewMultiClient(newCfg.CoolifyHosts))
// Recreate auth service from file config (env-based auth is immutable)
fc := manager.FileConfigSnapshot()
if manager.Sources().Auth == config.SourceFile && fc.Auth != nil {
registry.SwapAuth(auth.NewServiceFromFileConfig(fc.Auth))
}
log.Println("Configuration reloaded successfully")
})
routerOpts := &api.RouterOptions{
AlertMonitor: alertMonitor,
}
apiRouter := api.NewRouter(multiHostClient, authService, cfg, routerOpts)
apiRouter := api.NewRouter(registry, manager, routerOpts)
log.Println("Server starting on :6789")
if err := http.ListenAndServe(":6789", apiRouter); err != nil {

View file

@ -1,73 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"github.com/hhftechnology/vps-monitor/internal/auth"
"github.com/hhftechnology/vps-monitor/internal/models"
)
type AuthHandlers struct {
authService *auth.Service
}
func NewAuthHandlers(authService *auth.Service) *AuthHandlers {
return &AuthHandlers{
authService: authService,
}
}
func (ah *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
var loginReq models.LoginRequest
if err := json.NewDecoder(r.Body).Decode(&loginReq); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if loginReq.Username == "" || loginReq.Password == "" {
http.Error(w, "Username and password are required", http.StatusBadRequest)
return
}
if err := ah.authService.ValidateCredentials(loginReq.Username, loginReq.Password); err != nil {
http.Error(w, "Invalid username or password", http.StatusUnauthorized)
return
}
token, err := ah.authService.GenerateToken(loginReq.Username)
if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
response := models.LoginResponse{
Token: token,
User: models.User{
Username: loginReq.Username,
Role: "admin",
},
}
WriteJsonResponse(w, http.StatusOK, response)
}
// GetMe returns the current authenticated user's information
func (ah *AuthHandlers) GetMe(w http.ResponseWriter, r *http.Request) {
userValue := r.Context().Value(auth.UserContextKey)
if userValue == nil {
http.Error(w, "User not found in context", http.StatusUnauthorized)
return
}
user, ok := userValue.(models.User)
if !ok {
http.Error(w, "Invalid user data in context", http.StatusInternalServerError)
return
}
WriteJsonResponse(w, http.StatusOK, map[string]interface{}{
"user": user,
})
}

View file

@ -4,12 +4,14 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"regexp"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/hhftechnology/vps-monitor/internal/coolify"
"github.com/hhftechnology/vps-monitor/internal/models"
"github.com/hhftechnology/vps-monitor/internal/system"
)
@ -26,38 +28,45 @@ func (ar *APIRouter) GetSystemStats(w http.ResponseWriter, r *http.Request) {
}
// Override hostname if configured
if ar.config.Hostname != "" {
stats.HostInfo.Hostname = ar.config.Hostname
cfg := ar.registry.Config()
if cfg.Hostname != "" {
stats.HostInfo.Hostname = cfg.Hostname
}
WriteJsonResponse(w, http.StatusOK, stats)
}
func (ar *APIRouter) GetContainers(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
containersMap, hostErrors, err := ar.docker.ListContainersAllHosts(ctx)
containersMap, hostErrors, err := ar.registry.Docker().ListContainersAllHosts(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if len(hostErrors) > 0 {
http.Error(w, fmt.Sprintf("Error listing containers on some hosts: %v", hostErrors), http.StatusInternalServerError)
return
}
// Flatten the map for easier frontend consumption
allContainers := []models.ContainerInfo{}
for _, containers := range containersMap {
allContainers = append(allContainers, containers...)
}
// Build host errors list for the frontend (graceful partial results)
hostErrorMessages := make([]map[string]string, 0, len(hostErrors))
for _, he := range hostErrors {
hostErrorMessages = append(hostErrorMessages, map[string]string{
"host": he.HostName,
"message": he.Err.Error(),
})
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"containers": allContainers,
"hosts": ar.docker.GetHosts(),
"readOnly": ar.config.ReadOnly,
"containers": allContainers,
"hosts": ar.registry.Docker().GetHosts(),
"readOnly": ar.registry.Config().ReadOnly,
"hostErrors": hostErrorMessages,
"coolifyConfigured": ar.registry.Coolify() != nil,
})
}
@ -70,15 +79,12 @@ func (ar *APIRouter) GetContainer(w http.ResponseWriter, r *http.Request) {
return
}
inspect, err := ar.docker.GetContainer(r.Context(), host, id)
inspect, err := ar.registry.Docker().GetContainer(r.Context(), host, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Transform InspectResponse into a flat structure matching the frontend ContainerDetailData interface.
// The raw InspectResponse has "State" as an object (e.g. {Status:"running", Running:true}),
// but the frontend expects "state" as a simple string like the list endpoint returns.
name := inspect.Name
if len(name) > 0 && name[0] == '/' {
name = name[1:]
@ -130,7 +136,7 @@ func (ar *APIRouter) StartContainer(w http.ResponseWriter, r *http.Request) {
return
}
err := ar.docker.StartContainer(r.Context(), host, id)
err := ar.registry.Docker().StartContainer(r.Context(), host, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@ -149,7 +155,7 @@ func (ar *APIRouter) StopContainer(w http.ResponseWriter, r *http.Request) {
return
}
err := ar.docker.StopContainer(r.Context(), host, id)
err := ar.registry.Docker().StopContainer(r.Context(), host, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@ -168,7 +174,7 @@ func (ar *APIRouter) RestartContainer(w http.ResponseWriter, r *http.Request) {
return
}
err := ar.docker.RestartContainer(r.Context(), host, id)
err := ar.registry.Docker().RestartContainer(r.Context(), host, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@ -187,7 +193,7 @@ func (ar *APIRouter) RemoveContainer(w http.ResponseWriter, r *http.Request) {
return
}
err := ar.docker.RemoveContainer(r.Context(), host, id)
err := ar.registry.Docker().RemoveContainer(r.Context(), host, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@ -206,7 +212,6 @@ func (ar *APIRouter) GetContainerLogsParsed(w http.ResponseWriter, r *http.Reque
return
}
// Parse query parameters for log options
options := parseLogOptions(r)
if options.Follow {
@ -214,7 +219,7 @@ func (ar *APIRouter) GetContainerLogsParsed(w http.ResponseWriter, r *http.Reque
return
}
logs, err := ar.docker.GetContainerLogsParsed(host, id, options)
logs, err := ar.registry.Docker().GetContainerLogsParsed(host, id, options)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@ -227,7 +232,7 @@ func (ar *APIRouter) GetContainerLogsParsed(w http.ResponseWriter, r *http.Reque
}
func (ar *APIRouter) streamParsedLogs(w http.ResponseWriter, host, id string, options models.LogOptions) {
stream, err := ar.docker.StreamContainerLogsParsed(host, id, options)
stream, err := ar.registry.Docker().StreamContainerLogsParsed(host, id, options)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@ -308,7 +313,7 @@ func (ar *APIRouter) GetEnvVariables(w http.ResponseWriter, r *http.Request) {
return
}
envVariables, err := ar.docker.GetEnvVariables(r.Context(), host, id)
envVariables, err := ar.registry.Docker().GetEnvVariables(r.Context(), host, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@ -334,7 +339,6 @@ func (ar *APIRouter) UpdateEnvVariables(w http.ResponseWriter, r *http.Request)
return
}
// Validate environment variable keys (using pre-compiled regex)
for key := range envVariables.Env {
if !envKeyRegex.MatchString(key) {
http.Error(w, fmt.Sprintf("invalid environment variable key: %s", key), http.StatusBadRequest)
@ -342,14 +346,35 @@ func (ar *APIRouter) UpdateEnvVariables(w http.ResponseWriter, r *http.Request)
}
}
newContainerID, err := ar.docker.SetEnvVariables(r.Context(), host, id, envVariables.Env)
newContainerID, labels, err := ar.registry.Docker().SetEnvVariables(r.Context(), host, id, envVariables.Env)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
response := map[string]any{
"message": "Environment variables updated",
"new_container_id": newContainerID,
})
}
// Best-effort sync to Coolify API
coolifyMulti := ar.registry.Coolify()
if coolifyMulti != nil {
coolifyClient := coolifyMulti.GetClient(host)
coolifyResource := coolify.ExtractResourceInfo(labels)
if coolifyClient != nil && coolifyResource != nil {
syncCtx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if syncErr := coolifyClient.SyncEnvVars(syncCtx, coolifyResource, envVariables.Env); syncErr != nil {
log.Printf("Warning: failed to sync env vars to Coolify for host %s: %v", host, syncErr)
response["coolify_synced"] = false
response["coolify_error"] = syncErr.Error()
} else {
response["coolify_synced"] = true
}
}
}
WriteJsonResponse(w, http.StatusOK, response)
}

View file

@ -19,27 +19,32 @@ func (ar *APIRouter) GetImages(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
imagesMap, hostErrors, err := ar.docker.ListImagesAllHosts(ctx)
imagesMap, hostErrors, err := ar.registry.Docker().ListImagesAllHosts(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if len(hostErrors) > 0 {
http.Error(w, fmt.Sprintf("Error listing images on some hosts: %v", hostErrors), http.StatusInternalServerError)
return
}
// Flatten the map for easier frontend consumption
allImages := []models.ImageInfo{}
for _, images := range imagesMap {
allImages = append(allImages, images...)
}
// Build host errors list (graceful partial results)
hostErrorMessages := make([]map[string]string, 0, len(hostErrors))
for _, he := range hostErrors {
hostErrorMessages = append(hostErrorMessages, map[string]string{
"host": he.HostName,
"message": he.Err.Error(),
})
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"images": allImages,
"hosts": ar.docker.GetHosts(),
"readOnly": ar.config.ReadOnly,
"images": allImages,
"hosts": ar.registry.Docker().GetHosts(),
"readOnly": ar.registry.Config().ReadOnly,
"hostErrors": hostErrorMessages,
})
}
@ -60,7 +65,7 @@ func (ar *APIRouter) GetImage(w http.ResponseWriter, r *http.Request) {
return
}
image, err := ar.docker.GetImage(r.Context(), host, id)
image, err := ar.registry.Docker().GetImage(r.Context(), host, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@ -91,7 +96,7 @@ func (ar *APIRouter) RemoveImage(w http.ResponseWriter, r *http.Request) {
force, _ := strconv.ParseBool(forceStr)
result, err := ar.docker.RemoveImage(r.Context(), host, id, force)
result, err := ar.registry.Docker().RemoveImage(r.Context(), host, id, force)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to remove image: %v", err), http.StatusInternalServerError)
return
@ -118,7 +123,7 @@ func (ar *APIRouter) PullImage(w http.ResponseWriter, r *http.Request) {
return
}
reader, err := ar.docker.PullImage(r.Context(), host, imageName)
reader, err := ar.registry.Docker().PullImage(r.Context(), host, imageName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return

View file

@ -3,15 +3,14 @@ package middleware
import (
"encoding/json"
"net/http"
"github.com/hhftechnology/vps-monitor/internal/config"
)
// ReadOnly creates a middleware that blocks mutating requests when in read-only mode
func ReadOnly(cfg *config.Config) func(http.Handler) http.Handler {
// ReadOnly creates a middleware that blocks mutating requests when in read-only mode.
// The isReadOnly function is evaluated per request, supporting hot-reload.
func ReadOnly(isReadOnly func() bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if cfg.ReadOnly {
if isReadOnly() {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)

View file

@ -2,7 +2,6 @@ package api
import (
"context"
"fmt"
"net/http"
"time"
@ -15,26 +14,31 @@ func (ar *APIRouter) GetNetworks(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
networksMap, hostErrors, err := ar.docker.ListNetworksAllHosts(ctx)
networksMap, hostErrors, err := ar.registry.Docker().ListNetworksAllHosts(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if len(hostErrors) > 0 {
http.Error(w, fmt.Sprintf("Error listing networks on some hosts: %v", hostErrors), http.StatusInternalServerError)
return
}
// Flatten the map for easier frontend consumption
allNetworks := []models.NetworkInfo{}
for _, networks := range networksMap {
allNetworks = append(allNetworks, networks...)
}
// Build host errors list (graceful partial results)
hostErrorMessages := make([]map[string]string, 0, len(hostErrors))
for _, he := range hostErrors {
hostErrorMessages = append(hostErrorMessages, map[string]string{
"host": he.HostName,
"message": he.Err.Error(),
})
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"networks": allNetworks,
"hosts": ar.docker.GetHosts(),
"networks": allNetworks,
"hosts": ar.registry.Docker().GetHosts(),
"hostErrors": hostErrorMessages,
})
}
@ -48,7 +52,7 @@ func (ar *APIRouter) GetNetwork(w http.ResponseWriter, r *http.Request) {
return
}
network, err := ar.docker.GetNetworkDetails(r.Context(), host, id)
network, err := ar.registry.Docker().GetNetworkDetails(r.Context(), host, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return

View file

@ -13,8 +13,8 @@ import (
"github.com/hhftechnology/vps-monitor/internal/api/middleware"
"github.com/hhftechnology/vps-monitor/internal/auth"
"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/services"
"github.com/hhftechnology/vps-monitor/internal/static"
)
@ -27,10 +27,8 @@ var jsonBufferPool = sync.Pool{
type APIRouter struct {
router *chi.Mux
docker *docker.MultiHostClient
authService *auth.Service
config *config.Config
alertMonitor *alerts.Monitor
registry *services.Registry
manager *config.Manager
alertHandlers *AlertHandlers
}
@ -39,33 +37,31 @@ type RouterOptions struct {
AlertMonitor *alerts.Monitor
}
func NewRouter(docker *docker.MultiHostClient, authService *auth.Service, config *config.Config, opts *RouterOptions) *chi.Mux {
func NewRouter(registry *services.Registry, manager *config.Manager, opts *RouterOptions) *chi.Mux {
cfg := registry.Config()
r := &APIRouter{
router: chi.NewRouter(),
docker: docker,
authService: authService,
config: config,
router: chi.NewRouter(),
registry: registry,
manager: manager,
}
// Set up alert handlers if monitor is provided
// Set up alert handlers
if opts != nil && opts.AlertMonitor != nil {
r.alertMonitor = opts.AlertMonitor
r.alertHandlers = NewAlertHandlers(opts.AlertMonitor, &models.AlertConfigResponse{
Enabled: config.Alerts.Enabled,
CPUThreshold: config.Alerts.CPUThreshold,
MemoryThreshold: config.Alerts.MemoryThreshold,
CheckInterval: config.Alerts.CheckInterval.String(),
WebhookEnabled: config.Alerts.WebhookURL != "",
Enabled: cfg.Alerts.Enabled,
CPUThreshold: cfg.Alerts.CPUThreshold,
MemoryThreshold: cfg.Alerts.MemoryThreshold,
CheckInterval: cfg.Alerts.CheckInterval.String(),
WebhookEnabled: cfg.Alerts.WebhookURL != "",
})
} else {
// Create handlers with nil monitor (alerts disabled) but preserve the
// configured/default thresholds so clients can still display accurate config.
r.alertHandlers = NewAlertHandlers(nil, &models.AlertConfigResponse{
Enabled: config.Alerts.Enabled,
CPUThreshold: config.Alerts.CPUThreshold,
MemoryThreshold: config.Alerts.MemoryThreshold,
CheckInterval: config.Alerts.CheckInterval.String(),
WebhookEnabled: config.Alerts.WebhookURL != "",
Enabled: cfg.Alerts.Enabled,
CPUThreshold: cfg.Alerts.CPUThreshold,
MemoryThreshold: cfg.Alerts.MemoryThreshold,
CheckInterval: cfg.Alerts.CheckInterval.String(),
WebhookEnabled: cfg.Alerts.WebhookURL != "",
})
}
@ -99,37 +95,29 @@ func (ar *APIRouter) Routes() *chi.Mux {
// API routes
ar.router.Route("/api/v1", func(r chi.Router) {
// System stats - publicly available for now
// System stats - publicly available
r.Get("/system/stats", ar.GetSystemStats)
if ar.authService != nil {
authHandlers := NewAuthHandlers(ar.authService)
r.Post("/auth/login", authHandlers.Login)
// Auth login - always registered, dynamic behavior
r.Post("/auth/login", ar.handleLogin)
r.Group(func(protected chi.Router) {
protected.Use(auth.Middleware(ar.authService))
// Settings endpoints (protected by dynamic auth)
ar.registerSettingsRoutes(r)
protected.Get("/auth/me", authHandlers.GetMe)
// protected.Get("/system/stats", ar.GetSystemStats) // Moved to public
protected.Post("/devices/register", ar.RegisterDevice)
ar.registerContainerRoutes(protected)
ar.registerImageRoutes(protected)
ar.registerNetworkRoutes(protected)
ar.registerAlertRoutes(protected)
})
return
}
// All other routes go through dynamic auth middleware
r.Group(func(protected chi.Router) {
protected.Use(auth.DynamicMiddleware(ar.registry.Auth))
// r.Get("/system/stats", ar.GetSystemStats) // Already registered above
r.Post("/devices/register", ar.RegisterDevice)
ar.registerContainerRoutes(r)
ar.registerImageRoutes(r)
ar.registerNetworkRoutes(r)
ar.registerAlertRoutes(r)
protected.Get("/auth/me", ar.handleGetMe)
protected.Post("/devices/register", ar.RegisterDevice)
ar.registerContainerRoutes(protected)
ar.registerImageRoutes(protected)
ar.registerNetworkRoutes(protected)
ar.registerAlertRoutes(protected)
})
})
// Serve embedded frontend static files
// This handles all non-API routes and serves the React SPA
staticFS, err := static.GetFileSystem()
if err != nil {
log.Printf("Warning: Could not load embedded frontend files: %v", err)
@ -149,12 +137,14 @@ func (ar *APIRouter) registerContainerRoutes(r chi.Router) {
r.Get("/", ar.GetContainer)
r.Get("/logs/parsed", ar.GetContainerLogsParsed)
r.Get("/env", ar.GetEnvVariables)
r.Get("/stats", ar.HandleContainerStats) // WebSocket for streaming stats
r.Get("/stats/once", ar.GetContainerStatsOnce) // Single snapshot
r.Get("/stats", ar.HandleContainerStats)
r.Get("/stats/once", ar.GetContainerStatsOnce)
// Mutating routes (blocked in read-only mode)
r.Group(func(mutating chi.Router) {
mutating.Use(middleware.ReadOnly(ar.config))
mutating.Use(middleware.ReadOnly(func() bool {
return ar.registry.Config().ReadOnly
}))
mutating.Post("/start", ar.StartContainer)
mutating.Post("/stop", ar.StopContainer)
mutating.Post("/restart", ar.RestartContainer)
@ -172,14 +162,18 @@ func (ar *APIRouter) registerImageRoutes(r chi.Router) {
// Mutating routes (blocked in read-only mode)
r.Group(func(mutating chi.Router) {
mutating.Use(middleware.ReadOnly(ar.config))
mutating.Use(middleware.ReadOnly(func() bool {
return ar.registry.Config().ReadOnly
}))
mutating.Delete("/", ar.RemoveImage)
})
})
// Image pull (mutating)
r.Group(func(mutating chi.Router) {
mutating.Use(middleware.ReadOnly(ar.config))
mutating.Use(middleware.ReadOnly(func() bool {
return ar.registry.Config().ReadOnly
}))
mutating.Post("/images/pull", ar.PullImage)
})
}
@ -195,3 +189,72 @@ func (ar *APIRouter) registerAlertRoutes(r chi.Router) {
r.Post("/alerts/{id}/acknowledge", ar.alertHandlers.AcknowledgeAlert)
r.Post("/alerts/acknowledge-all", ar.alertHandlers.AcknowledgeAllAlerts)
}
func (ar *APIRouter) registerSettingsRoutes(r chi.Router) {
r.Route("/settings", func(r chi.Router) {
r.Use(auth.DynamicMiddleware(ar.registry.Auth))
r.Get("/", ar.GetSettings)
r.Put("/docker-hosts", ar.UpdateDockerHosts)
r.Put("/coolify-hosts", ar.UpdateCoolifyHosts)
r.Put("/read-only", ar.UpdateReadOnly)
r.Put("/auth", ar.UpdateAuth)
r.Post("/test/docker-host", ar.TestDockerHost)
r.Post("/test/coolify-host", ar.TestCoolifyHost)
})
}
// handleLogin delegates to the dynamic auth service.
func (ar *APIRouter) handleLogin(w http.ResponseWriter, r *http.Request) {
svc := ar.registry.Auth()
if svc == nil {
http.Error(w, "Authentication is not enabled", http.StatusNotFound)
return
}
var loginReq struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&loginReq); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if loginReq.Username == "" || loginReq.Password == "" {
http.Error(w, "Username and password are required", http.StatusBadRequest)
return
}
if err := svc.ValidateCredentials(loginReq.Username, loginReq.Password); err != nil {
http.Error(w, "Invalid username or password", http.StatusUnauthorized)
return
}
token, err := svc.GenerateToken(loginReq.Username)
if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"token": token,
"user": map[string]string{
"username": loginReq.Username,
"role": "admin",
},
})
}
// handleGetMe returns the current authenticated user's information.
func (ar *APIRouter) handleGetMe(w http.ResponseWriter, r *http.Request) {
userValue := r.Context().Value(auth.UserContextKey)
if userValue == nil {
http.Error(w, "User not found in context", http.StatusUnauthorized)
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"user": userValue,
})
}

View file

@ -0,0 +1,401 @@
package api
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"regexp"
"strings"
"time"
"github.com/hhftechnology/vps-monitor/internal/auth"
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/coolify"
"github.com/hhftechnology/vps-monitor/internal/docker"
)
const secretMask = "••••••••"
var hostNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)
// GetSettings returns the current configuration with source tracking and masked secrets.
func (ar *APIRouter) GetSettings(w http.ResponseWriter, r *http.Request) {
sources := ar.manager.Sources()
cfg := ar.registry.Config()
fc := ar.manager.FileConfigSnapshot()
// Docker hosts with per-entry source
envDockerNames := ar.manager.EnvDockerHostNames()
dockerHosts := make([]map[string]any, 0, len(cfg.DockerHosts))
for _, h := range cfg.DockerHosts {
source := config.SourceFile
if envDockerNames[h.Name] {
source = config.SourceEnv
}
dockerHosts = append(dockerHosts, map[string]any{
"name": h.Name,
"host": h.Host,
"source": source,
})
}
// Coolify hosts with per-entry source (mask tokens)
envCoolifyNames := ar.manager.EnvCoolifyHostNames()
coolifyHosts := make([]map[string]any, 0, len(cfg.CoolifyHosts))
for _, ch := range cfg.CoolifyHosts {
source := config.SourceFile
if envCoolifyNames[ch.HostName] {
source = config.SourceEnv
}
coolifyHosts = append(coolifyHosts, map[string]any{
"hostName": ch.HostName,
"apiURL": ch.APIURL,
"apiToken": secretMask,
"source": source,
})
}
// Auth
authResp := map[string]any{
"source": sources.Auth,
"enabled": false,
}
if sources.Auth == config.SourceEnv {
svc := ar.registry.Auth()
authResp["enabled"] = svc != nil
} else if fc.Auth != nil {
authResp["enabled"] = fc.Auth.Enabled
authResp["adminUsername"] = fc.Auth.AdminUsername
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"dockerHosts": map[string]any{
"source": sources.DockerHosts,
"hosts": dockerHosts,
},
"coolifyHosts": map[string]any{
"source": sources.CoolifyHosts,
"hosts": coolifyHosts,
},
"readOnly": map[string]any{
"source": sources.ReadOnly,
"value": cfg.ReadOnly,
},
"auth": authResp,
})
}
// UpdateDockerHosts handles PUT /api/v1/settings/docker-hosts.
func (ar *APIRouter) UpdateDockerHosts(w http.ResponseWriter, r *http.Request) {
var req struct {
Hosts []config.DockerHost `json:"hosts"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if len(req.Hosts) == 0 && len(ar.manager.EnvDockerHostNames()) == 0 {
http.Error(w, "at least one Docker host is required", http.StatusBadRequest)
return
}
seen := make(map[string]bool)
for _, h := range req.Hosts {
if !hostNameRegex.MatchString(h.Name) {
http.Error(w, fmt.Sprintf("invalid host name: %q", h.Name), http.StatusBadRequest)
return
}
if !isValidHostScheme(h.Host) {
http.Error(w, fmt.Sprintf("invalid host URL: %q (must start with unix://, ssh://, or tcp://)", h.Host), http.StatusBadRequest)
return
}
if seen[h.Name] {
http.Error(w, fmt.Sprintf("duplicate host name: %q", h.Name), http.StatusBadRequest)
return
}
seen[h.Name] = true
}
if err := ar.manager.UpdateDockerHosts(req.Hosts); err != nil {
http.Error(w, err.Error(), settingsErrorStatus(err))
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{"message": "Docker hosts updated"})
}
// UpdateCoolifyHosts handles PUT /api/v1/settings/coolify-hosts.
func (ar *APIRouter) UpdateCoolifyHosts(w http.ResponseWriter, r *http.Request) {
var req struct {
Hosts []struct {
HostName string `json:"hostName"`
APIURL string `json:"apiURL"`
APIToken string `json:"apiToken"`
} `json:"hosts"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
existing := ar.manager.FileConfigSnapshot()
existingMap := make(map[string]string)
for _, ch := range existing.CoolifyHosts {
existingMap[ch.HostName] = ch.APIToken
}
hosts := make([]config.CoolifyHostConfig, 0, len(req.Hosts))
seen := make(map[string]bool)
for _, h := range req.Hosts {
if h.HostName == "" || h.APIURL == "" || h.APIToken == "" {
http.Error(w, "hostName, apiURL, and apiToken are required for each entry", http.StatusBadRequest)
return
}
if !hostNameRegex.MatchString(h.HostName) {
http.Error(w, fmt.Sprintf("invalid host name: %q", h.HostName), http.StatusBadRequest)
return
}
if !isValidCoolifyURL(h.APIURL) {
http.Error(w, fmt.Sprintf("invalid API URL: %q (must start with http:// or https://)", h.APIURL), http.StatusBadRequest)
return
}
if seen[h.HostName] {
http.Error(w, fmt.Sprintf("duplicate host name: %q", h.HostName), http.StatusBadRequest)
return
}
seen[h.HostName] = true
token := h.APIToken
if token == secretMask {
if stored, ok := existingMap[h.HostName]; ok {
token = stored
} else {
http.Error(w, fmt.Sprintf("no existing token for host %q; provide the actual token", h.HostName), http.StatusBadRequest)
return
}
}
hosts = append(hosts, config.CoolifyHostConfig{
HostName: h.HostName,
APIURL: strings.TrimRight(h.APIURL, "/"),
APIToken: token,
})
}
if err := ar.manager.UpdateCoolifyHosts(hosts); err != nil {
http.Error(w, err.Error(), settingsErrorStatus(err))
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{"message": "Coolify hosts updated"})
}
// UpdateReadOnly handles PUT /api/v1/settings/read-only.
func (ar *APIRouter) UpdateReadOnly(w http.ResponseWriter, r *http.Request) {
var req struct {
Value bool `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if err := ar.manager.UpdateReadOnly(req.Value); err != nil {
http.Error(w, err.Error(), settingsErrorStatus(err))
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{"message": "Read-only mode updated"})
}
// UpdateAuth handles PUT /api/v1/settings/auth.
func (ar *APIRouter) UpdateAuth(w http.ResponseWriter, r *http.Request) {
var req struct {
Enabled bool `json:"enabled"`
AdminUsername string `json:"adminUsername"`
NewPassword string `json:"newPassword,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Enabled && req.AdminUsername == "" {
http.Error(w, "adminUsername is required when enabling auth", http.StatusBadRequest)
return
}
err := ar.manager.UpdateAuth(func(authCfg *config.FileAuthConfig) (*config.FileAuthConfig, error) {
authCfg.Enabled = req.Enabled
if req.Enabled {
authCfg.AdminUsername = req.AdminUsername
if authCfg.JWTSecret == "" {
secret, err := auth.GenerateRandomHex(32)
if err != nil {
return nil, fmt.Errorf("failed to generate JWT secret")
}
authCfg.JWTSecret = secret
}
if req.NewPassword != "" {
salt, err := auth.GenerateRandomHex(32)
if err != nil {
return nil, fmt.Errorf("failed to generate salt")
}
authCfg.AdminPasswordSalt = salt
authCfg.AdminPasswordHash = auth.HashPasswordSHA256(req.NewPassword, salt)
}
if authCfg.AdminPasswordHash == "" {
return nil, fmt.Errorf("newPassword is required when first enabling auth")
}
}
return authCfg, nil
})
if err != nil {
http.Error(w, err.Error(), settingsErrorStatus(err))
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{"message": "Auth settings updated"})
}
// TestDockerHost handles POST /api/v1/settings/test/docker-host.
func (ar *APIRouter) TestDockerHost(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Host string `json:"host"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Host == "" {
http.Error(w, "host is required", http.StatusBadRequest)
return
}
if !isValidHostScheme(req.Host) {
http.Error(w, "invalid host URL (must start with unix://, ssh://, or tcp://)", http.StatusBadRequest)
return
}
tempClient, err := docker.NewMultiHostClient([]config.DockerHost{
{Name: "test", Host: req.Host},
})
if err != nil {
WriteJsonResponse(w, http.StatusOK, map[string]any{
"success": false,
"message": fmt.Sprintf("Failed to create client: %v", err),
})
return
}
defer tempClient.Close()
cl, err := tempClient.GetClient("test")
if err != nil {
WriteJsonResponse(w, http.StatusOK, map[string]any{
"success": false,
"message": fmt.Sprintf("Internal error: %v", err),
})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
ping, err := cl.Ping(ctx)
if err != nil {
WriteJsonResponse(w, http.StatusOK, map[string]any{
"success": false,
"message": fmt.Sprintf("Connection failed: %v", err),
})
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"success": true,
"message": "Connection successful",
"dockerVersion": ping.APIVersion,
})
}
// TestCoolifyHost handles POST /api/v1/settings/test/coolify-host.
func (ar *APIRouter) TestCoolifyHost(w http.ResponseWriter, r *http.Request) {
var req struct {
HostName string `json:"hostName"`
APIURL string `json:"apiURL"`
APIToken string `json:"apiToken"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.APIURL == "" || req.APIToken == "" {
http.Error(w, "apiURL and apiToken are required", http.StatusBadRequest)
return
}
if !isValidCoolifyURL(req.APIURL) {
http.Error(w, "invalid API URL (must start with http:// or https://)", http.StatusBadRequest)
return
}
token := req.APIToken
if token == secretMask {
fc := ar.manager.FileConfigSnapshot()
for _, ch := range fc.CoolifyHosts {
if ch.HostName == req.HostName {
token = ch.APIToken
break
}
}
if token == secretMask {
http.Error(w, "no stored token found; provide the actual token", http.StatusBadRequest)
return
}
}
client := coolify.NewSingleClient(strings.TrimRight(req.APIURL, "/"), token)
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := client.TestConnection(ctx); err != nil {
log.Printf("Coolify test connection failed: %v", err)
WriteJsonResponse(w, http.StatusOK, map[string]any{
"success": false,
"message": fmt.Sprintf("Connection failed: %v", err),
})
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"success": true,
"message": "Connection successful",
})
}
func settingsErrorStatus(err error) int {
if strings.Contains(err.Error(), "environment variable") {
return http.StatusConflict
}
return http.StatusInternalServerError
}
func isValidHostScheme(host string) bool {
return strings.HasPrefix(host, "unix://") ||
strings.HasPrefix(host, "ssh://") ||
strings.HasPrefix(host, "tcp://")
}
func isValidCoolifyURL(raw string) bool {
return strings.HasPrefix(raw, "https://") || strings.HasPrefix(raw, "http://")
}

View file

@ -37,7 +37,7 @@ func (ar *APIRouter) HandleContainerStats(w http.ResponseWriter, r *http.Request
ctx := r.Context()
// Start streaming stats from Docker
statsCh, errCh := ar.docker.StreamContainerStats(ctx, host, id)
statsCh, errCh := ar.registry.Docker().StreamContainerStats(ctx, host, id)
// Handle WebSocket close from client
done := make(chan struct{})
@ -111,7 +111,7 @@ func (ar *APIRouter) GetContainerStatsOnce(w http.ResponseWriter, r *http.Reques
return
}
stats, err := ar.docker.GetContainerStatsOnce(r.Context(), host, id)
stats, err := ar.registry.Docker().GetContainerStatsOnce(r.Context(), host, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return

View file

@ -104,12 +104,12 @@ func (ar *APIRouter) HandleTerminal(w http.ResponseWriter, r *http.Request) {
}
func (ar *APIRouter) startExecSession(ctx context.Context, host, containerID string) (string, *types.HijackedResponse, error) {
execID, err := ar.docker.CreateExec(ctx, host, containerID)
execID, err := ar.registry.Docker().CreateExec(ctx, host, containerID)
if err != nil {
return "", nil, fmt.Errorf("create exec failed: %w", err)
}
resp, err := ar.docker.AttachExec(ctx, host, execID)
resp, err := ar.registry.Docker().AttachExec(ctx, host, execID)
if err != nil {
return "", nil, fmt.Errorf("attach exec failed: %w", err)
}
@ -165,7 +165,7 @@ func (ar *APIRouter) forwardClientInput(
if messageType == websocket.TextMessage {
var msg ResizeMessage
if err := json.Unmarshal(data, &msg); err == nil && msg.Type == "resize" {
if err := ar.docker.ResizeExec(ctx, host, execID, msg.Rows, msg.Cols); err != nil {
if err := ar.registry.Docker().ResizeExec(ctx, host, execID, msg.Rows, msg.Cols); err != nil {
log.Printf("failed to resize terminal: %v", err)
}
continue

View file

@ -11,44 +11,62 @@ type contextKey string
const UserContextKey contextKey = "user"
// Middleware creates an authentication middleware
func Middleware(authService *Service) func(http.Handler) http.Handler {
// DynamicMiddleware resolves the auth service per request, supporting hot-reload.
// If the service function returns nil, auth is disabled and the request passes through.
func DynamicMiddleware(getService func() *Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
var tokenString string
if authHeader != "" {
// Check if the header starts with "Bearer "
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, "Invalid authorization header format", http.StatusUnauthorized)
return
}
tokenString = parts[1]
} else {
// Try to get token from query parameter (useful for WebSockets)
tokenString = r.URL.Query().Get("token")
if tokenString == "" {
http.Error(w, "Authorization header or token query parameter required", http.StatusUnauthorized)
return
}
}
claims, err := authService.VerifyToken(tokenString)
if err != nil {
if errors.Is(err, ErrTokenExpired) {
http.Error(w, "Token has expired", http.StatusUnauthorized)
return
}
http.Error(w, "Invalid token", http.StatusUnauthorized)
svc := getService()
if svc == nil {
next.ServeHTTP(w, r)
return
}
user := GetUserFromClaims(claims)
ctx := context.WithValue(r.Context(), UserContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
validateAndServe(svc, next, w, r)
})
}
}
// Middleware creates an authentication middleware with a fixed auth service.
func Middleware(authService *Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
validateAndServe(authService, next, w, r)
})
}
}
// validateAndServe extracts JWT, validates it, adds user to context, and calls next.
func validateAndServe(svc *Service, next http.Handler, w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
var tokenString string
if authHeader != "" {
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, "Invalid authorization header format", http.StatusUnauthorized)
return
}
tokenString = parts[1]
} else {
tokenString = r.URL.Query().Get("token")
if tokenString == "" {
http.Error(w, "Authorization header or token query parameter required", http.StatusUnauthorized)
return
}
}
claims, err := svc.VerifyToken(tokenString)
if err != nil {
if errors.Is(err, ErrTokenExpired) {
http.Error(w, "Token has expired", http.StatusUnauthorized)
return
}
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
user := GetUserFromClaims(claims)
ctx := context.WithValue(r.Context(), UserContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
}

View file

@ -1,6 +1,7 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
@ -8,6 +9,7 @@ import (
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/models"
"golang.org/x/crypto/bcrypt"
)
@ -134,6 +136,39 @@ func GetUserFromClaims(claims *Claims) models.User {
}
}
// NewServiceFromFileConfig creates an auth service from file-based config.
// Returns nil if the config is nil, disabled, or incomplete.
func NewServiceFromFileConfig(cfg *config.FileAuthConfig) *Service {
if cfg == nil || !cfg.Enabled {
return nil
}
if cfg.JWTSecret == "" || cfg.AdminUsername == "" || cfg.AdminPasswordHash == "" {
return nil
}
return &Service{
jwtSecret: []byte(cfg.JWTSecret),
adminUsername: cfg.AdminUsername,
adminPasswordHash: cfg.AdminPasswordHash,
sha256Salt: cfg.AdminPasswordSalt,
tokenExpiration: 7 * 24 * time.Hour,
}
}
// HashPasswordSHA256 computes SHA256(password + salt) and returns the hex-encoded hash.
func HashPasswordSHA256(password, salt string) string {
h := sha256.Sum256([]byte(password + salt))
return hex.EncodeToString(h[:])
}
// GenerateRandomHex generates a cryptographically random hex string of the specified byte length.
func GenerateRandomHex(byteLen int) (string, error) {
b := make([]byte, byteLen)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// HashPassword generates a bcrypt hash from a plain password
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)

View file

@ -9,8 +9,24 @@ import (
)
type DockerHost struct {
Name string
Host string
Name string `json:"name"`
Host string `json:"host"`
}
// CoolifyHostConfig maps a Docker host name to a Coolify API instance.
type CoolifyHostConfig struct {
HostName string `json:"hostName"`
APIURL string `json:"apiURL"`
APIToken string `json:"apiToken"`
}
// FileAuthConfig represents auth settings stored in the config file.
type FileAuthConfig struct {
Enabled bool `json:"enabled"`
JWTSecret string `json:"jwtSecret,omitempty"`
AdminUsername string `json:"adminUsername,omitempty"`
AdminPasswordHash string `json:"adminPasswordHash,omitempty"`
AdminPasswordSalt string `json:"adminPasswordSalt,omitempty"`
}
// AlertConfig holds configuration for the alerting system
@ -23,16 +39,18 @@ type AlertConfig struct {
}
type Config struct {
ReadOnly bool
Hostname string // Optional override for displayed hostname
DockerHosts []DockerHost
Alerts AlertConfig
ReadOnly bool
Hostname string // Optional override for displayed hostname
DockerHosts []DockerHost
CoolifyHosts []CoolifyHostConfig
Alerts AlertConfig
}
func NewConfig() *Config {
isReadOnlyMode := os.Getenv("READONLY_MODE") == "true"
hostname := os.Getenv("HOSTNAME_OVERRIDE") // Custom display hostname
dockerHosts := parseDockerHosts()
coolifyHosts := parseCoolifyHostConfigs()
alertConfig := parseAlertConfig()
// if we don't have any docker hosts, we should default back to
@ -41,11 +59,23 @@ func NewConfig() *Config {
dockerHosts = []DockerHost{{Name: "local", Host: "unix:///var/run/docker.sock"}}
}
// Warn if Coolify hosts reference unknown Docker hosts
dockerHostNames := make(map[string]bool, len(dockerHosts))
for _, dh := range dockerHosts {
dockerHostNames[dh.Name] = true
}
for _, ch := range coolifyHosts {
if !dockerHostNames[ch.HostName] {
log.Printf("Warning: COOLIFY_CONFIGS references unknown Docker host %q", ch.HostName)
}
}
return &Config{
ReadOnly: isReadOnlyMode,
Hostname: hostname,
DockerHosts: dockerHosts,
Alerts: alertConfig,
ReadOnly: isReadOnlyMode,
Hostname: hostname,
DockerHosts: dockerHosts,
CoolifyHosts: coolifyHosts,
Alerts: alertConfig,
}
}
@ -79,6 +109,47 @@ func parseAlertConfig() AlertConfig {
return config
}
func parseCoolifyHostConfigs() []CoolifyHostConfig {
// Format: COOLIFY_CONFIGS=hostA|https://coolify-a.com|tokenA,hostB|https://coolify-b.com|tokenB
raw := os.Getenv("COOLIFY_CONFIGS")
if raw == "" {
return nil
}
var configs []CoolifyHostConfig
seen := make(map[string]bool)
for entry := range strings.SplitSeq(raw, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
parts := strings.SplitN(entry, "|", 3)
if len(parts) != 3 {
log.Fatalf("Invalid COOLIFY_CONFIGS format: %s (expected: hostName|apiURL|apiToken)", entry)
}
hostName := strings.TrimSpace(parts[0])
apiURL := strings.TrimRight(strings.TrimSpace(parts[1]), "/")
apiToken := strings.TrimSpace(parts[2])
if hostName == "" || apiURL == "" || apiToken == "" {
log.Fatalf("Invalid COOLIFY_CONFIGS format: %s (all fields must be non-empty)", entry)
}
if seen[hostName] {
log.Fatalf("Duplicate Coolify host name in COOLIFY_CONFIGS: %s", hostName)
}
seen[hostName] = true
configs = append(configs, CoolifyHostConfig{
HostName: hostName,
APIURL: apiURL,
APIToken: apiToken,
})
}
return configs
}
func parseDockerHosts() []DockerHost {
// Format: DOCKER_HOSTS=local=unix:///var/run/docker.sock,remote=ssh://root@X.X.X.X
dockerHosts := os.Getenv("DOCKER_HOSTS")

View file

@ -0,0 +1,394 @@
package config
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sync"
)
// 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"`
}
// Source indicates where a config value came from.
type Source string
const (
SourceEnv Source = "env"
SourceFile Source = "file"
SourceDefault Source = "default"
SourceMixed Source = "mixed"
)
// EnvSnapshot captures which env vars are set at startup.
type EnvSnapshot struct {
DockerHostsSet bool
CoolifySet bool
ReadOnlySet bool
AuthSet bool
}
// Manager handles loading, merging, and persisting configuration.
type Manager struct {
mu sync.RWMutex
filePath string
envSnapshot EnvSnapshot
envConfig *Config // config derived purely from env vars
fileConfig FileConfig // config from file
merged *Config // final merged config
sources ConfigSources // per-category source tracking
onChange []func(*Config) // callbacks when config changes
generation uint64 // incremented on each merge to detect stale callbacks
}
// ConfigSources tracks the source of each config category.
type ConfigSources struct {
DockerHosts Source `json:"dockerHosts"`
CoolifyHosts Source `json:"coolifyHosts"`
ReadOnly Source `json:"readOnly"`
Auth Source `json:"auth"`
}
// NewManager creates a config manager that loads from env vars and an optional file.
func NewManager() *Manager {
filePath := os.Getenv("CONFIG_PATH")
if filePath == "" {
filePath = "/data/config.json"
}
envSnapshot := EnvSnapshot{
DockerHostsSet: os.Getenv("DOCKER_HOSTS") != "",
CoolifySet: os.Getenv("COOLIFY_CONFIGS") != "",
ReadOnlySet: os.Getenv("READONLY_MODE") != "",
AuthSet: os.Getenv("JWT_SECRET") != "" ||
os.Getenv("ADMIN_USERNAME") != "" ||
os.Getenv("ADMIN_PASSWORD") != "",
}
// Load env-based config using existing parsers.
envConfig := NewConfig()
m := &Manager{
filePath: filePath,
envSnapshot: envSnapshot,
envConfig: envConfig,
}
// Load file config (if it exists).
m.fileConfig = m.loadFile()
// Merge and compute sources.
m.merged, m.sources = m.merge()
return m
}
// Config returns the current merged config (thread-safe).
func (m *Manager) Config() *Config {
m.mu.RLock()
defer m.mu.RUnlock()
return m.merged
}
// Sources returns the source tracking for each category.
func (m *Manager) Sources() ConfigSources {
m.mu.RLock()
defer m.mu.RUnlock()
return m.sources
}
// FileConfigSnapshot returns the current file config (for reading stored secrets).
func (m *Manager) FileConfigSnapshot() FileConfig {
m.mu.RLock()
defer m.mu.RUnlock()
return m.fileConfig
}
// OnChange registers a callback invoked after any config update.
func (m *Manager) OnChange(fn func(*Config)) {
m.mu.Lock()
defer m.mu.Unlock()
m.onChange = append(m.onChange, fn)
}
// EnvDockerHostNames returns the set of Docker host names defined via env vars.
func (m *Manager) EnvDockerHostNames() map[string]bool {
m.mu.RLock()
defer m.mu.RUnlock()
names := make(map[string]bool)
if m.envSnapshot.DockerHostsSet {
for _, h := range m.envConfig.DockerHosts {
names[h.Name] = true
}
}
return names
}
// EnvCoolifyHostNames returns the set of Coolify host names defined via env vars.
func (m *Manager) EnvCoolifyHostNames() map[string]bool {
m.mu.RLock()
defer m.mu.RUnlock()
names := make(map[string]bool)
if m.envSnapshot.CoolifySet {
for _, h := range m.envConfig.CoolifyHosts {
names[h.HostName] = true
}
}
return names
}
// UpdateDockerHosts updates the file-defined Docker hosts.
func (m *Manager) UpdateDockerHosts(hosts []DockerHost) error {
m.mu.Lock()
if m.envSnapshot.DockerHostsSet {
envNames := make(map[string]bool)
for _, h := range m.envConfig.DockerHosts {
envNames[h.Name] = true
}
for _, h := range hosts {
if envNames[h.Name] {
m.mu.Unlock()
return fmt.Errorf("host %q is defined via environment variable and cannot be managed from the UI", h.Name)
}
}
}
oldDockerHosts := m.fileConfig.DockerHosts
m.fileConfig.DockerHosts = hosts
if err := m.persist(); err != nil {
m.fileConfig.DockerHosts = oldDockerHosts
m.mu.Unlock()
return err
}
m.remerge()
return nil
}
// UpdateCoolifyHosts updates the file-defined Coolify hosts.
func (m *Manager) UpdateCoolifyHosts(hosts []CoolifyHostConfig) error {
m.mu.Lock()
if m.envSnapshot.CoolifySet {
envNames := make(map[string]bool)
for _, h := range m.envConfig.CoolifyHosts {
envNames[h.HostName] = true
}
for _, h := range hosts {
if envNames[h.HostName] {
m.mu.Unlock()
return fmt.Errorf("coolify host %q is defined via environment variable and cannot be managed from the UI", h.HostName)
}
}
}
oldCoolifyHosts := m.fileConfig.CoolifyHosts
m.fileConfig.CoolifyHosts = hosts
if err := m.persist(); err != nil {
m.fileConfig.CoolifyHosts = oldCoolifyHosts
m.mu.Unlock()
return err
}
m.remerge()
return nil
}
// UpdateReadOnly updates the read-only setting in the file config.
func (m *Manager) UpdateReadOnly(readOnly bool) error {
m.mu.Lock()
if m.envSnapshot.ReadOnlySet {
m.mu.Unlock()
return fmt.Errorf("read-only mode is configured via environment variable and cannot be changed from the UI")
}
oldReadOnly := m.fileConfig.ReadOnly
m.fileConfig.ReadOnly = &readOnly
if err := m.persist(); err != nil {
m.fileConfig.ReadOnly = oldReadOnly
m.mu.Unlock()
return err
}
m.remerge()
return nil
}
// UpdateAuth applies a mutation function to the current file auth config atomically.
func (m *Manager) UpdateAuth(mutate func(current *FileAuthConfig) (*FileAuthConfig, error)) error {
m.mu.Lock()
if m.envSnapshot.AuthSet {
m.mu.Unlock()
return fmt.Errorf("auth is configured via environment variables and cannot be changed from the UI")
}
oldAuth := m.fileConfig.Auth
current := &FileAuthConfig{}
if oldAuth != nil {
*current = *oldAuth
}
updated, err := mutate(current)
if err != nil {
m.mu.Unlock()
return err
}
m.fileConfig.Auth = updated
if err := m.persist(); err != nil {
m.fileConfig.Auth = oldAuth
m.mu.Unlock()
return err
}
m.remerge()
return nil
}
// merge produces the merged config and source tracking. Must be called with lock held.
func (m *Manager) merge() (*Config, ConfigSources) {
cfg := &Config{}
sources := ConfigSources{}
// Preserve vps-monitor specific fields from env config
cfg.Hostname = m.envConfig.Hostname
cfg.Alerts = m.envConfig.Alerts
// Docker hosts: env hosts + file hosts combined. Env hosts win on name collision.
envDockerNames := make(map[string]bool)
if m.envSnapshot.DockerHostsSet {
for _, h := range m.envConfig.DockerHosts {
cfg.DockerHosts = append(cfg.DockerHosts, h)
envDockerNames[h.Name] = true
}
}
for _, h := range m.fileConfig.DockerHosts {
if !envDockerNames[h.Name] {
cfg.DockerHosts = append(cfg.DockerHosts, h)
}
}
if len(cfg.DockerHosts) == 0 {
cfg.DockerHosts = []DockerHost{{Name: "local", Host: "unix:///var/run/docker.sock"}}
sources.DockerHosts = SourceDefault
} else if m.envSnapshot.DockerHostsSet && len(m.fileConfig.DockerHosts) > 0 {
sources.DockerHosts = SourceMixed
} else if m.envSnapshot.DockerHostsSet {
sources.DockerHosts = SourceEnv
} else {
sources.DockerHosts = SourceFile
}
// Coolify hosts: same merge strategy.
envCoolifyNames := make(map[string]bool)
if m.envSnapshot.CoolifySet {
for _, h := range m.envConfig.CoolifyHosts {
cfg.CoolifyHosts = append(cfg.CoolifyHosts, h)
envCoolifyNames[h.HostName] = true
}
}
for _, h := range m.fileConfig.CoolifyHosts {
if !envCoolifyNames[h.HostName] {
cfg.CoolifyHosts = append(cfg.CoolifyHosts, h)
}
}
if len(cfg.CoolifyHosts) == 0 {
sources.CoolifyHosts = SourceDefault
} else if m.envSnapshot.CoolifySet && len(m.fileConfig.CoolifyHosts) > 0 {
sources.CoolifyHosts = SourceMixed
} else if m.envSnapshot.CoolifySet {
sources.CoolifyHosts = SourceEnv
} else {
sources.CoolifyHosts = SourceFile
}
// Read-only
if m.envSnapshot.ReadOnlySet {
cfg.ReadOnly = m.envConfig.ReadOnly
sources.ReadOnly = SourceEnv
} else if m.fileConfig.ReadOnly != nil {
cfg.ReadOnly = *m.fileConfig.ReadOnly
sources.ReadOnly = SourceFile
} else {
cfg.ReadOnly = false
sources.ReadOnly = SourceDefault
}
// Auth
if m.envSnapshot.AuthSet {
sources.Auth = SourceEnv
} else if m.fileConfig.Auth != nil {
sources.Auth = SourceFile
} else {
sources.Auth = SourceDefault
}
return cfg, sources
}
// remerge re-merges config then unlocks the mutex before firing callbacks.
// Callers must hold the write lock. The lock is released by this function.
func (m *Manager) remerge() {
m.generation++
gen := m.generation
m.merged, m.sources = m.merge()
cfg := m.merged
cbs := make([]func(*Config), len(m.onChange))
copy(cbs, m.onChange)
m.mu.Unlock()
for _, fn := range cbs {
m.mu.RLock()
stale := m.generation != gen
m.mu.RUnlock()
if stale {
return
}
fn(cfg)
}
}
// loadFile reads the config file from disk.
func (m *Manager) loadFile() FileConfig {
data, err := os.ReadFile(m.filePath)
if err != nil {
if os.IsNotExist(err) {
return FileConfig{}
}
log.Fatalf("Failed to read config file %s: %v", m.filePath, err)
}
var fc FileConfig
if err := json.Unmarshal(data, &fc); err != nil {
log.Fatalf("Failed to parse config file %s: %v\nIf the file is corrupted, delete it and restart.", m.filePath, err)
}
return fc
}
// persist writes the file config to disk atomically. Must be called with lock held.
func (m *Manager) persist() error {
data, err := json.MarshalIndent(m.fileConfig, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
dir := filepath.Dir(m.filePath)
if err := os.MkdirAll(dir, 0750); err != nil {
return fmt.Errorf("failed to create config directory %s: %w", dir, err)
}
tmp := m.filePath + ".tmp"
if err := os.WriteFile(tmp, data, 0600); err != nil {
os.Remove(tmp)
return fmt.Errorf("failed to write temp config file: %w", err)
}
if err := os.Rename(tmp, m.filePath); err != nil {
os.Remove(tmp)
return fmt.Errorf("failed to rename config file: %w", err)
}
return nil
}

View file

@ -0,0 +1,225 @@
package coolify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
"github.com/hhftechnology/vps-monitor/internal/config"
)
type ResourceType string
const (
ResourceTypeApplication ResourceType = "application"
ResourceTypeService ResourceType = "service"
ResourceTypeDatabase ResourceType = "database"
)
type ResourceInfo struct {
Type ResourceType
UUID string
}
type Client struct {
apiURL string
apiToken string
httpClient *http.Client
}
func newClient(apiURL, apiToken string) *Client {
return &Client{
apiURL: apiURL,
apiToken: apiToken,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// MultiClient routes Coolify API calls to the correct per-host client.
type MultiClient struct {
clients map[string]*Client
}
// NewMultiClient creates a MultiClient from per-host configs.
// Returns nil if no configs are provided.
func NewMultiClient(hostConfigs []config.CoolifyHostConfig) *MultiClient {
if len(hostConfigs) == 0 {
return nil
}
clients := make(map[string]*Client, len(hostConfigs))
for _, hc := range hostConfigs {
clients[hc.HostName] = newClient(hc.APIURL, hc.APIToken)
}
return &MultiClient{clients: clients}
}
// GetClient returns the Coolify client for the given Docker host name.
// Returns nil if no config exists for that host.
func (mc *MultiClient) GetClient(hostName string) *Client {
if mc == nil {
return nil
}
return mc.clients[hostName]
}
// TestConnection checks if the Coolify API is reachable by calling /api/v1/version.
func (c *Client) TestConnection(ctx context.Context) error {
if c == nil {
return fmt.Errorf("coolify client is nil")
}
url := fmt.Sprintf("%s/api/v1/version", c.apiURL)
_, err := c.doRequest(ctx, "GET", url, nil)
if err != nil {
return fmt.Errorf("coolify API unreachable: %w", err)
}
return nil
}
// NewSingleClient creates a single Coolify client for testing connections.
func NewSingleClient(apiURL, apiToken string) *Client {
return newClient(apiURL, apiToken)
}
// ExtractResourceInfo checks container labels for Coolify management info.
// Returns nil if the container is not managed by Coolify.
func ExtractResourceInfo(labels map[string]string) *ResourceInfo {
if labels["coolify.managed"] != "true" {
return nil
}
uuid := labels["com.docker.compose.project"]
if uuid == "" {
return nil
}
switch labels["coolify.type"] {
case "application":
return &ResourceInfo{Type: ResourceTypeApplication, UUID: uuid}
case "service":
return &ResourceInfo{Type: ResourceTypeService, UUID: uuid}
case "database":
return &ResourceInfo{Type: ResourceTypeDatabase, UUID: uuid}
default:
return &ResourceInfo{Type: ResourceTypeApplication, UUID: uuid}
}
}
type envVarEntry struct {
Key string `json:"key"`
Value string `json:"value"`
IsPreview bool `json:"is_preview"`
}
type bulkEnvPayload struct {
Data []envVarEntry `json:"data"`
}
type coolifyEnvVar struct {
UUID string `json:"uuid"`
Key string `json:"key"`
}
// SyncEnvVars syncs environment variables to Coolify's API so they persist
// across redeployments. It upserts new/changed vars and deletes removed ones.
func (c *Client) SyncEnvVars(ctx context.Context, resource *ResourceInfo, envVars map[string]string) error {
if c == nil || resource == nil {
return nil
}
if resource.Type == ResourceTypeDatabase {
return fmt.Errorf("coolify: syncing env vars for database resources is not supported")
}
// 1. Fetch current env vars from Coolify to find deletions
existing, err := c.listEnvVars(ctx, resource)
if err != nil {
return fmt.Errorf("coolify: failed to fetch existing env vars: %w", err)
}
// 2. Delete env vars that no longer exist
for _, ev := range existing {
if _, exists := envVars[ev.Key]; !exists {
if delErr := c.deleteEnvVar(ctx, resource, ev.UUID); delErr != nil {
log.Printf("Warning: failed to delete Coolify env var %s (%s): %v", ev.Key, ev.UUID, delErr)
}
}
}
// 3. Bulk upsert the current env vars
entries := make([]envVarEntry, 0, len(envVars))
for key, value := range envVars {
entries = append(entries, envVarEntry{Key: key, Value: value, IsPreview: false})
}
body, err := json.Marshal(bulkEnvPayload{Data: entries})
if err != nil {
return fmt.Errorf("coolify: failed to marshal env vars: %w", err)
}
url := fmt.Sprintf("%s/api/v1/%ss/%s/envs/bulk", c.apiURL, resource.Type, resource.UUID)
if _, err := c.doRequest(ctx, http.MethodPatch, url, body); err != nil {
return fmt.Errorf("coolify: bulk update failed: %w", err)
}
return nil
}
func (c *Client) listEnvVars(ctx context.Context, resource *ResourceInfo) ([]coolifyEnvVar, error) {
url := fmt.Sprintf("%s/api/v1/%ss/%s/envs", c.apiURL, resource.Type, resource.UUID)
respBody, err := c.doRequest(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
var envVars []coolifyEnvVar
if err := json.Unmarshal(respBody, &envVars); err != nil {
return nil, err
}
return envVars, nil
}
func (c *Client) deleteEnvVar(ctx context.Context, resource *ResourceInfo, envUUID string) error {
url := fmt.Sprintf("%s/api/v1/%ss/%s/envs/%s", c.apiURL, resource.Type, resource.UUID, envUUID)
_, err := c.doRequest(ctx, http.MethodDelete, url, nil)
return err
}
func (c *Client) doRequest(ctx context.Context, method, url string, body []byte) ([]byte, error) {
var bodyReader io.Reader
if body != nil {
bodyReader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
return respBody, nil
}

View file

@ -6,6 +6,7 @@ import (
"net/http"
"strings"
"sync"
"time"
"github.com/docker/cli/cli/connhelper"
"github.com/docker/docker/api/types/container"
@ -38,6 +39,7 @@ func NewMultiHostClient(hosts []config.DockerHost) (*MultiHostClient, error) {
Transport: &http.Transport{
DialContext: helper.Dialer,
},
Timeout: 10 * time.Second,
}
apiClient, err = client.NewClientWithOpts(
@ -156,3 +158,10 @@ func (c *MultiHostClient) GetClient(hostName string) (*client.Client, error) {
func (c *MultiHostClient) GetHosts() []config.DockerHost {
return c.hosts
}
// Close closes all underlying Docker API clients.
func (c *MultiHostClient) Close() {
for _, cl := range c.clients {
cl.Close()
}
}

View file

@ -74,17 +74,20 @@ func (c *MultiHostClient) GetEnvVariables(ctx context.Context, hostName, id stri
return envMap, nil
}
func (c *MultiHostClient) SetEnvVariables(ctx context.Context, hostName, id string, envVariables map[string]string) (string, error) {
func (c *MultiHostClient) SetEnvVariables(ctx context.Context, hostName, id string, envVariables map[string]string) (string, map[string]string, error) {
apiClient, err := c.GetClient(hostName)
if err != nil {
return "", err
return "", nil, err
}
inspect, err := apiClient.ContainerInspect(ctx, id)
if err != nil {
return "", err
return "", nil, err
}
// Store labels before modifying the container (needed for Coolify sync)
labels := inspect.Config.Labels
envMap := make(map[string]string)
// First, load all existing env vars from the container config
for _, env := range inspect.Config.Env {
@ -114,12 +117,12 @@ func (c *MultiHostClient) SetEnvVariables(ctx context.Context, hostName, id stri
err = apiClient.ContainerStop(ctx, id, container.StopOptions{})
if err != nil {
return "", err
return "", nil, err
}
err = apiClient.ContainerRemove(ctx, id, container.RemoveOptions{})
if err != nil {
return "", err
return "", nil, err
}
newConfig := inspect.Config
@ -136,13 +139,13 @@ func (c *MultiHostClient) SetEnvVariables(ctx context.Context, hostName, id stri
containerName,
)
if err != nil {
return "", err
return "", nil, err
}
err = apiClient.ContainerStart(ctx, resp.ID, container.StartOptions{})
if err != nil {
return "", err
return "", nil, err
}
return resp.ID, nil
return resp.ID, labels, nil
}

View file

@ -0,0 +1,94 @@
package services
import (
"sync"
"github.com/hhftechnology/vps-monitor/internal/alerts"
"github.com/hhftechnology/vps-monitor/internal/auth"
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/coolify"
"github.com/hhftechnology/vps-monitor/internal/docker"
)
// Registry holds all runtime services behind a RWMutex, allowing hot-swap.
type Registry struct {
mu sync.RWMutex
docker *docker.MultiHostClient
coolify *coolify.MultiClient
auth *auth.Service
config *config.Config
alerts *alerts.Monitor
}
// NewRegistry creates a registry with the initial set of services.
func NewRegistry(
dockerClient *docker.MultiHostClient,
coolifyClient *coolify.MultiClient,
authService *auth.Service,
cfg *config.Config,
alertMonitor *alerts.Monitor,
) *Registry {
return &Registry{
docker: dockerClient,
coolify: coolifyClient,
auth: authService,
config: cfg,
alerts: alertMonitor,
}
}
func (r *Registry) Docker() *docker.MultiHostClient {
r.mu.RLock()
defer r.mu.RUnlock()
return r.docker
}
func (r *Registry) Coolify() *coolify.MultiClient {
r.mu.RLock()
defer r.mu.RUnlock()
return r.coolify
}
func (r *Registry) Auth() *auth.Service {
r.mu.RLock()
defer r.mu.RUnlock()
return r.auth
}
func (r *Registry) Config() *config.Config {
r.mu.RLock()
defer r.mu.RUnlock()
return r.config
}
func (r *Registry) Alerts() *alerts.Monitor {
r.mu.RLock()
defer r.mu.RUnlock()
return r.alerts
}
func (r *Registry) SwapDocker(newClient *docker.MultiHostClient) *docker.MultiHostClient {
r.mu.Lock()
defer r.mu.Unlock()
old := r.docker
r.docker = newClient
return old
}
func (r *Registry) SwapCoolify(newClient *coolify.MultiClient) {
r.mu.Lock()
defer r.mu.Unlock()
r.coolify = newClient
}
func (r *Registry) SwapAuth(newService *auth.Service) {
r.mu.Lock()
defer r.mu.Unlock()
r.auth = newService
}
func (r *Registry) UpdateConfig(cfg *config.Config) {
r.mu.Lock()
defer r.mu.Unlock()
r.config = cfg
}