mirror of
https://github.com/mblanke/Dashboard.git
synced 2026-03-01 04:00:22 -05:00
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
This commit is contained in:
@@ -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}
|
||||
|
||||
65
src/app/api/network/route.ts
Normal file
65
src/app/api/network/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const PROM = "http://prometheus:9090";
|
||||
|
||||
const INSTANCE_MAP: Record<string, string> = {
|
||||
"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<string, any> = {};
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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,53 +16,89 @@ 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 }
|
||||
|
||||
@@ -1,13 +1,52 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import axios from "axios";
|
||||
import https from "https";
|
||||
|
||||
const UNIFI_HOST = process.env.UNIFI_HOST;
|
||||
const UNIFI_PORT = process.env.UNIFI_PORT || "8443";
|
||||
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<string, string>;
|
||||
body?: string;
|
||||
} = {}
|
||||
): Promise<{ status: number; headers: Record<string, string[]>; 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<string, string[]> = {};
|
||||
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_HOST || !UNIFI_USERNAME || !UNIFI_PASSWORD) {
|
||||
if (!UNIFI_USERNAME || !UNIFI_PASSWORD) {
|
||||
return NextResponse.json(
|
||||
{ error: "UniFi credentials not configured" },
|
||||
{ status: 500 }
|
||||
@@ -15,42 +54,71 @@ export async function GET() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Login to UniFi Controller
|
||||
const loginUrl = `https://${UNIFI_HOST}:${UNIFI_PORT}/api/login`;
|
||||
await axios.post(
|
||||
loginUrl,
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
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<string, string> = { 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) {
|
||||
console.error("UniFi API error:", error);
|
||||
} catch (error: any) {
|
||||
console.error("UniFi API error:", error?.message || error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch UniFi devices" },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -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() {
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Server & GPU Stats */}
|
||||
<div className="space-y-6 mb-8">
|
||||
<ServerStatsWidget />
|
||||
<GPUStatsWidget />
|
||||
</div>
|
||||
|
||||
{/* Network, NAS & Server Overview */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
<UnifiWidget />
|
||||
<NetworkWidget />
|
||||
<SynologyWidget />
|
||||
<GrafanaWidget
|
||||
title="Server Stats"
|
||||
dashboardId="server-overview"
|
||||
panelId={1}
|
||||
title="Server Health"
|
||||
dashboardUid="server-health"
|
||||
panelId={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grafana Dashboards */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
<GrafanaWidget
|
||||
title="Docker Stats"
|
||||
dashboardId="docker-monitoring"
|
||||
panelId={2}
|
||||
title="Docker Containers"
|
||||
dashboardUid="docker-containers"
|
||||
panelId={8}
|
||||
/>
|
||||
<GrafanaWidget
|
||||
title="LLM Metrics"
|
||||
dashboardId="llm-monitoring"
|
||||
panelId={3}
|
||||
title="RAG Pipeline"
|
||||
dashboardUid="rag-pipeline"
|
||||
panelId={1}
|
||||
/>
|
||||
<GrafanaWidget
|
||||
title="System Load"
|
||||
dashboardId="system-metrics"
|
||||
panelId={4}
|
||||
title="Web Traffic"
|
||||
dashboardUid="traefik-traffic"
|
||||
panelId={7}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,47 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { BarChart3, ExternalLink, AlertCircle } from "lucide-react";
|
||||
|
||||
interface GrafanaWidgetProps {
|
||||
title: string;
|
||||
dashboardId: string;
|
||||
dashboardUid: string;
|
||||
panelId: number;
|
||||
}
|
||||
|
||||
export default function GrafanaWidget({
|
||||
title,
|
||||
dashboardId,
|
||||
dashboardUid,
|
||||
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`;
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const grafanaHost = process.env.NEXT_PUBLIC_GRAFANA_HOST || "https://grafana.guapo613.beer";
|
||||
const iframeUrl = `${grafanaHost}/d-solo/${dashboardUid}?orgId=1&panelId=${panelId}&theme=dark&refresh=30s`;
|
||||
const dashUrl = `${grafanaHost}/d/${dashboardUid}`;
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-700 bg-gray-800/60">
|
||||
<div className="px-4 py-3 border-b border-gray-700 bg-gray-800/60 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-orange-500" />
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="relative h-48">
|
||||
<iframe
|
||||
src={iframeUrl}
|
||||
className="w-full h-full"
|
||||
frameBorder="0"
|
||||
title={title}
|
||||
/>
|
||||
<div className="absolute top-2 right-2">
|
||||
<a
|
||||
href={`${grafanaHost}/d/${dashboardId}`}
|
||||
href={dashUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-gray-400 hover:text-white transition-colors"
|
||||
className="text-xs text-gray-400 hover:text-white transition-colors flex items-center gap-1"
|
||||
>
|
||||
Open in Grafana →
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Open
|
||||
</a>
|
||||
</div>
|
||||
<div className="relative h-48">
|
||||
{loadError ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-500">
|
||||
<AlertCircle className="w-8 h-8 mb-2" />
|
||||
<p className="text-xs">Panel unavailable</p>
|
||||
<a href={dashUrl} target="_blank" rel="noopener noreferrer"
|
||||
className="text-xs text-blue-400 hover:text-blue-300 mt-1">
|
||||
View in Grafana
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
src={iframeUrl}
|
||||
className="w-full h-full border-0"
|
||||
title={title}
|
||||
loading="lazy"
|
||||
onError={() => setLoadError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
132
src/components/NetworkWidget.tsx
Normal file
132
src/components/NetworkWidget.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Wifi, Router, Monitor, Signal, WifiOff } from "lucide-react";
|
||||
|
||||
interface UnifiDevice {
|
||||
name: string;
|
||||
mac: string;
|
||||
ip: string;
|
||||
model: string;
|
||||
type: string;
|
||||
state: number;
|
||||
uptime: number;
|
||||
clients: number;
|
||||
satisfaction: number | null;
|
||||
version: string;
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (!seconds) return "—";
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
function deviceIcon(type: string) {
|
||||
switch (type) {
|
||||
case "uap": return <Wifi className="w-3.5 h-3.5" />;
|
||||
case "usw": return <Monitor className="w-3.5 h-3.5" />;
|
||||
case "ugw":
|
||||
case "udm": return <Router className="w-3.5 h-3.5" />;
|
||||
default: return <Signal className="w-3.5 h-3.5" />;
|
||||
}
|
||||
}
|
||||
|
||||
export default function NetworkWidget() {
|
||||
const [devices, setDevices] = useState<UnifiDevice[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDevices();
|
||||
const interval = setInterval(fetchDevices, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchDevices = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/unifi");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (Array.isArray(data)) {
|
||||
setDevices(data);
|
||||
setError(false);
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
setLoading(false);
|
||||
} catch {
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalClients = devices.reduce((sum, d) => sum + d.clients, 0);
|
||||
const onlineDevices = devices.filter((d) => d.state === 1).length;
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Wifi className="w-5 h-5 text-blue-500" />
|
||||
UniFi Network
|
||||
</h3>
|
||||
{!loading && !error && devices.length > 0 && (
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Signal className="w-3 h-3 text-green-400" />
|
||||
{totalClients} clients
|
||||
</span>
|
||||
<span>{onlineDevices}/{devices.length} online</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
) : error || devices.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<WifiOff className="w-12 h-12 text-gray-600 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-400">No network data available</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{devices
|
||||
.sort((a, b) => b.clients - a.clients)
|
||||
.map((dev) => (
|
||||
<div key={dev.mac} className="bg-gray-900/50 rounded-lg p-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={dev.state === 1 ? "text-green-400" : "text-red-400"}>
|
||||
{deviceIcon(dev.type)}
|
||||
</span>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-200">{dev.name}</span>
|
||||
<span className="text-xs text-gray-500 ml-2">{dev.model}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||
{dev.clients > 0 && (
|
||||
<span className="text-blue-400">{dev.clients} clients</span>
|
||||
)}
|
||||
<span>{formatUptime(dev.uptime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{dev.ip && (
|
||||
<div className="text-xs text-gray-500 mt-1 ml-6">{dev.ip}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { HardDrive, Disc, Cpu, MemoryStick } from "lucide-react";
|
||||
|
||||
interface Volume {
|
||||
volume: string;
|
||||
id: string;
|
||||
size: number;
|
||||
used: number;
|
||||
available: number;
|
||||
percentUsed: string;
|
||||
status: string;
|
||||
fsType: string;
|
||||
}
|
||||
|
||||
interface Disk {
|
||||
name: string;
|
||||
model: string;
|
||||
status: string;
|
||||
isSsd: boolean;
|
||||
}
|
||||
|
||||
interface SynologyData {
|
||||
volumes: Volume[];
|
||||
disks: Disk[];
|
||||
utilization: { cpu: number | null; memory: number | null } | null;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 ** 4) return (bytes / (1024 ** 4)).toFixed(1) + " TB";
|
||||
if (bytes >= 1024 ** 3) return (bytes / (1024 ** 3)).toFixed(1) + " GB";
|
||||
if (bytes >= 1024 ** 2) return (bytes / (1024 ** 2)).toFixed(0) + " MB";
|
||||
return (bytes / 1024).toFixed(0) + " KB";
|
||||
}
|
||||
|
||||
export default function SynologyWidget() {
|
||||
const [volumes, setVolumes] = useState<any[]>([]);
|
||||
const [data, setData] = useState<SynologyData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
@@ -18,73 +49,103 @@ export default function SynologyWidget() {
|
||||
try {
|
||||
const response = await fetch("/api/synology");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setVolumes(data);
|
||||
const json = await response.json();
|
||||
if (json.volumes) {
|
||||
setData(json);
|
||||
setError(false);
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
const tb = bytes / 1024 ** 4;
|
||||
return tb.toFixed(2) + " TB";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<HardDrive className="w-5 h-5 text-purple-500" />
|
||||
Synology Storage
|
||||
Synology NAS
|
||||
</h3>
|
||||
{data?.utilization && (
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||
{data.utilization.cpu !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Cpu className="w-3 h-3" /> {data.utilization.cpu}%
|
||||
</span>
|
||||
)}
|
||||
{data.utilization.memory !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MemoryStick className="w-3 h-3" /> {data.utilization.memory}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500"></div>
|
||||
</div>
|
||||
) : error ? (
|
||||
) : error || !data ? (
|
||||
<div className="text-center py-8">
|
||||
<HardDrive className="w-12 h-12 text-gray-600 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-400">
|
||||
Configure Synology credentials in .env
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">Configure Synology credentials in .env</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{volumes.map((vol, idx) => (
|
||||
<div key={idx} className="bg-gray-900/50 rounded-lg p-3">
|
||||
{data.volumes.map((vol) => {
|
||||
const pct = parseFloat(vol.percentUsed);
|
||||
return (
|
||||
<div key={vol.id} className="bg-gray-900/50 rounded-lg p-3">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm text-gray-300">{vol.volume}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{vol.percentUsed}%
|
||||
</span>
|
||||
<span className="text-sm text-gray-300">{vol.id}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
vol.status === "normal" ? "bg-green-900/50 text-green-400" :
|
||||
vol.status === "attention" ? "bg-yellow-900/50 text-yellow-400" :
|
||||
"bg-red-900/50 text-red-400"
|
||||
}`}>{vol.status}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-700 rounded-full h-2 overflow-hidden">
|
||||
<div className="w-full bg-gray-700 rounded-full h-2 overflow-hidden mb-2">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
parseFloat(vol.percentUsed) > 90
|
||||
? "bg-red-500"
|
||||
: parseFloat(vol.percentUsed) > 75
|
||||
? "bg-yellow-500"
|
||||
: "bg-green-500"
|
||||
pct > 90 ? "bg-red-500" : pct > 75 ? "bg-yellow-500" : "bg-purple-500"
|
||||
}`}
|
||||
style={{ width: `${vol.percentUsed}%` }}
|
||||
></div>
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-xs text-gray-400">
|
||||
<span>{formatBytes(vol.used)} used</span>
|
||||
<span>{formatBytes(vol.available)} free</span>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>{formatBytes(vol.used)} / {formatBytes(vol.size)}</span>
|
||||
<span>{vol.percentUsed}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{data.disks.length > 0 && (
|
||||
<div className="pt-2 border-t border-gray-700/50">
|
||||
<p className="text-xs text-gray-500 mb-2">{data.disks.length} drives</p>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{data.disks.slice(0, 8).map((disk, i) => (
|
||||
<div key={i} className="flex items-center gap-1 text-xs">
|
||||
<Disc className={`w-3 h-3 ${
|
||||
disk.status === "normal" ? "text-green-400" : "text-yellow-400"
|
||||
}`} />
|
||||
<span className="text-gray-400 truncate" title={disk.model}>
|
||||
{disk.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user