Initial commit: Docker Dashboard with Next.js and Netbox integration

This commit is contained in:
Gemini Agent
2025-12-03 17:18:56 +00:00
commit b84e45a1fd
25 changed files with 7619 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const apiUrl = process.env.NETBOX_API_URL;
const apiToken = process.env.NETBOX_API_TOKEN;
if (!apiUrl || !apiToken) {
return NextResponse.json(
{ error: "NetBox configuration missing on server" },
{ status: 500 }
);
}
try {
const body = await request.json();
// Whitelist: Only allow specific fields to be updated
const payload: any = {};
if (typeof body.comments === "string") {
payload.comments = body.comments;
}
if (body.custom_fields) {
// Initialize custom_fields object if not present, but strictly controlled
const cf: any = {};
let hasCustomFields = false;
if (typeof body.custom_fields.docker_run_command === "string") {
cf.docker_run_command = body.custom_fields.docker_run_command;
hasCustomFields = true;
}
if (typeof body.custom_fields.docker_volumes === "string") {
cf.docker_volumes = body.custom_fields.docker_volumes;
hasCustomFields = true;
}
if (hasCustomFields) {
payload.custom_fields = cf;
}
}
if (Object.keys(payload).length === 0) {
return NextResponse.json(
{ error: "No valid fields provided for update" },
{ status: 400 }
);
}
// Remove trailing slash from API URL if present and construct endpoint
const baseUrl = apiUrl.replace(/\/api\/?$/, "").replace(/\/$/, "");
const endpoint = `${baseUrl}/api/virtualization/virtual-machines/${id}/`;
const res = await fetch(endpoint, {
method: "PATCH",
headers: {
"Authorization": `Token ${apiToken}`,
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify(payload),
});
if (!res.ok) {
const errorText = await res.text();
console.error(`NetBox API Error (${res.status}):`, errorText);
return NextResponse.json(
{ error: `NetBox rejected update: ${res.statusText}`, details: errorText },
{ status: res.status }
);
}
const updatedVM = await res.json();
return NextResponse.json(updatedVM);
} catch (error) {
console.error("Server Error updating VM:", error);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 }
);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,12 @@
@import "tailwindcss";
:root {
--background: #f8fafc; /* slate-50 */
--foreground: #0f172a; /* slate-900 */
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

18
frontend/src/app/page.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { getDockerContainers } from "@/lib/netbox";
import { ContainerViewer } from "@/components/ContainerViewer";
export const dynamic = 'force-dynamic';
export default async function Home() {
const groupedContainers = await getDockerContainers();
const appTitle = process.env.APP_TITLE || "Docker Inventory";
const appLogo = process.env.APP_LOGO || "";
return (
<ContainerViewer
groupedContainers={groupedContainers}
appTitle={appTitle}
appLogo={appLogo}
/>
);
}

View File

@@ -0,0 +1,412 @@
"use client";
import { useState, useMemo } from "react";
import {
Server,
Box,
Terminal,
Activity,
Copy,
Check,
MessageSquare,
ExternalLink,
Search,
Pencil,
X,
Save,
Loader2,
HardDrive
} from "lucide-react";
import { type GroupedContainers, type NetboxVM } from "@/lib/netbox";
import { cn } from "@/lib/utils";
interface ContainerViewerProps {
groupedContainers: GroupedContainers;
appTitle: string;
appLogo: string;
}
type EditableField = 'comments' | 'command' | 'volumes';
export function ContainerViewer({ groupedContainers: initialData, appTitle, appLogo }: ContainerViewerProps) {
const [groupedContainers, setGroupedContainers] = useState(initialData);
const hosts = useMemo(() => Object.keys(groupedContainers).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })), [groupedContainers]);
const [selectedHost, setSelectedHost] = useState<string>(hosts[0] || "");
const [searchQuery, setSearchQuery] = useState("");
const [copiedId, setCopiedId] = useState<string | null>(null);
const [editingField, setEditingField] = useState<{ vmId: number, field: EditableField } | null>(null);
const [editValue, setEditValue] = useState("");
const [isSaving, setIsSaving] = useState(false);
// Global Search
const displayContainers = useMemo(() => {
if (!searchQuery) {
return groupedContainers[selectedHost] || [];
}
const lowerQuery = searchQuery.toLowerCase();
const allContainers: NetboxVM[] = [];
Object.values(groupedContainers).forEach(hostContainers => {
hostContainers.forEach(vm => {
if (
vm.name.toLowerCase().includes(lowerQuery) ||
(vm.description && vm.description.toLowerCase().includes(lowerQuery)) ||
(vm.primary_ip4?.address && vm.primary_ip4.address.includes(lowerQuery)) ||
(vm.comments && vm.comments.toLowerCase().includes(lowerQuery))
) {
allContainers.push(vm);
}
});
});
return allContainers;
}, [groupedContainers, selectedHost, searchQuery]);
const handleCopy = (text: string, id: string) => {
navigator.clipboard.writeText(text);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
const startEditing = (vm: NetboxVM, field: EditableField) => {
let value = "";
if (field === 'comments') value = vm.comments || "";
else if (field === 'command') value = vm.custom_fields?.docker_run_command || "";
else if (field === 'volumes') value = vm.custom_fields?.docker_volumes || "";
setEditValue(value);
setEditingField({ vmId: vm.id, field });
};
const cancelEditing = () => {
setEditingField(null);
setEditValue("");
};
const saveEditing = async () => {
if (!editingField) return;
setIsSaving(true);
try {
const payload: any = {};
if (editingField.field === 'comments') payload.comments = editValue;
else if (editingField.field === 'command') payload.custom_fields = { docker_run_command: editValue };
else if (editingField.field === 'volumes') payload.custom_fields = { docker_volumes: editValue };
const res = await fetch(`/api/vm/${editingField.vmId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error("Failed to save");
const updatedVM = await res.json();
setGroupedContainers(prev => {
const newGrouped = { ...prev };
Object.keys(newGrouped).forEach(host => {
newGrouped[host] = newGrouped[host].map(vm => {
if (vm.id === editingField.vmId) {
return {
...vm,
comments: updatedVM.comments,
custom_fields: { ...vm.custom_fields, ...updatedVM.custom_fields }
};
}
return vm;
});
});
return newGrouped;
});
setEditingField(null);
} catch (error) {
console.error("Save failed", error);
alert("Fehler beim Speichern!");
} finally {
setIsSaving(false);
}
};
// --- Helper to render an editable section ---
const renderSection = (vm: NetboxVM, field: EditableField, icon: React.ReactNode, label: string, content: string | undefined) => {
const isEditing = editingField?.vmId === vm.id && editingField.field === field;
const hasContent = content && content.trim().length > 0;
// Hide section if empty AND not editing.
// BUT: User needs a way to add content to empty fields.
// Design decision: Always show a small "Add" button or placeholder?
// User requirement: "Zeilen sollen übrigens NUR angezeigt werden, wenn es inhalt gibt".
// Conflict: If hidden, how to edit? -> Maybe show a subtle "Add" action elsewhere?
// Compromise: If empty, show a very subtle "Add [Label]" button only on hover of the card?
// Or better: Keep them hidden but provide an "Edit Mode" toggle?
// Given the CLI context, I will interpret "hidden" strictly. BUT I will add a small icon row at the bottom of the card to "Add Property" if missing.
if (!hasContent && !isEditing) return null;
return (
<div className="bg-slate-50 border-t border-slate-200 px-5 py-3">
<div className="flex items-start gap-4">
<div className="flex-1 min-w-0 flex flex-col">
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">
{icon}
{label}
</div>
{isEditing ? (
<div className="space-y-2 w-full">
<textarea
className="w-full bg-slate-800 text-slate-200 p-3 rounded-lg font-mono text-xs border border-blue-500 focus:outline-none min-h-[100px]"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
autoFocus
/>
<div className="flex gap-2 justify-end">
<button
onClick={cancelEditing}
disabled={isSaving}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-slate-600 bg-white border border-slate-300 rounded hover:bg-slate-50"
>
<X className="w-3 h-3" /> Abbrechen
</button>
<button
onClick={saveEditing}
disabled={isSaving}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700 disabled:opacity-50"
>
{isSaving ? <Loader2 className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
Speichern
</button>
</div>
</div>
) : (
/* Flex container for Content + Buttons to ensure bottom alignment */
<div className="flex items-end gap-2">
<pre className="flex-1 text-xs font-mono bg-slate-800 text-slate-300 p-3 rounded-lg overflow-x-auto whitespace-pre-wrap break-all shadow-inner min-h-[2.5rem]">
{content}
</pre>
{/* Buttons group - shrink-0 to keep size, items-end handled by parent flex */}
<div className="flex gap-2 flex-shrink-0">
<button
onClick={() => startEditing(vm, field)}
className="p-2 rounded-md bg-white border border-slate-200 hover:bg-blue-50 hover:border-blue-200 text-slate-500 hover:text-blue-600 transition-all shadow-sm"
title="Bearbeiten"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => handleCopy(content || "", `${field}-${vm.id}`)}
className="p-2 rounded-md bg-white border border-slate-200 hover:bg-blue-50 hover:border-blue-200 text-slate-500 hover:text-slate-600 transition-all shadow-sm"
title="Kopieren"
>
{copiedId === `${field}-${vm.id}` ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
};
return (
<div className="flex h-screen overflow-hidden bg-slate-200 text-slate-900">
{/* Sidebar */}
<aside className="w-64 bg-slate-900 border-r border-slate-800 flex-shrink-0 overflow-y-auto flex flex-col shadow-xl z-20">
<div className="p-6 sticky top-0 bg-slate-900 z-10 border-b border-slate-800">
<h1 className="text-xl font-bold flex items-center gap-2 text-blue-400">
{appLogo ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={appLogo} alt="Logo" className="w-8 h-8 object-contain" />
) : (
<Box className="w-6 h-6" />
)}
<span>{appTitle}</span>
</h1>
<p className="text-xs text-slate-400 mt-1">NetBox Inventory</p>
</div>
<nav className="flex-1 p-4 space-y-2">
<div className="mb-4 px-2 text-xs font-semibold text-slate-500 uppercase tracking-wider">
Geräte
</div>
{hosts.map((host) => (
<button
key={host}
onClick={() => { setSelectedHost(host); setSearchQuery(""); }}
className={cn(
"w-full flex items-center gap-3 px-3 py-3 text-sm font-medium rounded-xl transition-all border shadow-sm",
selectedHost === host && !searchQuery
? "bg-blue-600 text-white border-blue-500 shadow-md ring-1 ring-blue-400/20"
: "bg-slate-800/50 text-slate-400 border-slate-700 hover:bg-slate-800 hover:text-slate-100 hover:border-slate-600"
)}
>
<Server className={cn("w-4 h-4", selectedHost === host && !searchQuery ? "text-blue-200" : "text-slate-500")} />
<span className="truncate font-semibold">{host}</span>
<span className={cn(
"ml-auto py-0.5 px-2 rounded-full text-xs font-bold",
selectedHost === host && !searchQuery
? "bg-blue-500 text-white"
: "bg-slate-800 text-slate-500"
)}>
{groupedContainers[host]?.length || 0}
</span>
</button>
))}
</nav>
</aside>
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden">
<header className="bg-slate-900 border-b border-slate-800 p-4 flex items-center justify-between shadow-md z-10">
<div className="flex items-center gap-4">
<div className="p-2 bg-blue-600 rounded-lg text-white shadow-lg shadow-blue-900/20">
{searchQuery ? <Search className="w-6 h-6" /> : <Server className="w-6 h-6" />}
</div>
<div>
<h2 className="text-xl font-bold text-white leading-tight">
{searchQuery ? "Suchergebnisse" : (selectedHost || "Kein Gerät ausgewählt")}
</h2>
<p className="text-xs text-slate-400">
{displayContainers.length} Container gefunden
</p>
</div>
</div>
<div className="relative w-64 md:w-80">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-4 w-4 text-slate-500" />
</div>
<input
type="text"
className="block w-full pl-10 pr-3 py-2 border border-slate-700 rounded-lg leading-5 bg-slate-800 text-slate-300 placeholder-slate-500 focus:outline-none focus:bg-slate-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 sm:text-sm transition-colors"
placeholder="Suche (Global)..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</header>
<main className="flex-1 overflow-y-auto bg-slate-200 p-6 lg:p-10">
<div className="w-full max-w-full mx-auto">
{!selectedHost && !searchQuery ? (
<div className="text-center py-20">
<h2 className="text-2xl font-semibold text-slate-400">Bitte wähle links ein Gerät aus.</h2>
</div>
) : displayContainers.length === 0 ? (
<div className="text-center py-20 opacity-60">
<Box className="w-12 h-12 text-slate-400 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-slate-500">Keine Container gefunden.</h2>
</div>
) : (
<div className="grid grid-cols-1 gap-4">
{displayContainers.map((vm) => (
<div
key={vm.id}
className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-all flex flex-col group"
>
{/* Card Header */}
<div className="p-4 flex items-center gap-4 min-w-0">
<div className="flex-shrink-0 p-2 bg-blue-50 rounded-lg text-blue-600 group-hover:bg-blue-600 group-hover:text-white transition-colors">
<Box className="w-5 h-5" />
</div>
<div className="flex-shrink-0 flex items-center gap-2">
<h3 className="font-bold text-lg text-slate-900 whitespace-nowrap">
{vm.name}
</h3>
{vm.netboxUrl && (
<a
href={vm.netboxUrl}
target="_blank"
rel="noopener noreferrer"
className="text-slate-400 hover:text-blue-600 transition-colors p-1"
title="In NetBox öffnen"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
{searchQuery && (
<span className="hidden sm:inline-block px-2 py-0.5 bg-slate-100 text-slate-500 text-xs rounded border border-slate-200 ml-2">
{vm.device?.name || vm.cluster?.name || "Unknown Host"}
</span>
)}
<div className="h-6 w-px bg-slate-200 mx-2 hidden md:block" />
<div className="flex-1 min-w-0 hidden md:block">
<p className="text-slate-600 text-sm truncate" title={vm.description || ""}>
{vm.description || <span className="italic text-slate-400">Keine Beschreibung</span>}
</p>
</div>
<div className="flex-shrink-0 hidden sm:flex items-center gap-2 bg-slate-50 rounded px-2 py-1 border border-slate-100">
<Activity className="w-3.5 h-3.5 text-slate-400" />
<span className="text-xs font-mono text-slate-700 font-medium">
{vm.primary_ip4?.address?.split('/')[0] || vm.primary_ip?.address?.split('/')[0] || "No IP"}
</span>
</div>
<div className="flex-shrink-0 ml-auto">
<span className={cn(
"px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap",
vm.status.value.toLowerCase() === 'active'
? "bg-green-50 text-green-700 border-green-200"
: "bg-slate-100 text-slate-600 border-slate-200"
)}>
{vm.status.label}
</span>
</div>
</div>
{/* Mobile Description */}
<div className="md:hidden px-4 pb-3 text-sm text-slate-500 truncate">
{vm.description}
</div>
{/* Add Buttons for Missing Fields */}
{!vm.comments && !editingField && (
<div className="px-5 py-1 flex justify-end bg-slate-50 border-t border-slate-200 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => startEditing(vm, 'comments')} className="text-[10px] text-blue-500 hover:underline flex items-center gap-1">
<MessageSquare className="w-3 h-3" /> Kommentar hinzufügen
</button>
</div>
)}
{/* We handle the display logic inside renderSection, but if it returns null, we might want 'Add' buttons.
To keep it clean:
1. Render existing sections.
2. If a section is missing, show a mini toolbar at the bottom on hover to add it.
*/}
{renderSection(vm, 'comments', <MessageSquare className="w-3 h-3" />, "Kommentare", vm.comments)}
{renderSection(vm, 'command', <Terminal className="w-3 h-3" />, "Befehl", vm.custom_fields?.docker_run_command)}
{renderSection(vm, 'volumes', <HardDrive className="w-3 h-3" />, "Volumes", vm.custom_fields?.docker_volumes)}
{/* Mini Toolbar for adding missing fields */}
<div className="px-5 py-2 flex gap-4 bg-slate-50 border-t border-slate-200 opacity-0 group-hover:opacity-100 transition-opacity">
{!vm.comments && !editingField && (
<button onClick={() => startEditing(vm, 'comments')} className="text-[10px] text-slate-400 hover:text-blue-600 flex items-center gap-1 transition-colors">
<MessageSquare className="w-3 h-3" /> + Kommentar
</button>
)}
{!vm.custom_fields?.docker_run_command && !editingField && (
<button onClick={() => startEditing(vm, 'command')} className="text-[10px] text-slate-400 hover:text-blue-600 flex items-center gap-1 transition-colors">
<Terminal className="w-3 h-3" /> + Befehl
</button>
)}
{!vm.custom_fields?.docker_volumes && !editingField && (
<button onClick={() => startEditing(vm, 'volumes')} className="text-[10px] text-slate-400 hover:text-blue-600 flex items-center gap-1 transition-colors">
<HardDrive className="w-3 h-3" /> + Volumes
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</main>
</div>
</div>
);
}

152
frontend/src/lib/netbox.ts Normal file
View File

@@ -0,0 +1,152 @@
export interface NetboxIP {
id: number;
address: string;
}
export interface NetboxCluster {
id: number;
name: string;
url: string;
}
export interface NetboxDevice {
id: number;
name: string;
url: string;
}
export interface NetboxCustomFields {
docker_run_command?: string;
docker_volumes?: string;
[key: string]: any;
}
export interface NetboxVM {
id: number;
name: string;
description?: string; // Kurze Beschreibung
comments?: string; // Markdown Kommentare
status: { value: string; label: string };
primary_ip?: NetboxIP;
primary_ip4?: NetboxIP;
cluster?: NetboxCluster;
device?: NetboxDevice; // Das Host-Gerät
custom_fields?: NetboxCustomFields;
netboxUrl?: string; // Computed URL to NetBox UI
}
export interface NetboxResponse<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
// Helper to group containers by host (device)
export type GroupedContainers = Record<string, NetboxVM[]>;
const MOCK_DATA: NetboxVM[] = [
{
id: 1,
name: "nginx-proxy",
description: "Reverse Proxy",
comments: "Dies ist ein **wichtiger** Container.\nBitte nicht stoppen!",
status: { value: "active", label: "Active" },
primary_ip4: { id: 10, address: "192.168.1.50/24" },
device: { id: 1, name: "docker-host-01", url: "" },
cluster: { id: 99, name: "Docker-Hosts", url: "" },
custom_fields: {
docker_run_command: "docker run -d -p 80:80 --name nginx-proxy nginx:latest",
docker_volumes: "-v /etc/nginx:/etc/nginx\n-v /var/log/nginx:/var/log/nginx"
},
netboxUrl: "#",
},
{
id: 2,
name: "postgres-db",
description: "DB Main",
status: { value: "active", label: "Active" },
primary_ip4: { id: 11, address: "192.168.1.51/24" },
device: { id: 1, name: "docker-host-01", url: "" },
cluster: { id: 99, name: "Docker-Hosts", url: "" },
custom_fields: {
docker_run_command: "docker run -d postgres:14",
},
netboxUrl: "#",
},
{
id: 3,
name: "grafana",
description: "Monitoring",
status: { value: "staged", label: "Staged" },
device: { id: 2, name: "docker-host-02", url: "" },
cluster: { id: 99, name: "Docker-Hosts", url: "" },
custom_fields: {
docker_run_command: "docker run -d grafana/grafana"
},
netboxUrl: "#",
},
];
export async function getDockerContainers(): Promise<GroupedContainers> {
const apiUrl = process.env.NETBOX_API_URL;
const apiToken = process.env.NETBOX_API_TOKEN;
if (!apiUrl || !apiToken) {
console.warn("NETBOX_API_URL or NETBOX_API_TOKEN not set. Using mock data.");
return groupContainers(MOCK_DATA);
}
try {
// Fetch VMs with role 'docker-container'.
const res = await fetch(
`${apiUrl}/api/virtualization/virtual-machines/?role=docker-container&limit=1000`,
{
headers: {
Authorization: `Token ${apiToken}`,
Accept: "application/json",
},
next: { revalidate: 10 }, // Short cache for testing
}
);
if (!res.ok) {
console.error("Failed to fetch from Netbox:", res.status, res.statusText);
return groupContainers(MOCK_DATA);
}
const data: NetboxResponse<NetboxVM> = await res.json();
// Compute NetBox UI URL
// Assumes apiUrl is something like "https://netbox.local/api" or "https://netbox.local/"
// We want "https://netbox.local/virtualization/virtual-machines/{id}/"
const baseUrl = apiUrl.replace(new RegExp("/api/?$"), "").replace(new RegExp("$"), "");
const enrichedResults = data.results.map(vm => ({
...vm,
netboxUrl: `${baseUrl}/virtualization/virtual-machines/${vm.id}/`
}));
return groupContainers(enrichedResults);
} catch (error) {
console.error("Error fetching netbox data:", error);
return groupContainers(MOCK_DATA);
}
}
function groupContainers(vms: NetboxVM[]): GroupedContainers {
const grouped: GroupedContainers = {};
vms.forEach((vm) => {
// Prioritize 'device' name, fallback to 'cluster' name, then "Unassigned"
const hostName = vm.device?.name || vm.cluster?.name || "Unassigned";
if (!grouped[hostName]) {
grouped[hostName] = [];
}
grouped[hostName].push(vm);
});
return grouped;
}

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}