fix: Docker socket support, Synology port default, defensive container fetch

- containers/route.ts: Use Unix socket (/var/run/docker.sock) via axios socketPath
  instead of HTTP to localhost:2375 (TCP API not exposed)
- synology/route.ts: Default port to 5001 (HTTPS) and auto-detect protocol
  based on port (5000=HTTP, 5001+=HTTPS)
- page.tsx: Defensive check on container API response shape before setting state
This commit is contained in:
2026-02-17 08:22:34 -05:00
parent dc01ddc09f
commit ed9c0f0fd0
3 changed files with 291 additions and 164 deletions

View File

@@ -1,63 +1,122 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import Docker from "dockerode"; import axios from "axios";
import http from "http";
const docker = new Docker({ socketPath: "/var/run/docker.sock" }); // Support both Unix socket and TCP
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || "/var/run/docker.sock";
const DOCKER_HOST = process.env.DOCKER_HOST;
function getAxiosConfig(path: string, params?: Record<string, any>) {
if (DOCKER_HOST) {
// TCP mode: DOCKER_HOST is set (e.g., http://localhost:2375)
return { url: `${DOCKER_HOST}${path}`, params };
}
// Unix socket mode (default)
return {
url: `http://localhost${path}`,
params,
socketPath: DOCKER_SOCKET,
httpAgent: new http.Agent({ socketPath: DOCKER_SOCKET } as any),
};
}
export async function GET() { export async function GET() {
try { try {
const containers = await docker.listContainers({ all: true }); const config = getAxiosConfig("/containers/json", { all: true });
const response = await axios.get(config.url, {
params: config.params,
socketPath: (config as any).socketPath,
httpAgent: (config as any).httpAgent,
});
const enriched = await Promise.all( // Enhanced container data with stats
containers.map(async (c: any) => { const containers = await Promise.all(
let statsText = ""; response.data.map(async (container: any) => {
let cpu = "0%"; let stats = { cpu: "0%", memory: "0 MB" };
let memory = "0 MB";
if (c.State === "running") { if (container.State === "running") {
try { try {
const container = docker.getContainer(c.Id); const statsConfig = getAxiosConfig(
const stats = await container.stats({ stream: false }); `/containers/${container.Id}/stats`,
{ stream: false }
);
const statsResponse = await axios.get(statsConfig.url, {
params: statsConfig.params,
socketPath: (statsConfig as any).socketPath,
httpAgent: (statsConfig as any).httpAgent,
});
const data = statsResponse.data;
const cpuDelta = const cpuDelta =
stats.cpu_stats?.cpu_usage?.total_usage - data.cpu_stats.cpu_usage.total_usage -
(stats.precpu_stats?.cpu_usage?.total_usage || 0); (data.precpu_stats?.cpu_usage?.total_usage || 0);
const systemDelta = const systemDelta =
stats.cpu_stats?.system_cpu_usage - data.cpu_stats.system_cpu_usage -
(stats.precpu_stats?.system_cpu_usage || 0); (data.precpu_stats?.system_cpu_usage || 0);
const online = stats.cpu_stats?.online_cpus || 1; const cpuPercent =
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * 100 * online : 0; systemDelta > 0
? (cpuDelta / systemDelta) *
100 *
(data.cpu_stats.online_cpus || 1)
: 0;
const memUsage = stats.memory_stats?.usage || 0; const memoryUsage = data.memory_stats?.usage || 0;
const memLimit = stats.memory_stats?.limit || 0; const memoryLimit = data.memory_stats?.limit || 0;
const memMB = (memUsage / 1024 / 1024).toFixed(1); const memoryMB = (memoryUsage / 1024 / 1024).toFixed(1);
const memLimitMB = (memLimit / 1024 / 1024).toFixed(0); const memoryLimitMB = (memoryLimit / 1024 / 1024).toFixed(0);
cpu = `${cpuPercent.toFixed(1)}%`; stats = {
memory = `${memMB} MB / ${memLimitMB} MB`; cpu: cpuPercent.toFixed(1) + "%",
statsText = `${cpu}, ${memory}`; memory: `${memoryMB} MB / ${memoryLimitMB} MB`,
};
} catch (err) { } catch (err) {
statsText = "n/a"; console.warn(
`Failed to fetch stats for ${container.Names[0]}:`,
err instanceof Error ? err.message : err
);
} }
} }
return { return {
id: c.Id.slice(0, 12), id: container.Id.slice(0, 12),
name: c.Names?.[0]?.replace(/^\//, "") || "unknown", name: container.Names[0]?.replace(/^\//, "") || "unknown",
image: c.Image, image: container.Image,
state: c.State, imageId: container.ImageID,
status: c.Status, status: container.Status,
ports: c.Ports || [], state: container.State,
labels: c.Labels || {}, created: container.Created,
cpu, ports: (container.Ports || []).map((port: any) => {
memory, if (port.PublicPort) {
stats: statsText, return `${port.PublicPort}:${port.PrivatePort}/${port.Type}`;
}
return `${port.PrivatePort}/${port.Type}`;
}),
labels: container.Labels || {},
mounts: (container.Mounts || []).map((mount: any) => ({
source: mount.Source,
destination: mount.Destination,
mode: mount.Mode,
})),
stats,
}; };
}) })
); );
return NextResponse.json(enriched); return NextResponse.json({
total: containers.length,
running: containers.filter((c) => c.state === "running").length,
containers: containers.sort((a: any, b: any) =>
a.name.localeCompare(b.name)
),
});
} catch (error) { } catch (error) {
console.error("Containers API error:", error); console.error("Docker API error:", error instanceof Error ? error.message : error);
return NextResponse.json({ error: "Failed to fetch containers" }, { status: 500 }); const errorMessage =
error instanceof Error ? error.message : "Failed to fetch containers";
return NextResponse.json(
{ error: errorMessage, total: 0, running: 0, containers: [] },
{ status: 500 }
);
} }
} }

View File

@@ -1,11 +1,14 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import axios from "axios";
import https from "https";
const SYNOLOGY_HOST = process.env.SYNOLOGY_HOST; const SYNOLOGY_HOST = process.env.SYNOLOGY_HOST;
const SYNOLOGY_PORT = process.env.SYNOLOGY_PORT || "5000"; const SYNOLOGY_PORT = process.env.SYNOLOGY_PORT || "5001";
const SYNOLOGY_USERNAME = process.env.SYNOLOGY_USERNAME; const SYNOLOGY_USERNAME = process.env.SYNOLOGY_USERNAME;
const SYNOLOGY_PASSWORD = process.env.SYNOLOGY_PASSWORD; const SYNOLOGY_PASSWORD = process.env.SYNOLOGY_PASSWORD;
export const dynamic = "force-dynamic"; // Port 5000 = HTTP, 5001+ = HTTPS (Synology convention)
const protocol = SYNOLOGY_PORT === "5000" ? "http" : "https";
export async function GET() { export async function GET() {
if (!SYNOLOGY_HOST || !SYNOLOGY_USERNAME || !SYNOLOGY_PASSWORD) { if (!SYNOLOGY_HOST || !SYNOLOGY_USERNAME || !SYNOLOGY_PASSWORD) {
@@ -16,89 +19,54 @@ export async function GET() {
} }
try { try {
const protocol = SYNOLOGY_PORT === "5001" ? "https" : "http"; const baseUrl = `${protocol}://${SYNOLOGY_HOST}:${SYNOLOGY_PORT}`;
const baseUrl = `${protocol}://${SYNOLOGY_HOST}:${SYNOLOGY_PORT}/webapi`; const httpsConfig =
protocol === "https"
// Login ? {
const loginUrl = `${baseUrl}/auth.cgi?api=SYNO.API.Auth&version=3&method=login&account=${encodeURIComponent( httpsAgent: new https.Agent({ rejectUnauthorized: false }),
SYNOLOGY_USERNAME
)}&passwd=${encodeURIComponent(SYNOLOGY_PASSWORD)}&session=FileStation&format=sid`;
const loginResp = await fetch(loginUrl, { cache: "no-store" });
const loginData = await loginResp.json();
if (!loginData.success) {
console.error("Synology login failed:", JSON.stringify(loginData));
return NextResponse.json(
{ error: "Synology login failed" },
{ status: 401 }
);
} }
: {};
const sid = loginData.data.sid; // Login to Synology
const loginResponse = await axios.get(`${baseUrl}/webapi/auth.cgi`, {
// Get storage info params: {
const storageResp = await fetch( api: "SYNO.API.Auth",
`${baseUrl}/entry.cgi?api=SYNO.Storage.CGI.Storage&version=1&method=load_info&_sid=${sid}`, version: 3,
{ cache: "no-store" } method: "login",
); account: SYNOLOGY_USERNAME,
const storageData = await storageResp.json(); passwd: SYNOLOGY_PASSWORD,
session: "FileStation",
// Get system utilization format: "sid",
let utilization = null; },
try { ...httpsConfig,
const utilResp = await fetch(
`${baseUrl}/entry.cgi?api=SYNO.Core.System.Utilization&version=1&method=get&_sid=${sid}`,
{ cache: "no-store" }
);
const utilData = await utilResp.json();
if (utilData.success) {
utilization = {
cpu: utilData.data?.cpu?.user_load ?? null,
memory: utilData.data?.memory?.real_usage ?? null,
};
}
} catch (e) {
console.error("Synology utilization error:", e);
}
// Parse volumes - Synology returns size.total / size.used as string byte counts
const volumes = (storageData.data?.volumes || []).map((vol: any) => {
const total = parseInt(vol.size?.total || "0", 10);
const used = parseInt(vol.size?.used || "0", 10);
const free = total - used;
return {
volume: vol.vol_path || vol.id || "unknown",
id: vol.id || vol.vol_path || "unknown",
size: total,
used: used,
available: free,
percentUsed: total > 0 ? ((used / total) * 100).toFixed(1) : "0",
status: vol.status || "unknown",
fsType: vol.fs_type || "unknown",
};
}); });
// Parse disks const sid = loginResponse.data.data.sid;
const disks = (storageData.data?.disks || []).map((disk: any) => ({
name: disk.longName || disk.name || disk.id || "Unknown", // Get storage info
model: disk.model || "Unknown", const storageResponse = await axios.get(`${baseUrl}/webapi/entry.cgi`, {
serial: disk.serial || "", params: {
sizeBytes: parseInt(disk.size_total || "0", 10), api: "SYNO.Storage.CGI.Storage",
status: disk.smart_status || disk.overview_status || "unknown", version: 1,
isSsd: disk.isSsd || false, method: "load_info",
temp: disk.temp || null, _sid: sid,
},
...httpsConfig,
});
const volumes = storageResponse.data.data.volumes.map((vol: any) => ({
volume: vol.volume_path,
size: vol.size_total_byte,
used: vol.size_used_byte,
available: vol.size_free_byte,
percentUsed: ((vol.size_used_byte / vol.size_total_byte) * 100).toFixed(
2
),
})); }));
// Logout (non-blocking) return NextResponse.json(volumes);
fetch( } catch (error) {
`${baseUrl}/auth.cgi?api=SYNO.API.Auth&version=3&method=logout&session=FileStation&_sid=${sid}`, console.error("Synology API error:", error instanceof Error ? error.message : error);
{ cache: "no-store" }
).catch(() => {});
return NextResponse.json({ volumes, disks, utilization });
} catch (error: any) {
console.error("Synology API error:", error?.message || error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to fetch Synology storage" }, { error: "Failed to fetch Synology storage" },
{ status: 500 } { status: 500 }

View File

@@ -1,15 +1,13 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Server, Activity } from "lucide-react"; import { Search, Server, Activity } from "lucide-react";
import ContainerGroup from "@/components/ContainerGroup"; import ContainerGroup from "@/components/ContainerGroup";
import SearchBar from "@/components/SearchBar"; import SearchBar from "@/components/SearchBar";
import GrafanaWidget from "@/components/GrafanaWidget"; import GrafanaWidget from "@/components/GrafanaWidget";
import NetworkWidget from "@/components/NetworkWidget"; import UnifiWidget from "@/components/UnifiWidget";
import SynologyWidget from "@/components/SynologyWidget"; import SynologyWidget from "@/components/SynologyWidget";
import ServerStatsWidget from "@/components/ServerStatsWidget"; import SemanticSearch from "@/components/SemanticSearch";
import GPUStatsWidget from "@/components/GPUStatsWidget";
import RAGWidget from "@/components/RAGWidget";
import { Container } from "@/types"; import { Container } from "@/types";
export default function Home() { export default function Home() {
@@ -19,7 +17,7 @@ export default function Home() {
useEffect(() => { useEffect(() => {
fetchContainers(); fetchContainers();
const interval = setInterval(fetchContainers, 10000); const interval = setInterval(fetchContainers, 10000); // Refresh every 10s
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
@@ -27,10 +25,19 @@ export default function Home() {
try { try {
const response = await fetch("/api/containers"); const response = await fetch("/api/containers");
const data = await response.json(); const data = await response.json();
// Defensive: ensure we always set an array
if (data && Array.isArray(data.containers)) {
setContainers(data.containers);
} else if (Array.isArray(data)) {
setContainers(data); setContainers(data);
} else {
console.warn("Unexpected container API response:", data);
setContainers([]);
}
setLoading(false); setLoading(false);
} catch (error) { } catch (error) {
console.error("Failed to fetch containers:", error); console.error("Failed to fetch containers:", error);
setContainers([]);
setLoading(false); setLoading(false);
} }
}; };
@@ -39,46 +46,86 @@ export default function Home() {
return { return {
media: containers.filter((c) => media: containers.filter((c) =>
[ [
"sonarr", "radarr", "lidarr", "whisparr", "prowlarr", "bazarr", "sonarr",
"tautulli", "overseerr", "ombi", "jellyfin", "plex", "audiobookshelf", "radarr",
"lidarr",
"whisparr",
"prowlarr",
"bazarr",
"tautulli",
"overseerr",
"ombi",
"jellyfin",
"plex",
"audiobookshelf",
"lazylibrarian", "lazylibrarian",
].some((app) => c.name.toLowerCase().includes(app)) ].some((app) => c.name.toLowerCase().includes(app))
), ),
download: containers.filter((c) => download: containers.filter((c) =>
[ [
"qbittorrent", "transmission", "sabnzbd", "nzbget", "deluge", "qbittorrent",
"gluetun", "flaresolverr", "transmission",
"sabnzbd",
"nzbget",
"deluge",
"gluetun",
"flaresolverr",
].some((app) => c.name.toLowerCase().includes(app)) ].some((app) => c.name.toLowerCase().includes(app))
), ),
infrastructure: containers.filter((c) => infrastructure: containers.filter((c) =>
[ [
"traefik", "portainer", "heimdall", "homepage", "nginx", "caddy", "traefik",
"pihole", "adguard", "unbound", "mosquitto", "portainer",
"heimdall",
"homepage",
"nginx",
"caddy",
"pihole",
"adguard",
"unbound",
"mosquitto",
].some((app) => c.name.toLowerCase().includes(app)) ].some((app) => c.name.toLowerCase().includes(app))
), ),
monitoring: containers.filter((c) => monitoring: containers.filter((c) =>
[ [
"grafana", "prometheus", "cadvisor", "node-exporter", "dozzle", "grafana",
"uptime-kuma", "beszel", "dockmon", "docker-stats-exporter", "diun", "prometheus",
"cadvisor",
"node-exporter",
"dozzle",
"uptime-kuma",
"beszel",
"dockmon",
"docker-stats-exporter",
"diun",
"container-census", "container-census",
].some((app) => c.name.toLowerCase().includes(app)) ].some((app) => c.name.toLowerCase().includes(app))
), ),
automation: containers.filter((c) => automation: containers.filter((c) =>
[ [
"homeassistant", "home-assistant", "n8n", "nodered", "node-red", "homeassistant",
"home-assistant",
"n8n",
"nodered",
"node-red",
"duplicati", "duplicati",
].some((app) => c.name.toLowerCase().includes(app)) ].some((app) => c.name.toLowerCase().includes(app))
), ),
productivity: containers.filter((c) => productivity: containers.filter((c) =>
[ [
"nextcloud", "openproject", "gitea", "gitlab", "code-server", "vscode", "nextcloud",
"openproject",
"gitea",
"gitlab",
"code-server",
"vscode",
].some((app) => c.name.toLowerCase().includes(app)) ].some((app) => c.name.toLowerCase().includes(app))
), ),
media_processing: containers.filter((c) => media_processing: containers.filter((c) =>
["tdarr"].some((app) => c.name.toLowerCase().includes(app)) ["tdarr"].some((app) => c.name.toLowerCase().includes(app))
), ),
ai: containers.filter((c) => ai: containers.filter((c) =>
["openwebui", "open-webui", "ollama", "stable-diffusion", "mcp", "rag", "litellm", "llm-router", "qdrant", "chromadb"].some( ["openwebui", "open-webui", "ollama", "stable-diffusion", "mcp"].some(
(app) => c.name.toLowerCase().includes(app) (app) => c.name.toLowerCase().includes(app)
) )
), ),
@@ -105,6 +152,7 @@ export default function Home() {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900"> <div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
{/* Header */}
<header className="border-b border-gray-700 bg-gray-900/50 backdrop-blur-lg sticky top-0 z-50"> <header className="border-b border-gray-700 bg-gray-900/50 backdrop-blur-lg sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -126,65 +174,117 @@ export default function Home() {
</header> </header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Server & GPU Stats */} {/* Widgets Section */}
<div className="space-y-6 mb-8"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<ServerStatsWidget /> <UnifiWidget />
<GPUStatsWidget />
</div>
{/* Network, NAS & Server Overview */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<NetworkWidget />
<SynologyWidget /> <SynologyWidget />
<GrafanaWidget
title="Server Stats"
dashboardId="server-overview"
panelId={1}
/>
</div> </div>
{/* Grafana Dashboards */} {/* Grafana Dashboards */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<GrafanaWidget <GrafanaWidget
title="Docker Containers" title="Docker Stats"
dashboardUid="docker-containers" dashboardId="docker-monitoring"
panelId={8} panelId={2}
/>
<GrafanaWidget
title="LLM Metrics"
dashboardId="llm-monitoring"
panelId={3}
/>
<GrafanaWidget
title="System Load"
dashboardId="system-metrics"
panelId={4}
/> />
<RAGWidget />
</div> </div>
{/* Semantic Search */}
<div className="mb-8">
<SemanticSearch />
</div>
{/* Container Groups */}
{loading ? ( {loading ? (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div> </div>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
{grouped.ai.length > 0 && (
<ContainerGroup title="AI Services" containers={grouped.ai} icon="🤖" />
)}
{grouped.media.length > 0 && ( {grouped.media.length > 0 && (
<ContainerGroup title="Media Management" containers={grouped.media} icon="📺" /> <ContainerGroup
title="Media Management"
containers={grouped.media}
icon="📺"
/>
)} )}
{grouped.download.length > 0 && ( {grouped.download.length > 0 && (
<ContainerGroup title="Download Clients" containers={grouped.download} icon="⬇️" /> <ContainerGroup
title="Download Clients"
containers={grouped.download}
icon="⬇️"
/>
)}
{grouped.ai.length > 0 && (
<ContainerGroup
title="AI Services"
containers={grouped.ai}
icon="🤖"
/>
)} )}
{grouped.photos.length > 0 && ( {grouped.photos.length > 0 && (
<ContainerGroup title="Photo Management" containers={grouped.photos} icon="📷" /> <ContainerGroup
title="Photo Management"
containers={grouped.photos}
icon="📷"
/>
)} )}
{grouped.media_processing.length > 0 && ( {grouped.media_processing.length > 0 && (
<ContainerGroup title="Media Processing" containers={grouped.media_processing} icon="🎬" /> <ContainerGroup
title="Media Processing"
containers={grouped.media_processing}
icon="🎬"
/>
)} )}
{grouped.automation.length > 0 && ( {grouped.automation.length > 0 && (
<ContainerGroup title="Automation" containers={grouped.automation} icon="⚡" /> <ContainerGroup
title="Automation"
containers={grouped.automation}
icon="⚡"
/>
)} )}
{grouped.productivity.length > 0 && ( {grouped.productivity.length > 0 && (
<ContainerGroup title="Productivity" containers={grouped.productivity} icon="💼" /> <ContainerGroup
title="Productivity"
containers={grouped.productivity}
icon="💼"
/>
)} )}
{grouped.infrastructure.length > 0 && ( {grouped.infrastructure.length > 0 && (
<ContainerGroup title="Infrastructure" containers={grouped.infrastructure} icon="🔧" /> <ContainerGroup
title="Infrastructure"
containers={grouped.infrastructure}
icon="🔧"
/>
)} )}
{grouped.monitoring.length > 0 && ( {grouped.monitoring.length > 0 && (
<ContainerGroup title="Monitoring" containers={grouped.monitoring} icon="📊" /> <ContainerGroup
title="Monitoring"
containers={grouped.monitoring}
icon="📊"
/>
)} )}
{grouped.databases.length > 0 && ( {grouped.databases.length > 0 && (
<ContainerGroup title="Databases" containers={grouped.databases} icon="🗄️" /> <ContainerGroup
title="Databases"
containers={grouped.databases}
icon="🗄️"
/>
)} )}
</div> </div>
)} )}