mirror of
https://github.com/hhftechnology/vps-monitor.git
synced 2026-04-26 10:41:00 +00:00
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:
parent
064b76effa
commit
8e02d66da3
42 changed files with 2968 additions and 244 deletions
21
.env.example
21
.env.example
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
31
frontend/src/components/ui/switch.tsx
Normal file
31
frontend/src/components/ui/switch.tsx
Normal 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 }
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
17
frontend/src/features/settings/api/get-settings.ts
Normal file
17
frontend/src/features/settings/api/get-settings.ts
Normal 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;
|
||||
}
|
||||
25
frontend/src/features/settings/api/test-coolify-host.ts
Normal file
25
frontend/src/features/settings/api/test-coolify-host.ts
Normal 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;
|
||||
}
|
||||
24
frontend/src/features/settings/api/test-docker-host.ts
Normal file
24
frontend/src/features/settings/api/test-docker-host.ts
Normal 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;
|
||||
}
|
||||
26
frontend/src/features/settings/api/update-auth.ts
Normal file
26
frontend/src/features/settings/api/update-auth.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { authenticatedFetch } from "@/lib/api-client";
|
||||
import { API_BASE_URL } from "@/types/api";
|
||||
|
||||
const ENDPOINT = `${API_BASE_URL}/api/v1/settings/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";
|
||||
}
|
||||
22
frontend/src/features/settings/api/update-coolify-hosts.ts
Normal file
22
frontend/src/features/settings/api/update-coolify-hosts.ts
Normal 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";
|
||||
}
|
||||
20
frontend/src/features/settings/api/update-docker-hosts.ts
Normal file
20
frontend/src/features/settings/api/update-docker-hosts.ts
Normal 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";
|
||||
}
|
||||
20
frontend/src/features/settings/api/update-read-only.ts
Normal file
20
frontend/src/features/settings/api/update-read-only.ts
Normal 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";
|
||||
}
|
||||
138
frontend/src/features/settings/components/auth-section.tsx
Normal file
138
frontend/src/features/settings/components/auth-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
9
frontend/src/features/settings/components/env-badge.tsx
Normal file
9
frontend/src/features/settings/components/env-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
48
frontend/src/features/settings/components/settings-page.tsx
Normal file
48
frontend/src/features/settings/components/settings-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
frontend/src/features/settings/hooks/use-settings.ts
Normal file
80
frontend/src/features/settings/hooks/use-settings.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
48
frontend/src/features/settings/types.ts
Normal file
48
frontend/src/features/settings/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
7
frontend/src/routes/settings.tsx
Normal file
7
frontend/src/routes/settings.tsx
Normal 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,
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
401
home/internal/api/settings_handlers.go
Normal file
401
home/internal/api/settings_handlers.go
Normal 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://")
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
394
home/internal/config/manager.go
Normal file
394
home/internal/config/manager.go
Normal 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
|
||||
}
|
||||
225
home/internal/coolify/client.go
Normal file
225
home/internal/coolify/client.go
Normal 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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
94
home/internal/services/registry.go
Normal file
94
home/internal/services/registry.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue