Initial commit: ATLAS Dashboard (Next.js)

This commit is contained in:
2026-02-13 12:24:02 -05:00
commit d6debe51b1
72 changed files with 16965 additions and 0 deletions

View 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 });
}
}

View 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 }
);
}
}

View 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
View 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
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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;
}