Initial commit: Docker Dashboard with Next.js and Netbox integration
This commit is contained in:
87
frontend/src/app/api/vm/[id]/route.ts
Normal file
87
frontend/src/app/api/vm/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
12
frontend/src/app/globals.css
Normal file
12
frontend/src/app/globals.css
Normal 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;
|
||||
}
|
||||
34
frontend/src/app/layout.tsx
Normal file
34
frontend/src/app/layout.tsx
Normal 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
18
frontend/src/app/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
412
frontend/src/components/ContainerViewer.tsx
Normal file
412
frontend/src/components/ContainerViewer.tsx
Normal 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
152
frontend/src/lib/netbox.ts
Normal 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;
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user