diff --git a/src/app/api/containers/route.ts b/src/app/api/containers/route.ts index eed7e0f..51f935e 100644 --- a/src/app/api/containers/route.ts +++ b/src/app/api/containers/route.ts @@ -1,63 +1,122 @@ 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) { + 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() { 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( - containers.map(async (c: any) => { - let statsText = ""; - let cpu = "0%"; - let memory = "0 MB"; + // Enhanced container data with stats + const containers = await Promise.all( + response.data.map(async (container: any) => { + let stats = { cpu: "0%", memory: "0 MB" }; - if (c.State === "running") { + if (container.State === "running") { try { - const container = docker.getContainer(c.Id); - const stats = await container.stats({ stream: false }); + const statsConfig = getAxiosConfig( + `/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 = - stats.cpu_stats?.cpu_usage?.total_usage - - (stats.precpu_stats?.cpu_usage?.total_usage || 0); + data.cpu_stats.cpu_usage.total_usage - + (data.precpu_stats?.cpu_usage?.total_usage || 0); const systemDelta = - stats.cpu_stats?.system_cpu_usage - - (stats.precpu_stats?.system_cpu_usage || 0); - const online = stats.cpu_stats?.online_cpus || 1; - const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * 100 * online : 0; + data.cpu_stats.system_cpu_usage - + (data.precpu_stats?.system_cpu_usage || 0); + const cpuPercent = + systemDelta > 0 + ? (cpuDelta / systemDelta) * + 100 * + (data.cpu_stats.online_cpus || 1) + : 0; - const memUsage = stats.memory_stats?.usage || 0; - const memLimit = stats.memory_stats?.limit || 0; - const memMB = (memUsage / 1024 / 1024).toFixed(1); - const memLimitMB = (memLimit / 1024 / 1024).toFixed(0); + const memoryUsage = data.memory_stats?.usage || 0; + const memoryLimit = data.memory_stats?.limit || 0; + const memoryMB = (memoryUsage / 1024 / 1024).toFixed(1); + const memoryLimitMB = (memoryLimit / 1024 / 1024).toFixed(0); - cpu = `${cpuPercent.toFixed(1)}%`; - memory = `${memMB} MB / ${memLimitMB} MB`; - statsText = `${cpu}, ${memory}`; + stats = { + cpu: cpuPercent.toFixed(1) + "%", + memory: `${memoryMB} MB / ${memoryLimitMB} MB`, + }; } catch (err) { - statsText = "n/a"; + console.warn( + `Failed to fetch stats for ${container.Names[0]}:`, + err instanceof Error ? err.message : err + ); } } return { - id: c.Id.slice(0, 12), - name: c.Names?.[0]?.replace(/^\//, "") || "unknown", - image: c.Image, - state: c.State, - status: c.Status, - ports: c.Ports || [], - labels: c.Labels || {}, - cpu, - memory, - stats: statsText, + id: container.Id.slice(0, 12), + name: container.Names[0]?.replace(/^\//, "") || "unknown", + image: container.Image, + imageId: container.ImageID, + status: container.Status, + state: container.State, + created: container.Created, + ports: (container.Ports || []).map((port: any) => { + if (port.PublicPort) { + 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) { - console.error("Containers API error:", error); - return NextResponse.json({ error: "Failed to fetch containers" }, { status: 500 }); + console.error("Docker API error:", error instanceof Error ? error.message : error); + const errorMessage = + error instanceof Error ? error.message : "Failed to fetch containers"; + + return NextResponse.json( + { error: errorMessage, total: 0, running: 0, containers: [] }, + { status: 500 } + ); } } diff --git a/src/app/api/synology/route.ts b/src/app/api/synology/route.ts index 17cb237..9eb49d2 100644 --- a/src/app/api/synology/route.ts +++ b/src/app/api/synology/route.ts @@ -1,11 +1,14 @@ import { NextResponse } from "next/server"; +import axios from "axios"; +import https from "https"; 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_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() { if (!SYNOLOGY_HOST || !SYNOLOGY_USERNAME || !SYNOLOGY_PASSWORD) { @@ -16,89 +19,54 @@ export async function GET() { } try { - const protocol = SYNOLOGY_PORT === "5001" ? "https" : "http"; - const baseUrl = `${protocol}://${SYNOLOGY_HOST}:${SYNOLOGY_PORT}/webapi`; + const baseUrl = `${protocol}://${SYNOLOGY_HOST}:${SYNOLOGY_PORT}`; + const httpsConfig = + protocol === "https" + ? { + httpsAgent: new https.Agent({ rejectUnauthorized: false }), + } + : {}; - // Login - const loginUrl = `${baseUrl}/auth.cgi?api=SYNO.API.Auth&version=3&method=login&account=${encodeURIComponent( - 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; - - // Get storage info - const storageResp = await fetch( - `${baseUrl}/entry.cgi?api=SYNO.Storage.CGI.Storage&version=1&method=load_info&_sid=${sid}`, - { cache: "no-store" } - ); - const storageData = await storageResp.json(); - - // Get system utilization - let utilization = null; - try { - 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", - }; + // Login to Synology + const loginResponse = await axios.get(`${baseUrl}/webapi/auth.cgi`, { + params: { + api: "SYNO.API.Auth", + version: 3, + method: "login", + account: SYNOLOGY_USERNAME, + passwd: SYNOLOGY_PASSWORD, + session: "FileStation", + format: "sid", + }, + ...httpsConfig, }); - // Parse disks - const disks = (storageData.data?.disks || []).map((disk: any) => ({ - name: disk.longName || disk.name || disk.id || "Unknown", - model: disk.model || "Unknown", - serial: disk.serial || "", - sizeBytes: parseInt(disk.size_total || "0", 10), - status: disk.smart_status || disk.overview_status || "unknown", - isSsd: disk.isSsd || false, - temp: disk.temp || null, + const sid = loginResponse.data.data.sid; + + // Get storage info + const storageResponse = await axios.get(`${baseUrl}/webapi/entry.cgi`, { + params: { + api: "SYNO.Storage.CGI.Storage", + version: 1, + method: "load_info", + _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) - fetch( - `${baseUrl}/auth.cgi?api=SYNO.API.Auth&version=3&method=logout&session=FileStation&_sid=${sid}`, - { cache: "no-store" } - ).catch(() => {}); - - return NextResponse.json({ volumes, disks, utilization }); - } catch (error: any) { - console.error("Synology API error:", error?.message || error); + return NextResponse.json(volumes); + } catch (error) { + console.error("Synology API error:", error instanceof Error ? error.message : error); return NextResponse.json( { error: "Failed to fetch Synology storage" }, { status: 500 } diff --git a/src/app/page.tsx b/src/app/page.tsx index 4a8089d..ac42d77 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,15 +1,13 @@ "use client"; import { useEffect, useState } from "react"; -import { Server, Activity } from "lucide-react"; +import { Search, Server, Activity } from "lucide-react"; import ContainerGroup from "@/components/ContainerGroup"; import SearchBar from "@/components/SearchBar"; import GrafanaWidget from "@/components/GrafanaWidget"; -import NetworkWidget from "@/components/NetworkWidget"; +import UnifiWidget from "@/components/UnifiWidget"; import SynologyWidget from "@/components/SynologyWidget"; -import ServerStatsWidget from "@/components/ServerStatsWidget"; -import GPUStatsWidget from "@/components/GPUStatsWidget"; -import RAGWidget from "@/components/RAGWidget"; +import SemanticSearch from "@/components/SemanticSearch"; import { Container } from "@/types"; export default function Home() { @@ -19,7 +17,7 @@ export default function Home() { useEffect(() => { fetchContainers(); - const interval = setInterval(fetchContainers, 10000); + const interval = setInterval(fetchContainers, 10000); // Refresh every 10s return () => clearInterval(interval); }, []); @@ -27,10 +25,19 @@ export default function Home() { try { const response = await fetch("/api/containers"); const data = await response.json(); - setContainers(data); + // Defensive: ensure we always set an array + if (data && Array.isArray(data.containers)) { + setContainers(data.containers); + } else if (Array.isArray(data)) { + setContainers(data); + } else { + console.warn("Unexpected container API response:", data); + setContainers([]); + } setLoading(false); } catch (error) { console.error("Failed to fetch containers:", error); + setContainers([]); setLoading(false); } }; @@ -39,46 +46,86 @@ export default function Home() { return { media: containers.filter((c) => [ - "sonarr", "radarr", "lidarr", "whisparr", "prowlarr", "bazarr", - "tautulli", "overseerr", "ombi", "jellyfin", "plex", "audiobookshelf", + "sonarr", + "radarr", + "lidarr", + "whisparr", + "prowlarr", + "bazarr", + "tautulli", + "overseerr", + "ombi", + "jellyfin", + "plex", + "audiobookshelf", "lazylibrarian", ].some((app) => c.name.toLowerCase().includes(app)) ), download: containers.filter((c) => [ - "qbittorrent", "transmission", "sabnzbd", "nzbget", "deluge", - "gluetun", "flaresolverr", + "qbittorrent", + "transmission", + "sabnzbd", + "nzbget", + "deluge", + "gluetun", + "flaresolverr", ].some((app) => c.name.toLowerCase().includes(app)) ), infrastructure: containers.filter((c) => [ - "traefik", "portainer", "heimdall", "homepage", "nginx", "caddy", - "pihole", "adguard", "unbound", "mosquitto", + "traefik", + "portainer", + "heimdall", + "homepage", + "nginx", + "caddy", + "pihole", + "adguard", + "unbound", + "mosquitto", ].some((app) => c.name.toLowerCase().includes(app)) ), monitoring: containers.filter((c) => [ - "grafana", "prometheus", "cadvisor", "node-exporter", "dozzle", - "uptime-kuma", "beszel", "dockmon", "docker-stats-exporter", "diun", + "grafana", + "prometheus", + "cadvisor", + "node-exporter", + "dozzle", + "uptime-kuma", + "beszel", + "dockmon", + "docker-stats-exporter", + "diun", "container-census", ].some((app) => c.name.toLowerCase().includes(app)) ), automation: containers.filter((c) => [ - "homeassistant", "home-assistant", "n8n", "nodered", "node-red", + "homeassistant", + "home-assistant", + "n8n", + "nodered", + "node-red", "duplicati", ].some((app) => c.name.toLowerCase().includes(app)) ), 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)) ), media_processing: containers.filter((c) => ["tdarr"].some((app) => c.name.toLowerCase().includes(app)) ), 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) ) ), @@ -105,6 +152,7 @@ export default function Home() { return (
+ {/* Header */}
@@ -126,65 +174,117 @@ export default function Home() {
- {/* Server & GPU Stats */} -
- - -
- - {/* Network, NAS & Server Overview */} -
- + {/* Widgets Section */} +
+ - +
{/* Grafana Dashboards */} -
+
+ + - -
+ + {/* Semantic Search */} +
+ +
+ {/* Container Groups */} {loading ? (
) : (
- {grouped.ai.length > 0 && ( - - )} {grouped.media.length > 0 && ( - + )} {grouped.download.length > 0 && ( - + + )} + {grouped.ai.length > 0 && ( + )} {grouped.photos.length > 0 && ( - + )} {grouped.media_processing.length > 0 && ( - + )} {grouped.automation.length > 0 && ( - + )} {grouped.productivity.length > 0 && ( - + )} {grouped.infrastructure.length > 0 && ( - + )} {grouped.monitoring.length > 0 && ( - + )} {grouped.databases.length > 0 && ( - + )}
)}