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

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.env
.DS_Store
node_modules/
.next/
out/
build/
dist/
*.log

62
README.md Normal file
View File

@@ -0,0 +1,62 @@
# Docker Dashboard (Netbox Integration)
Ein modernes Dashboard zur Visualisierung von Docker-Containern und Inventar-Daten, integriert mit Netbox. Entwickelt mit Next.js, React und Tailwind CSS.
## 🚀 Features
- **Inventar-Übersicht**: Darstellung von Container-Informationen.
- **Netbox Integration**: Nahtlose Anbindung an die Netbox API.
- **Modernes UI**: Responsives Design dank Tailwind CSS v4.
- **Dockerized**: Einfaches Deployment via Docker Compose.
## 🛠️ Technologien
- **Frontend**: [Next.js 16](https://nextjs.org/), [React 19](https://react.dev/)
- **Styling**: [Tailwind CSS 4](https://tailwindcss.com/), Lucide React Icons
- **Deployment**: Docker & Docker Compose
## 📦 Installation & Setup
### Voraussetzungen
- Docker & Docker Compose
- Zugriff auf eine Netbox-Instanz (API URL & Token)
### Starten mit Docker Compose
1. **Repository klonen** (oder Dateien herunterladen).
2. **Umgebungsvariablen konfigurieren**:
Erstelle eine `.env` Datei im Hauptverzeichnis oder nutze die bestehende.
```env
NETBOX_API_URL=https://deine-netbox-url.com
NETBOX_API_TOKEN=dein_api_token
APP_TITLE="Mein Docker Dashboard"
```
3. **Container starten**:
```bash
docker-compose up -d --build
```
Das Dashboard ist nun unter `http://localhost:3000` erreichbar.
## 🔧 Entwicklung
Um das Frontend lokal ohne Docker zu entwickeln:
```bash
cd frontend
npm install
npm run dev
```
## 📂 Projektstruktur
- `docker-compose.yml`: Definition der Services.
- `frontend/`: Quellcode der Next.js Anwendung.
- `src/app/`: Next.js App Router Pages.
- `src/lib/`: Hilfsfunktionen und API-Clients.
- `src/components/`: UI-Komponenten.

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
services:
netbox-docker-ui:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: netbox-docker-ui
restart: unless-stopped
ports:
- "3000:3000"
env_file:
- .env
environment:
# Fallbacks falls nicht in .env gesetzt
- NETBOX_API_URL=${NETBOX_API_URL:-}
- NETBOX_API_TOKEN=${NETBOX_API_TOKEN:-}
- APP_TITLE=${APP_TITLE:-Docker Inventory}
- APP_LOGO=${APP_LOGO:-}

41
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

57
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,57 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime.
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD ["node", "server.js"]

36
frontend/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
frontend/next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

6577
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
frontend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^0.555.0",
"next": "16.0.6",
"react": "19.2.0",
"react-dom": "19.2.0",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
frontend/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
frontend/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

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

34
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}