mirror of
https://github.com/mblanke/Dashboard.git
synced 2026-03-01 12:10:20 -05:00
Initial commit: ATLAS Dashboard (Next.js)
This commit is contained in:
62
src/app/api/containers/route.ts
Normal file
62
src/app/api/containers/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import Docker from "dockerode";
|
||||
|
||||
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const containers = await docker.listContainers({ all: true });
|
||||
|
||||
const enriched = await Promise.all(
|
||||
containers.map(async (c: any) => {
|
||||
let statsText = "";
|
||||
let cpu = "0%";
|
||||
let memory = "0 MB";
|
||||
|
||||
if (c.State === "running") {
|
||||
try {
|
||||
const container = docker.getContainer(c.Id);
|
||||
const stats = await container.stats({ stream: false });
|
||||
|
||||
const cpuDelta =
|
||||
stats.cpu_stats?.cpu_usage?.total_usage -
|
||||
(stats.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;
|
||||
|
||||
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);
|
||||
|
||||
cpu = `${cpuPercent.toFixed(1)}%`;
|
||||
memory = `${memMB} MB / ${memLimitMB} MB`;
|
||||
statsText = `${cpu}, ${memory}`;
|
||||
} catch (err) {
|
||||
statsText = "n/a";
|
||||
}
|
||||
}
|
||||
|
||||
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 || [],
|
||||
cpu,
|
||||
memory,
|
||||
stats: statsText,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json(enriched);
|
||||
} catch (error) {
|
||||
console.error("Containers API error:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch containers" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
70
src/app/api/synology/route.ts
Normal file
70
src/app/api/synology/route.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
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_USERNAME = process.env.SYNOLOGY_USERNAME;
|
||||
const SYNOLOGY_PASSWORD = process.env.SYNOLOGY_PASSWORD;
|
||||
|
||||
export async function GET() {
|
||||
if (!SYNOLOGY_HOST || !SYNOLOGY_USERNAME || !SYNOLOGY_PASSWORD) {
|
||||
return NextResponse.json(
|
||||
{ error: "Synology credentials not configured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const protocol = SYNOLOGY_PORT === "5000" ? "http" : "https";
|
||||
const baseUrl = `${protocol}://${SYNOLOGY_HOST}:${SYNOLOGY_PORT}`;
|
||||
|
||||
// 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,
|
||||
}),
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
httpsAgent: new (require("https").Agent)({
|
||||
rejectUnauthorized: false,
|
||||
}),
|
||||
});
|
||||
|
||||
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
|
||||
),
|
||||
}));
|
||||
|
||||
return NextResponse.json(volumes);
|
||||
} catch (error) {
|
||||
console.error("Synology API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch Synology storage" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
59
src/app/api/unifi/route.ts
Normal file
59
src/app/api/unifi/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
src/app/globals.css
Normal file
41
src/app/globals.css
Normal file
@@ -0,0 +1,41 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #333;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #444;
|
||||
}
|
||||
19
src/app/layout.tsx
Normal file
19
src/app/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dashboard",
|
||||
description: "Home Server Dashboard",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
279
src/app/page.tsx
Normal file
279
src/app/page.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Search, 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 SynologyWidget from "@/components/SynologyWidget";
|
||||
import { Container } from "@/types";
|
||||
|
||||
export default function Home() {
|
||||
const [containers, setContainers] = useState<Container[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchContainers();
|
||||
const interval = setInterval(fetchContainers, 10000); // Refresh every 10s
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchContainers = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/containers");
|
||||
const data = await response.json();
|
||||
setContainers(data);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch containers:", error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const groupContainers = (containers: Container[]) => {
|
||||
return {
|
||||
media: containers.filter((c) =>
|
||||
[
|
||||
"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",
|
||||
].some((app) => c.name.toLowerCase().includes(app))
|
||||
),
|
||||
infrastructure: containers.filter((c) =>
|
||||
[
|
||||
"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",
|
||||
"container-census",
|
||||
].some((app) => c.name.toLowerCase().includes(app))
|
||||
),
|
||||
automation: containers.filter((c) =>
|
||||
[
|
||||
"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",
|
||||
].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"].some(
|
||||
(app) => c.name.toLowerCase().includes(app)
|
||||
)
|
||||
),
|
||||
photos: containers.filter((c) =>
|
||||
["immich"].some((app) => c.name.toLowerCase().includes(app))
|
||||
),
|
||||
databases: containers.filter((c) =>
|
||||
["postgres", "mariadb", "mysql", "mongo", "redis", "db"].some((app) =>
|
||||
c.name.toLowerCase().includes(app)
|
||||
)
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const filteredContainers = containers.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.image.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const grouped = groupContainers(
|
||||
searchQuery ? filteredContainers : containers
|
||||
);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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 space-x-3">
|
||||
<Server className="w-8 h-8 text-blue-500" />
|
||||
<h1 className="text-2xl font-bold text-white">Atlas Dashboard</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-400">
|
||||
<Activity className="w-4 h-4" />
|
||||
<span>{containers.length} containers</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<SearchBar value={searchQuery} onChange={setSearchQuery} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Widgets Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
<UnifiWidget />
|
||||
<SynologyWidget />
|
||||
<GrafanaWidget
|
||||
title="Server Stats"
|
||||
dashboardId="server-overview"
|
||||
panelId={1}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
<GrafanaWidget
|
||||
title="LLM Metrics"
|
||||
dashboardId="llm-monitoring"
|
||||
panelId={3}
|
||||
/>
|
||||
<GrafanaWidget
|
||||
title="System Load"
|
||||
dashboardId="system-metrics"
|
||||
panelId={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Container Groups */}
|
||||
{loading ? (
|
||||
<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>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{grouped.media.length > 0 && (
|
||||
<ContainerGroup
|
||||
title="Media Management"
|
||||
containers={grouped.media}
|
||||
icon="📺"
|
||||
/>
|
||||
)}
|
||||
{grouped.download.length > 0 && (
|
||||
<ContainerGroup
|
||||
title="Download Clients"
|
||||
containers={grouped.download}
|
||||
icon="⬇️"
|
||||
/>
|
||||
)}
|
||||
{grouped.ai.length > 0 && (
|
||||
<ContainerGroup
|
||||
title="AI Services"
|
||||
containers={grouped.ai}
|
||||
icon="🤖"
|
||||
/>
|
||||
)}
|
||||
{grouped.photos.length > 0 && (
|
||||
<ContainerGroup
|
||||
title="Photo Management"
|
||||
containers={grouped.photos}
|
||||
icon="📷"
|
||||
/>
|
||||
)}
|
||||
{grouped.media_processing.length > 0 && (
|
||||
<ContainerGroup
|
||||
title="Media Processing"
|
||||
containers={grouped.media_processing}
|
||||
icon="🎬"
|
||||
/>
|
||||
)}
|
||||
{grouped.automation.length > 0 && (
|
||||
<ContainerGroup
|
||||
title="Automation"
|
||||
containers={grouped.automation}
|
||||
icon="⚡"
|
||||
/>
|
||||
)}
|
||||
{grouped.productivity.length > 0 && (
|
||||
<ContainerGroup
|
||||
title="Productivity"
|
||||
containers={grouped.productivity}
|
||||
icon="💼"
|
||||
/>
|
||||
)}
|
||||
{grouped.infrastructure.length > 0 && (
|
||||
<ContainerGroup
|
||||
title="Infrastructure"
|
||||
containers={grouped.infrastructure}
|
||||
icon="🔧"
|
||||
/>
|
||||
)}
|
||||
{grouped.monitoring.length > 0 && (
|
||||
<ContainerGroup
|
||||
title="Monitoring"
|
||||
containers={grouped.monitoring}
|
||||
icon="📊"
|
||||
/>
|
||||
)}
|
||||
{grouped.databases.length > 0 && (
|
||||
<ContainerGroup
|
||||
title="Databases"
|
||||
containers={grouped.databases}
|
||||
icon="🗄️"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
src/components/ContainerGroup.tsx
Normal file
115
src/components/ContainerGroup.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { Container } from "@/types";
|
||||
import { motion } from "framer-motion";
|
||||
import { ExternalLink, Power, Circle } from "lucide-react";
|
||||
|
||||
interface ContainerGroupProps {
|
||||
title: string;
|
||||
containers: Container[];
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export default function ContainerGroup({
|
||||
title,
|
||||
containers,
|
||||
icon,
|
||||
}: ContainerGroupProps) {
|
||||
const getStatusColor = (state: string) => {
|
||||
switch (state.toLowerCase()) {
|
||||
case "running":
|
||||
return "text-green-500";
|
||||
case "paused":
|
||||
return "text-yellow-500";
|
||||
case "exited":
|
||||
return "text-red-500";
|
||||
default:
|
||||
return "text-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
const getTraefikUrl = (labels: Record<string, string>) => {
|
||||
const host = labels["traefik.http.routers.https.rule"];
|
||||
if (host) {
|
||||
const match = host.match(/Host\(`([^`]+)`\)/);
|
||||
if (match) return `https://${match[1]}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800/40 backdrop-blur-sm rounded-lg border border-gray-700 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-700 bg-gray-800/60">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<span className="text-2xl">{icon}</span>
|
||||
{title}
|
||||
<span className="ml-auto text-sm text-gray-400">
|
||||
{containers.length}{" "}
|
||||
{containers.length === 1 ? "container" : "containers"}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-6">
|
||||
{containers.map((container, idx) => {
|
||||
const url = getTraefikUrl(container.labels);
|
||||
return (
|
||||
<motion.div
|
||||
key={container.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
className="bg-gray-900/50 rounded-lg border border-gray-700 p-4 hover:border-blue-500/50 transition-all duration-200 group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">
|
||||
{container.name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{container.image}
|
||||
</p>
|
||||
</div>
|
||||
<Circle
|
||||
className={`w-3 h-3 fill-current ${getStatusColor(
|
||||
container.state
|
||||
)} flex-shrink-0`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-400">Status</span>
|
||||
<span className="text-gray-300">{container.status}</span>
|
||||
</div>
|
||||
|
||||
{container.ports.length > 0 && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-400">Ports</span>
|
||||
<span className="text-gray-300">
|
||||
{container.ports
|
||||
.filter((p) => p.publicPort)
|
||||
.map((p) => p.publicPort)
|
||||
.join(", ") || "Internal"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 mt-3 py-2 px-3 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 rounded text-xs font-medium transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Open
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/components/GrafanaWidget.tsx
Normal file
48
src/components/GrafanaWidget.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"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 (
|
||||
<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">
|
||||
<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}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
Open in Grafana →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/components/SearchBar.tsx
Normal file
23
src/components/SearchBar.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
interface SearchBarProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function SearchBar({ value, onChange }: SearchBarProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Search containers..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-800/60 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
src/components/SynologyWidget.tsx
Normal file
91
src/components/SynologyWidget.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { HardDrive } from "lucide-react";
|
||||
|
||||
export default function SynologyWidget() {
|
||||
const [volumes, setVolumes] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStorage();
|
||||
const interval = setInterval(fetchStorage, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchStorage = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/synology");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setVolumes(data);
|
||||
setError(false);
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
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
|
||||
</h3>
|
||||
</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 ? (
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{volumes.map((vol, idx) => (
|
||||
<div key={idx} 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>
|
||||
</div>
|
||||
<div className="w-full bg-gray-700 rounded-full h-2 overflow-hidden">
|
||||
<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"
|
||||
}`}
|
||||
style={{ width: `${vol.percentUsed}%` }}
|
||||
></div>
|
||||
</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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
src/components/UnifiWidget.tsx
Normal file
75
src/components/UnifiWidget.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Wifi, WifiOff } from "lucide-react";
|
||||
|
||||
export default function UnifiWidget() {
|
||||
const [devices, setDevices] = useState<any[]>([]);
|
||||
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();
|
||||
setDevices(data);
|
||||
setError(false);
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onlineDevices = devices.filter((d) => d.state === 1).length;
|
||||
const totalClients = devices.reduce((sum, d) => sum + (d.clients || 0), 0);
|
||||
|
||||
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>
|
||||
</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 ? (
|
||||
<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">
|
||||
Configure UniFi credentials in .env
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-gray-900/50 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-400">Devices Online</p>
|
||||
<p className="text-2xl font-bold text-green-500">
|
||||
{onlineDevices}/{devices.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-900/50 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-400">Connected Clients</p>
|
||||
<p className="text-2xl font-bold text-blue-500">{totalClients}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/types/index.ts
Normal file
40
src/types/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export interface Container {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
state: string;
|
||||
status: string;
|
||||
created: number;
|
||||
ports: Port[];
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Port {
|
||||
ip?: string;
|
||||
privatePort: number;
|
||||
publicPort?: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface UnifiDevice {
|
||||
name: string;
|
||||
mac: string;
|
||||
ip: string;
|
||||
model: string;
|
||||
state: number;
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
export interface SynologyStorage {
|
||||
volume: string;
|
||||
size: number;
|
||||
used: number;
|
||||
available: number;
|
||||
percentUsed: number;
|
||||
}
|
||||
|
||||
export interface GrafanaDashboard {
|
||||
uid: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
Reference in New Issue
Block a user