From 1102f27f45c5dee4550d877a3ddc45d080ef3c71 Mon Sep 17 00:00:00 2001 From: mblanke Date: Fri, 13 Feb 2026 13:54:48 -0500 Subject: [PATCH] fix: UniFi, Synology, Network widgets + Grafana HTTPS - Rewrite UniFi API: port 443, /api/auth/login, /proxy/network/ prefix, native https module for self-signed cert, cookie-based session - Update credentials to Vault-Admin (view-only local account) - Rewrite NetworkWidget to show UniFi devices with clients/uptime - Fix Synology API: correct field mappings (size.total/size.used), add CPU/memory utilization endpoint - Fix network API: use prometheus:9090 container DNS - Add NODE_TLS_REJECT_UNAUTHORIZED=0 for UniFi self-signed cert - Expand AI container group (rag, litellm, qdrant) - Add force-dynamic to API routes --- docker-compose.yml | 1 + src/app/api/network/route.ts | 65 ++++++++ src/app/api/synology/route.ts | 121 +++++++++------ src/app/api/unifi/route.ts | 186 +++++++++++++++-------- src/app/page.tsx | 33 ++-- src/components/GrafanaWidget.tsx | 109 ++++++++------ src/components/NetworkWidget.tsx | 132 ++++++++++++++++ src/components/SynologyWidget.tsx | 243 +++++++++++++++++++----------- 8 files changed, 635 insertions(+), 255 deletions(-) create mode 100644 src/app/api/network/route.ts create mode 100644 src/components/NetworkWidget.tsx diff --git a/docker-compose.yml b/docker-compose.yml index 51e4aa0..458e504 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: - "3000:3000" environment: - NODE_ENV=production + - NODE_TLS_REJECT_UNAUTHORIZED=0 - UNIFI_HOST=${UNIFI_HOST} - UNIFI_PORT=${UNIFI_PORT} - UNIFI_USERNAME=${UNIFI_USERNAME} diff --git a/src/app/api/network/route.ts b/src/app/api/network/route.ts new file mode 100644 index 0000000..8fdacf1 --- /dev/null +++ b/src/app/api/network/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; + +const PROM = "http://prometheus:9090"; + +const INSTANCE_MAP: Record = { + "192.168.1.21:9100": "Atlas", + "192.168.1.50:9100": "Wile", + "192.168.1.51:9100": "RoadRunner", +}; + +async function promQuery(query: string) { + const resp = await fetch( + `${PROM}/api/v1/query?query=${encodeURIComponent(query)}`, + { cache: "no-store" } + ); + const data = await resp.json(); + return data.data?.result || []; +} + +function mapInstance(instance: string): string { + return INSTANCE_MAP[instance] || instance; +} + +export async function GET() { + try { + const deviceFilter = 'device=~"eth0|eno1|enp.*|end0|bond0"'; + + const [rxRate, txRate, rxTotal, txTotal] = await Promise.all([ + promQuery(`sum by (instance) (rate(node_network_receive_bytes_total{${deviceFilter}}[5m]))`), + promQuery(`sum by (instance) (rate(node_network_transmit_bytes_total{${deviceFilter}}[5m]))`), + promQuery(`sum by (instance) (node_network_receive_bytes_total{${deviceFilter}})`), + promQuery(`sum by (instance) (node_network_transmit_bytes_total{${deviceFilter}})`), + ]); + + const servers: Record = {}; + + for (const r of rxRate) { + const name = mapInstance(r.metric.instance); + if (!servers[name]) servers[name] = { name }; + servers[name].rxBytesPerSec = parseFloat(r.value[1]); + } + for (const r of txRate) { + const name = mapInstance(r.metric.instance); + if (!servers[name]) servers[name] = { name }; + servers[name].txBytesPerSec = parseFloat(r.value[1]); + } + for (const r of rxTotal) { + const name = mapInstance(r.metric.instance); + if (!servers[name]) servers[name] = { name }; + servers[name].rxBytesTotal = parseFloat(r.value[1]); + } + for (const r of txTotal) { + const name = mapInstance(r.metric.instance); + if (!servers[name]) servers[name] = { name }; + servers[name].txBytesTotal = parseFloat(r.value[1]); + } + + const result = Object.values(servers).sort((a: any, b: any) => a.name.localeCompare(b.name)); + + return NextResponse.json(result); + } catch (error: any) { + console.error("Network API error:", error?.message || error); + return NextResponse.json({ error: "Failed to fetch network stats" }, { status: 500 }); + } +} diff --git a/src/app/api/synology/route.ts b/src/app/api/synology/route.ts index 0531039..17cb237 100644 --- a/src/app/api/synology/route.ts +++ b/src/app/api/synology/route.ts @@ -1,11 +1,12 @@ import { NextResponse } from "next/server"; -import axios from "axios"; const SYNOLOGY_HOST = process.env.SYNOLOGY_HOST; -const SYNOLOGY_PORT = process.env.SYNOLOGY_PORT || "5001"; +const SYNOLOGY_PORT = process.env.SYNOLOGY_PORT || "5000"; const SYNOLOGY_USERNAME = process.env.SYNOLOGY_USERNAME; const SYNOLOGY_PASSWORD = process.env.SYNOLOGY_PASSWORD; +export const dynamic = "force-dynamic"; + export async function GET() { if (!SYNOLOGY_HOST || !SYNOLOGY_USERNAME || !SYNOLOGY_PASSWORD) { return NextResponse.json( @@ -15,56 +16,92 @@ export async function GET() { } try { - const protocol = SYNOLOGY_PORT === "5000" ? "http" : "https"; - const baseUrl = `${protocol}://${SYNOLOGY_HOST}:${SYNOLOGY_PORT}`; + const protocol = SYNOLOGY_PORT === "5001" ? "https" : "http"; + const baseUrl = `${protocol}://${SYNOLOGY_HOST}:${SYNOLOGY_PORT}/webapi`; - // 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", - }, - httpsAgent: new (require("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 sid = loginResponse.data.data.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 storageResponse = await axios.get(`${baseUrl}/webapi/entry.cgi`, { - params: { - api: "SYNO.Storage.CGI.Storage", - version: 1, - method: "load_info", - _sid: sid, - }, - httpsAgent: new (require("https").Agent)({ - rejectUnauthorized: false, - }), + 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", + }; }); - 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 - ), + // 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, })); - return NextResponse.json(volumes); - } catch (error) { - console.error("Synology API error:", error); + // 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( { error: "Failed to fetch Synology storage" }, { status: 500 } ); } -} \ No newline at end of file +} diff --git a/src/app/api/unifi/route.ts b/src/app/api/unifi/route.ts index f539d99..a929134 100644 --- a/src/app/api/unifi/route.ts +++ b/src/app/api/unifi/route.ts @@ -1,59 +1,127 @@ -import { NextResponse } from "next/server"; -import axios from "axios"; - -const UNIFI_HOST = process.env.UNIFI_HOST; -const UNIFI_PORT = process.env.UNIFI_PORT || "8443"; -const UNIFI_USERNAME = process.env.UNIFI_USERNAME; -const UNIFI_PASSWORD = process.env.UNIFI_PASSWORD; - -export async function GET() { - if (!UNIFI_HOST || !UNIFI_USERNAME || !UNIFI_PASSWORD) { - return NextResponse.json( - { error: "UniFi credentials not configured" }, - { status: 500 } - ); - } - - try { - // Login to UniFi Controller - const loginUrl = `https://${UNIFI_HOST}:${UNIFI_PORT}/api/login`; - await axios.post( - loginUrl, - { - username: UNIFI_USERNAME, - password: UNIFI_PASSWORD, - }, - { - httpsAgent: new (require("https").Agent)({ - rejectUnauthorized: false, - }), - } - ); - - // Get device list - const devicesUrl = `https://${UNIFI_HOST}:${UNIFI_PORT}/api/s/default/stat/device`; - const response = await axios.get(devicesUrl, { - httpsAgent: new (require("https").Agent)({ - rejectUnauthorized: false, - }), - }); - - const devices = response.data.data.map((device: any) => ({ - name: device.name || device.model, - mac: device.mac, - ip: device.ip, - model: device.model, - state: device.state, - uptime: device.uptime, - clients: device.num_sta || 0, - })); - - return NextResponse.json(devices); - } catch (error) { - console.error("UniFi API error:", error); - return NextResponse.json( - { error: "Failed to fetch UniFi devices" }, - { status: 500 } - ); - } -} +import { NextResponse } from "next/server"; +import https from "https"; + +const UNIFI_HOST = process.env.UNIFI_HOST || "192.168.1.1"; +const UNIFI_PORT = process.env.UNIFI_PORT || "443"; +const UNIFI_USERNAME = process.env.UNIFI_USERNAME; +const UNIFI_PASSWORD = process.env.UNIFI_PASSWORD; + +export const dynamic = "force-dynamic"; + +/** Low-level HTTPS request that ignores self-signed certs and returns cookies */ +function httpsRequest( + url: string, + opts: { + method?: string; + headers?: Record; + body?: string; + } = {} +): Promise<{ status: number; headers: Record; body: string }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const agent = new https.Agent({ rejectUnauthorized: false }); + const reqOpts: https.RequestOptions = { + hostname: parsed.hostname, + port: parseInt(parsed.port || "443", 10), + path: parsed.pathname + parsed.search, + method: opts.method || "GET", + headers: opts.headers || {}, + agent, + }; + const req = https.request(reqOpts, (res) => { + let data = ""; + res.on("data", (chunk: Buffer) => (data += chunk.toString())); + res.on("end", () => { + const hdrs: Record = {}; + for (const [k, v] of Object.entries(res.headers)) { + hdrs[k.toLowerCase()] = Array.isArray(v) ? v : v ? [v] : []; + } + resolve({ status: res.statusCode || 0, headers: hdrs, body: data }); + }); + }); + req.on("error", reject); + if (opts.body) req.write(opts.body); + req.end(); + }); +} + +export async function GET() { + if (!UNIFI_USERNAME || !UNIFI_PASSWORD) { + return NextResponse.json( + { error: "UniFi credentials not configured" }, + { status: 500 } + ); + } + + try { + const baseUrl = `https://${UNIFI_HOST}:${UNIFI_PORT}`; + + // Step 1: Login to UniFi OS + const loginResp = await httpsRequest(`${baseUrl}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: UNIFI_USERNAME, + password: UNIFI_PASSWORD, + }), + }); + + if (loginResp.status !== 200) { + console.error("UniFi login failed:", loginResp.status, loginResp.body.slice(0, 200)); + return NextResponse.json( + { error: `UniFi login failed: ${loginResp.status}` }, + { status: 401 } + ); + } + + // Extract session cookies + const setCookies = loginResp.headers["set-cookie"] || []; + const cookieStr = setCookies + .map((c) => c.split(";")[0]) + .join("; "); + + // Extract CSRF token if present + const csrfToken = (loginResp.headers["x-csrf-token"] || [])[0] || ""; + + // Step 2: Fetch devices + const reqHeaders: Record = { Cookie: cookieStr }; + if (csrfToken) reqHeaders["x-csrf-token"] = csrfToken; + + const devicesResp = await httpsRequest( + `${baseUrl}/proxy/network/api/s/default/stat/device`, + { headers: reqHeaders } + ); + + if (devicesResp.status !== 200) { + console.error("UniFi device fetch failed:", devicesResp.status); + return NextResponse.json( + { error: `UniFi device fetch failed: ${devicesResp.status}` }, + { status: 502 } + ); + } + + const devicesData = JSON.parse(devicesResp.body); + const rawDevices = devicesData?.data || []; + + const devices = rawDevices.map((device: any) => ({ + name: device.name || device.model || "Unknown", + mac: device.mac || "", + ip: device.ip || "", + model: device.model || "", + type: device.type || "", + state: device.state ?? 0, + uptime: device.uptime || 0, + clients: device.num_sta || 0, + satisfaction: device.satisfaction ?? null, + version: device.version || "", + })); + + return NextResponse.json(devices); + } catch (error: any) { + console.error("UniFi API error:", error?.message || error); + return NextResponse.json( + { error: "Failed to fetch UniFi devices" }, + { status: 500 } + ); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 9127232..38c7953 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,7 +5,7 @@ import { Server, Activity } from "lucide-react"; import ContainerGroup from "@/components/ContainerGroup"; import SearchBar from "@/components/SearchBar"; import GrafanaWidget from "@/components/GrafanaWidget"; -import UnifiWidget from "@/components/UnifiWidget"; +import NetworkWidget from "@/components/NetworkWidget"; import SynologyWidget from "@/components/SynologyWidget"; import ServerStatsWidget from "@/components/ServerStatsWidget"; import GPUStatsWidget from "@/components/GPUStatsWidget"; @@ -77,7 +77,7 @@ export default function Home() { ["tdarr"].some((app) => c.name.toLowerCase().includes(app)) ), ai: containers.filter((c) => - ["openwebui", "open-webui", "ollama", "stable-diffusion", "mcp"].some( + ["openwebui", "open-webui", "ollama", "stable-diffusion", "mcp", "rag", "litellm", "llm-router", "qdrant", "chromadb"].some( (app) => c.name.toLowerCase().includes(app) ) ), @@ -125,36 +125,39 @@ export default function Home() {
+ {/* Server & GPU Stats */}
+ {/* Network, NAS & Server Overview */}
- +
+ {/* Grafana Dashboards */}
diff --git a/src/components/GrafanaWidget.tsx b/src/components/GrafanaWidget.tsx index 22cab69..71fa9d2 100644 --- a/src/components/GrafanaWidget.tsx +++ b/src/components/GrafanaWidget.tsx @@ -1,48 +1,61 @@ -"use client"; - -import { BarChart3 } from "lucide-react"; - -interface GrafanaWidgetProps { - title: string; - dashboardId: string; - panelId: number; -} - -export default function GrafanaWidget({ - title, - dashboardId, - panelId, -}: GrafanaWidgetProps) { - const grafanaHost = - process.env.NEXT_PUBLIC_GRAFANA_HOST || "http://100.104.196.38:3000"; - const iframeUrl = `${grafanaHost}/d-solo/${dashboardId}?orgId=1&panelId=${panelId}&theme=dark`; - - return ( -
-
-

- - {title} -

-
-
-