Initial commit: Docker Dashboard with Next.js and Netbox integration
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
62
README.md
Normal file
62
README.md
Normal 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
17
docker-compose.yml
Normal 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
41
frontend/.gitignore
vendored
Normal 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
57
frontend/Dockerfile
Normal 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
36
frontend/README.md
Normal 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.
|
||||||
18
frontend/eslint.config.mjs
Normal file
18
frontend/eslint.config.mjs
Normal 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
7
frontend/next.config.ts
Normal 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
6577
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
frontend/postcss.config.mjs
Normal file
7
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
frontend/public/file.svg
Normal file
1
frontend/public/file.svg
Normal 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 |
1
frontend/public/globe.svg
Normal file
1
frontend/public/globe.svg
Normal 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
1
frontend/public/next.svg
Normal 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 |
1
frontend/public/vercel.svg
Normal file
1
frontend/public/vercel.svg
Normal 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 |
1
frontend/public/window.svg
Normal file
1
frontend/public/window.svg
Normal 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 |
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));
|
||||||
|
}
|
||||||
34
frontend/tsconfig.json
Normal file
34
frontend/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user