generated from Dicken/dickendock
Compare commits
14 Commits
main
...
6a7eb3dfe5
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a7eb3dfe5 | |||
| 63af09f43a | |||
| 7ae5a353cd | |||
| 2f31996bbb | |||
| b0ff551ca0 | |||
| 28bccb9d16 | |||
| 129b4253b5 | |||
| f963f7eb4e | |||
| a5b998b421 | |||
| 3a7f423f60 | |||
| 7b8723729b | |||
| 657496fe49 | |||
| ab49999649 | |||
| affb9aef16 |
41
.env.example
Normal file
41
.env.example
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# LaunchPad – Umgebungsvariablen
|
||||||
|
#
|
||||||
|
# Kopiere diese Datei nach ".env" und trage echte Werte ein:
|
||||||
|
# cp .env.example .env
|
||||||
|
#
|
||||||
|
# ".env" ist in .gitignore und wird NIE committet. Auf dem Server
|
||||||
|
# (/opt/LaunchPad) muss sie nach dem ersten `git pull` einmalig manuell
|
||||||
|
# angelegt werden, da git-ignorierte Dateien nicht mitgepullt werden.
|
||||||
|
|
||||||
|
# ─── Backend ─────────────────────────────────────────────────────────
|
||||||
|
# Node-Umgebung: "development" oder "production"
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Host/Port, auf dem das Backend lauscht (innerhalb des Containers)
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# Pfad zur SQLite-Datenbankdatei (im Docker-Container: das gemountete Volume)
|
||||||
|
DATABASE_PATH=/data/launchpad.db
|
||||||
|
|
||||||
|
# Log-Level für Fastify/Pino: fatal | error | warn | info | debug | trace
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# CORS-Origin fürs Frontend. "true" erlaubt alle Origins (Default – im lokalen
|
||||||
|
# Betrieb hinter Nginx unbedenklich). Für strengere Regeln z. B.:
|
||||||
|
# CORS_ORIGIN=https://launchpad.home
|
||||||
|
CORS_ORIGIN=true
|
||||||
|
|
||||||
|
# ─── FritzBox-Scan (optional) ───────────────────────────────────────
|
||||||
|
# Nur nötig für POST /api/scan/fritzbox. TR-064-Zugriff muss in der FritzBox
|
||||||
|
# aktiviert sein: Heimnetz -> Netzwerk -> Netzwerkeinstellungen ->
|
||||||
|
# "Zugriff für Anwendungen zulassen". Leer lassen = Scan liefert kontrolliert
|
||||||
|
# HTTP 400 statt zu fehlen.
|
||||||
|
FRITZBOX_HOST=192.168.1.1
|
||||||
|
FRITZBOX_PORT=49000
|
||||||
|
FRITZBOX_USERNAME=
|
||||||
|
FRITZBOX_PASSWORD=
|
||||||
|
|
||||||
|
# ─── Frontend (nur für lokale Entwicklung, `pnpm dev:frontend`) ─────
|
||||||
|
# Ziel für den Vite-Dev-Proxy von /api -> Backend
|
||||||
|
BACKEND_URL=http://localhost:3001
|
||||||
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
.DS_Store
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.tsbuildinfo
|
||||||
|
coverage/
|
||||||
173
README.md
Normal file
173
README.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# LaunchPad
|
||||||
|
|
||||||
|
Ein moderner, minimalistischer Homelab-Launcher – inspiriert von Raycast, Spotlight, Arc
|
||||||
|
und Linear. Tippe wenige Buchstaben, finde sofort den gewünschten Dienst, drücke Enter.
|
||||||
|
|
||||||
|
Kein überladenes Dashboard. Keine Kacheln. Im Mittelpunkt steht eine extrem schnelle Suche.
|
||||||
|
|
||||||
|
## Quickstart (Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> LaunchPad
|
||||||
|
cd LaunchPad
|
||||||
|
cp .env.example .env # optional anpassen, siehe unten
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
- Frontend: http://localhost:8080
|
||||||
|
- Backend / Health-API: http://localhost:3001/api/health
|
||||||
|
|
||||||
|
## Quickstart (lokale Entwicklung)
|
||||||
|
|
||||||
|
Voraussetzungen: Node.js ≥ 20, pnpm ≥ 9.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev:backend # startet Fastify auf :3001
|
||||||
|
pnpm dev:frontend # startet Vite auf :5173 (proxyt /api zum Backend)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
LaunchPad
|
||||||
|
│
|
||||||
|
├── apps
|
||||||
|
│ ├── frontend React + Vite + TypeScript + TailwindCSS
|
||||||
|
│ └── backend Fastify + TypeScript + Drizzle ORM + SQLite
|
||||||
|
│
|
||||||
|
├── packages
|
||||||
|
│ ├── shared gemeinsame Typen (Device, Service) + Such-Ranking-Logik
|
||||||
|
│ └── ui gemeinsame UI-Komponenten (SearchInput, StatusBadge)
|
||||||
|
│
|
||||||
|
├── docker zusätzliche Docker-Hilfsdateien
|
||||||
|
├── docs Projektdokumentation
|
||||||
|
├── pnpm-workspace.yaml
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── tsconfig.base.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stand dieses Commits
|
||||||
|
|
||||||
|
Dieser erste Commit liefert ein lauffähiges Grundgerüst:
|
||||||
|
|
||||||
|
- ✅ pnpm-Monorepo mit `apps/*` und `packages/*`
|
||||||
|
- ✅ Fastify-Backend mit `/api/health`-Endpunkt
|
||||||
|
- ✅ SQLite-Datenbank (better-sqlite3) inkl. Schema für `devices`, `services`, `categories`
|
||||||
|
(Drizzle ORM), automatisch angelegt beim Start
|
||||||
|
- ✅ React-Startseite mit Suchfeld, Dark-/Light-Mode und Live-Statusanzeige des Backends
|
||||||
|
- ✅ Tastaturkürzel `/` und `Strg+K` zum Fokussieren der Suche
|
||||||
|
- ✅ Docker-Compose-Setup: `docker compose up -d --build` startet Frontend + Backend
|
||||||
|
- ✅ Persistentes Docker-Volume für die SQLite-Datenbank
|
||||||
|
- ✅ REST-API für Geräte & Dienste (`/api/devices`, `/api/services`), Zod-validiert,
|
||||||
|
mit Repository-Layer über Drizzle (siehe `apps/backend/src/db/repositories`)
|
||||||
|
- ✅ Suche im Frontend gegen echte Backend-Daten (TanStack Query), inkl. Ranking-Logik
|
||||||
|
aus `packages/shared` und Tastatur-Navigation (Pfeiltasten, Enter, Escape)
|
||||||
|
- ✅ Kategorien-API (`/api/categories`), inkl. Umbenennen, Löschen (Dienste behalten
|
||||||
|
ihre Zuordnung nicht, werden aber nicht gelöscht) und Bulk-Reorder für Drag & Drop
|
||||||
|
- ✅ Favoriten-Toggle direkt in der Trefferliste (Stern anklicken)
|
||||||
|
- ✅ Scanner-Engine + API (`POST /api/scan/devices/:id`, `POST /api/scan/fritzbox`):
|
||||||
|
DNS-Kandidaten (hostname/.home/.local), Portscan (80, 443 + typische Ports),
|
||||||
|
Titel-/Favicon-Auslesen, Softwareerkennung, FritzBox-Geräteliste per TR-064
|
||||||
|
(HTTP-Digest-Auth). Läuft ausschließlich manuell per API-Aufruf – nie automatisch.
|
||||||
|
Benutzeränderungen an Diensten (Name, Kategorie, Favorit, Alias, Icon, Reihenfolge)
|
||||||
|
bleiben bei erneuten Scans garantiert erhalten.
|
||||||
|
- ✅ Adminbereich unter `/admin` (TanStack Router): Dashboard, Geräte (inkl.
|
||||||
|
„Jetzt scannen"-Button), Dienste (Inline-Bearbeitung), Kategorien (natives
|
||||||
|
Drag & Drop), Scanner (FritzBox-Trigger + Sammel-Scan), Logs (Scan-Historie),
|
||||||
|
Einstellungen (Live-Systeminfo + Theme), Plugins (ehrlicher Hinweis auf
|
||||||
|
zukünftigen Commit)
|
||||||
|
- ✅ PWA: installierbar (Manifest + Icons für Android/iOS/Desktop), Service
|
||||||
|
Worker mit App-Shell-Precaching, `/api/*` läuft offline über den letzten
|
||||||
|
Cache-Stand (NetworkFirst, 3s-Timeout)
|
||||||
|
|
||||||
|
Noch **nicht** enthalten (folgt in den nächsten Commits):
|
||||||
|
|
||||||
|
- Plugin-System (Scanner registrieren, Geräte importieren, Menüs erweitern, Icons)
|
||||||
|
- shadcn/ui, React Hook Form (aktuell einfache kontrollierte Formulare)
|
||||||
|
|
||||||
|
### Umgebungsvariablen (.env)
|
||||||
|
|
||||||
|
Alle Umgebungsvariablen sind in `.env.example` dokumentiert. Für die lokale
|
||||||
|
Entwicklung und für Docker Compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# .env anpassen (v. a. FRITZBOX_* für den FritzBox-Scan)
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env` ist in `.gitignore` und wird nie committet. **Auf dem Server
|
||||||
|
(`/opt/LaunchPad`) muss sie nach dem ersten `git pull` einmalig manuell
|
||||||
|
angelegt werden**, da git-ignorierte Dateien nicht mitgepullt werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/LaunchPad
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env # FRITZBOX_* eintragen
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Fehlt `.env` komplett, startet alles trotzdem mit den in `docker-compose.yml`
|
||||||
|
hinterlegten Defaults – der FritzBox-Scan liefert dann kontrolliert `HTTP 400`
|
||||||
|
statt abzustürzen. Für `pnpm dev:backend` (ohne Docker) wird `.env` automatisch
|
||||||
|
über `dotenv` geladen (`apps/backend/src/env.ts`, wird als allererstes importiert).
|
||||||
|
|
||||||
|
Siehe [`docs/ROADMAP.md`](./docs/ROADMAP.md) für die geplante Reihenfolge.
|
||||||
|
|
||||||
|
## API-Endpunkte (Stand Commit 2)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/health
|
||||||
|
GET /api/devices Liste aller Geräte inkl. ihrer Dienste
|
||||||
|
GET /api/devices/:id
|
||||||
|
POST /api/devices
|
||||||
|
PATCH /api/devices/:id
|
||||||
|
DELETE /api/devices/:id (löscht zugehörige Dienste per Cascade)
|
||||||
|
|
||||||
|
GET /api/services optional ?deviceId=&category=&favorite=true
|
||||||
|
GET /api/services/:id
|
||||||
|
POST /api/services erfordert existierende deviceId
|
||||||
|
PATCH /api/services/:id
|
||||||
|
DELETE /api/services/:id
|
||||||
|
|
||||||
|
GET /api/categories
|
||||||
|
POST /api/categories
|
||||||
|
PATCH /api/categories/reorder Body: [{ id, order }, ...]
|
||||||
|
PATCH /api/categories/:id Umbenennen
|
||||||
|
DELETE /api/categories/:id Dienste behalten ihre category nicht mehr (null),
|
||||||
|
werden aber nicht gelöscht
|
||||||
|
|
||||||
|
POST /api/scan/devices/:id Netzwerk-Scan für ein Gerät (DNS, Ports, Titel,
|
||||||
|
Favicon, Softwareerkennung); legt/aktualisiert Dienste
|
||||||
|
POST /api/scan/fritzbox Liest Geräteliste der FritzBox per TR-064
|
||||||
|
(erfordert FRITZBOX_HOST/USERNAME/PASSWORD)
|
||||||
|
|
||||||
|
GET /api/logs optional ?limit= (Default 100, Max 500)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend-Routen
|
||||||
|
|
||||||
|
```
|
||||||
|
/ Startseite: minimalistische Suche
|
||||||
|
/admin -> redirect zu /admin/dashboard
|
||||||
|
/admin/dashboard
|
||||||
|
/admin/devices
|
||||||
|
/admin/services
|
||||||
|
/admin/categories
|
||||||
|
/admin/scanner
|
||||||
|
/admin/plugins Hinweis: Plugin-System noch nicht gebaut
|
||||||
|
/admin/settings
|
||||||
|
/admin/logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment auf dem Server (xlc-launchpad)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/LaunchPad
|
||||||
|
git pull
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Branching
|
||||||
|
|
||||||
|
Entwicklung erfolgt ausschließlich auf `dev`. `main` bleibt der stabile Branch.
|
||||||
43
apps/backend/Dockerfile
Normal file
43
apps/backend/Dockerfile
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:20-alpine AS base
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# ---- deps: gesamten Workspace-Kontext installieren -------------------------
|
||||||
|
FROM base AS deps
|
||||||
|
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* tsconfig.base.json ./
|
||||||
|
COPY packages/shared/package.json packages/shared/package.json
|
||||||
|
COPY packages/ui/package.json packages/ui/package.json
|
||||||
|
COPY apps/backend/package.json apps/backend/package.json
|
||||||
|
COPY apps/frontend/package.json apps/frontend/package.json
|
||||||
|
# better-sqlite3 braucht Build-Tools für den nativen Modulbau
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
RUN pnpm install --frozen-lockfile || pnpm install
|
||||||
|
|
||||||
|
# ---- build: Backend + Shared bauen ------------------------------------------
|
||||||
|
FROM deps AS build
|
||||||
|
COPY packages/shared packages/shared
|
||||||
|
COPY apps/backend apps/backend
|
||||||
|
RUN pnpm --filter @launchpad/shared build
|
||||||
|
RUN pnpm --filter @launchpad/backend build
|
||||||
|
# nur Production-Dependencies fürs Runtime-Image behalten
|
||||||
|
RUN pnpm deploy --filter @launchpad/backend --prod /app/deploy
|
||||||
|
|
||||||
|
# ---- runtime: schlankes Laufzeit-Image --------------------------------------
|
||||||
|
FROM node:20-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV DATABASE_PATH=/data/launchpad.db
|
||||||
|
ENV PORT=3001
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
|
||||||
|
COPY --from=build /app/deploy/ ./
|
||||||
|
COPY --from=build /app/apps/backend/dist ./dist
|
||||||
|
COPY --from=build /app/packages/shared/dist ./node_modules/@launchpad/shared/dist
|
||||||
|
|
||||||
|
RUN mkdir -p /data
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
10
apps/backend/drizzle.config.ts
Normal file
10
apps/backend/drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "./src/db/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dialect: "sqlite",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_PATH ?? "./data/launchpad.db",
|
||||||
|
},
|
||||||
|
});
|
||||||
30
apps/backend/package.json
Normal file
30
apps/backend/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@launchpad/backend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:push": "drizzle-kit push"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@launchpad/shared": "workspace:*",
|
||||||
|
"@fastify/cors": "^9.0.1",
|
||||||
|
"better-sqlite3": "^11.3.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"drizzle-orm": "^0.33.0",
|
||||||
|
"fastify": "^4.28.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
|
"@types/node": "^20.14.15",
|
||||||
|
"drizzle-kit": "^0.24.2",
|
||||||
|
"pino-pretty": "^11.2.2",
|
||||||
|
"tsx": "^4.19.1",
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
79
apps/backend/src/db/client.ts
Normal file
79
apps/backend/src/db/client.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { existsSync, mkdirSync } from "node:fs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||||
|
import * as schema from "./schema.js";
|
||||||
|
|
||||||
|
const databasePath = process.env.DATABASE_PATH ?? "./data/launchpad.db";
|
||||||
|
|
||||||
|
// Stellt sicher, dass das Verzeichnis für die SQLite-Datei existiert
|
||||||
|
// (relevant im Docker-Volume /data).
|
||||||
|
const dir = dirname(databasePath);
|
||||||
|
if (dir !== "." && !existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlite = new Database(databasePath);
|
||||||
|
sqlite.pragma("journal_mode = WAL");
|
||||||
|
sqlite.pragma("foreign_keys = ON");
|
||||||
|
|
||||||
|
export const db = drizzle(sqlite, { schema });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt die Tabellen an, falls sie noch nicht existieren.
|
||||||
|
* Für den ersten Commit reicht ein einfaches "CREATE TABLE IF NOT EXISTS",
|
||||||
|
* spätere Commits ersetzen dies durch echte Drizzle-Migrationen (drizzle-kit).
|
||||||
|
*/
|
||||||
|
export function ensureSchema(): void {
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
mac TEXT,
|
||||||
|
manufacturer TEXT,
|
||||||
|
model TEXT,
|
||||||
|
online INTEGER NOT NULL DEFAULT 0,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
last_scan TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS services (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
category TEXT,
|
||||||
|
favorite INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"order" REAL NOT NULL DEFAULT 0,
|
||||||
|
alias TEXT NOT NULL DEFAULT '[]',
|
||||||
|
icon TEXT,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
https INTEGER NOT NULL DEFAULT 0,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
favicon TEXT,
|
||||||
|
description TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
"order" REAL NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scan_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
target_id TEXT,
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
101
apps/backend/src/db/repositories/categories.ts
Normal file
101
apps/backend/src/db/repositories/categories.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type {
|
||||||
|
Category,
|
||||||
|
CategoryCreateInput,
|
||||||
|
CategoryReorderInput,
|
||||||
|
CategoryUpdateInput,
|
||||||
|
} from "@launchpad/shared";
|
||||||
|
import { db } from "../client.js";
|
||||||
|
import { categories, services } from "../schema.js";
|
||||||
|
|
||||||
|
function nowIso(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRow(row: typeof categories.$inferSelect): Category {
|
||||||
|
return { id: row.id, name: row.name, order: row.order };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCategories(): Category[] {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(categories)
|
||||||
|
.all()
|
||||||
|
.map(mapRow)
|
||||||
|
.sort((a, b) => a.order - b.order);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCategory(id: string): Category | null {
|
||||||
|
const row = db.select().from(categories).where(eq(categories.id, id)).get();
|
||||||
|
return row ? mapRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCategory(input: CategoryCreateInput): Category {
|
||||||
|
const id = randomUUID();
|
||||||
|
const timestamp = nowIso();
|
||||||
|
const existing = listCategories();
|
||||||
|
const nextOrder = existing.length > 0 ? Math.max(...existing.map((c) => c.order)) + 1 : 0;
|
||||||
|
|
||||||
|
db.insert(categories)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
name: input.name,
|
||||||
|
order: nextOrder,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return getCategory(id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCategory(id: string, input: CategoryUpdateInput): Category | null {
|
||||||
|
const existing = getCategory(id);
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
db.update(categories)
|
||||||
|
.set({
|
||||||
|
...(input.name !== undefined && { name: input.name }),
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
})
|
||||||
|
.where(eq(categories.id, id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return getCategory(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die Reihenfolge mehrerer Kategorien in einer Transaktion neu,
|
||||||
|
* z. B. nach Drag & Drop in der Admin-UI (siehe Commit 6).
|
||||||
|
*/
|
||||||
|
export function reorderCategories(input: CategoryReorderInput): Category[] {
|
||||||
|
db.transaction((tx) => {
|
||||||
|
for (const entry of input) {
|
||||||
|
tx.update(categories)
|
||||||
|
.set({ order: entry.order, updatedAt: nowIso() })
|
||||||
|
.where(eq(categories.id, entry.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return listCategories();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht eine Kategorie. Dienste, die dieser Kategorie zugeordnet waren,
|
||||||
|
* verlieren ihre Kategoriezuordnung (category wird auf null gesetzt),
|
||||||
|
* werden aber nicht selbst gelöscht.
|
||||||
|
*/
|
||||||
|
export function deleteCategory(id: string): boolean {
|
||||||
|
const category = getCategory(id);
|
||||||
|
if (!category) return false;
|
||||||
|
|
||||||
|
db.update(services)
|
||||||
|
.set({ category: null, updatedAt: nowIso() })
|
||||||
|
.where(eq(services.category, category.name))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const result = db.delete(categories).where(eq(categories.id, id)).run();
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
152
apps/backend/src/db/repositories/devices.ts
Normal file
152
apps/backend/src/db/repositories/devices.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Device, DeviceCreateInput, DeviceUpdateInput } from "@launchpad/shared";
|
||||||
|
import { db } from "../client.js";
|
||||||
|
import { devices } from "../schema.js";
|
||||||
|
|
||||||
|
function nowIso(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRow(row: typeof devices.$inferSelect): Device {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
hostname: row.hostname,
|
||||||
|
ip: row.ip,
|
||||||
|
mac: row.mac,
|
||||||
|
manufacturer: row.manufacturer,
|
||||||
|
model: row.model,
|
||||||
|
online: row.online,
|
||||||
|
source: row.source as Device["source"],
|
||||||
|
lastScan: row.lastScan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDevices(): Device[] {
|
||||||
|
return db.select().from(devices).all().map(mapRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDevice(id: string): Device | null {
|
||||||
|
const row = db.select().from(devices).where(eq(devices.id, id)).get();
|
||||||
|
return row ? mapRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDevice(input: DeviceCreateInput): Device {
|
||||||
|
const id = randomUUID();
|
||||||
|
const timestamp = nowIso();
|
||||||
|
|
||||||
|
db.insert(devices)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
hostname: input.hostname,
|
||||||
|
ip: input.ip,
|
||||||
|
mac: input.mac ?? null,
|
||||||
|
manufacturer: input.manufacturer ?? null,
|
||||||
|
model: input.model ?? null,
|
||||||
|
online: input.online ?? false,
|
||||||
|
source: input.source ?? "manual",
|
||||||
|
lastScan: null,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return getDevice(id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert ein Gerät anhand von Benutzereingaben (z. B. über die Admin-UI).
|
||||||
|
* Für automatische Scan-Ergebnisse siehe upsertDeviceFromScan() unten.
|
||||||
|
*/
|
||||||
|
export function updateDevice(id: string, input: DeviceUpdateInput): Device | null {
|
||||||
|
const existing = getDevice(id);
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
...(input.hostname !== undefined && { hostname: input.hostname }),
|
||||||
|
...(input.ip !== undefined && { ip: input.ip }),
|
||||||
|
...(input.mac !== undefined && { mac: input.mac }),
|
||||||
|
...(input.manufacturer !== undefined && { manufacturer: input.manufacturer }),
|
||||||
|
...(input.model !== undefined && { model: input.model }),
|
||||||
|
...(input.online !== undefined && { online: input.online }),
|
||||||
|
...(input.source !== undefined && { source: input.source }),
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return getDevice(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteDevice(id: string): boolean {
|
||||||
|
const result = db.delete(devices).where(eq(devices.id, id)).run();
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceScanInput {
|
||||||
|
hostname: string;
|
||||||
|
ip: string;
|
||||||
|
mac?: string | null;
|
||||||
|
manufacturer?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
online?: boolean;
|
||||||
|
source: Device["source"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findByMacOrIp(mac: string | null | undefined, ip: string): Device | null {
|
||||||
|
const all = listDevices();
|
||||||
|
if (mac) {
|
||||||
|
const byMac = all.find((d) => d.mac && d.mac.toLowerCase() === mac.toLowerCase());
|
||||||
|
if (byMac) return byMac;
|
||||||
|
}
|
||||||
|
return all.find((d) => d.ip === ip) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt ein per Scan gefundenes Gerät an oder aktualisiert ein bestehendes
|
||||||
|
* (abgeglichen über MAC, sonst IP). Geräte haben keine "Benutzerfelder" im
|
||||||
|
* Sinne der Spezifikation (die betrifft nur Dienste) – Scans dürfen hier
|
||||||
|
* alle Felder aktualisieren.
|
||||||
|
*/
|
||||||
|
export function upsertDeviceFromScan(input: DeviceScanInput): Device {
|
||||||
|
const existing = findByMacOrIp(input.mac, input.ip);
|
||||||
|
const timestamp = nowIso();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
hostname: input.hostname,
|
||||||
|
ip: input.ip,
|
||||||
|
mac: input.mac ?? existing.mac,
|
||||||
|
manufacturer: input.manufacturer ?? existing.manufacturer,
|
||||||
|
model: input.model ?? existing.model,
|
||||||
|
online: input.online ?? existing.online,
|
||||||
|
source: input.source,
|
||||||
|
lastScan: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, existing.id))
|
||||||
|
.run();
|
||||||
|
return getDevice(existing.id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
db.insert(devices)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
hostname: input.hostname,
|
||||||
|
ip: input.ip,
|
||||||
|
mac: input.mac ?? null,
|
||||||
|
manufacturer: input.manufacturer ?? null,
|
||||||
|
model: input.model ?? null,
|
||||||
|
online: input.online ?? false,
|
||||||
|
source: input.source,
|
||||||
|
lastScan: timestamp,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return getDevice(id)!;
|
||||||
|
}
|
||||||
52
apps/backend/src/db/repositories/logs.ts
Normal file
52
apps/backend/src/db/repositories/logs.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { desc } from "drizzle-orm";
|
||||||
|
import type { ScanLogEntry } from "@launchpad/shared";
|
||||||
|
import { db } from "../client.js";
|
||||||
|
import { scanLogs } from "../schema.js";
|
||||||
|
|
||||||
|
function mapRow(row: typeof scanLogs.$inferSelect): ScanLogEntry {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
type: row.type as ScanLogEntry["type"],
|
||||||
|
targetId: row.targetId,
|
||||||
|
level: row.level as ScanLogEntry["level"],
|
||||||
|
message: row.message,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogScanInput {
|
||||||
|
type: ScanLogEntry["type"];
|
||||||
|
targetId?: string | null;
|
||||||
|
level: ScanLogEntry["level"];
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schreibt einen Eintrag für einen Scan-Versuch (Erfolg oder Fehler). */
|
||||||
|
export function logScan(input: LogScanInput): ScanLogEntry {
|
||||||
|
const id = randomUUID();
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
|
||||||
|
db.insert(scanLogs)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
type: input.type,
|
||||||
|
targetId: input.targetId ?? null,
|
||||||
|
level: input.level,
|
||||||
|
message: input.message,
|
||||||
|
createdAt,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return { id, type: input.type, targetId: input.targetId ?? null, level: input.level, message: input.message, createdAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listLogs(limit = 100): ScanLogEntry[] {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(scanLogs)
|
||||||
|
.orderBy(desc(scanLogs.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
.map(mapRow);
|
||||||
|
}
|
||||||
196
apps/backend/src/db/repositories/services.ts
Normal file
196
apps/backend/src/db/repositories/services.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Service, ServiceCreateInput, ServiceUpdateInput } from "@launchpad/shared";
|
||||||
|
import { db } from "../client.js";
|
||||||
|
import { services } from "../schema.js";
|
||||||
|
|
||||||
|
function nowIso(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRow(row: typeof services.$inferSelect): Service {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
deviceId: row.deviceId,
|
||||||
|
displayName: row.displayName,
|
||||||
|
hostname: row.hostname,
|
||||||
|
url: row.url,
|
||||||
|
https: row.https,
|
||||||
|
port: row.port,
|
||||||
|
category: row.category,
|
||||||
|
icon: row.icon,
|
||||||
|
favicon: row.favicon,
|
||||||
|
description: row.description,
|
||||||
|
favorite: row.favorite,
|
||||||
|
alias: JSON.parse(row.alias) as string[],
|
||||||
|
order: row.order,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceFilter {
|
||||||
|
deviceId?: string;
|
||||||
|
category?: string;
|
||||||
|
favorite?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listServices(filter: ServiceFilter = {}): Service[] {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(services)
|
||||||
|
.all()
|
||||||
|
.map(mapRow)
|
||||||
|
.filter((service) => {
|
||||||
|
if (filter.deviceId && service.deviceId !== filter.deviceId) return false;
|
||||||
|
if (filter.category && service.category !== filter.category) return false;
|
||||||
|
if (filter.favorite !== undefined && service.favorite !== filter.favorite) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listServicesByDevice(deviceId: string): Service[] {
|
||||||
|
return listServices({ deviceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getService(id: string): Service | null {
|
||||||
|
const row = db.select().from(services).where(eq(services.id, id)).get();
|
||||||
|
return row ? mapRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createService(input: ServiceCreateInput): Service {
|
||||||
|
const id = randomUUID();
|
||||||
|
const timestamp = nowIso();
|
||||||
|
|
||||||
|
db.insert(services)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
deviceId: input.deviceId,
|
||||||
|
displayName: input.displayName,
|
||||||
|
hostname: input.hostname,
|
||||||
|
url: input.url,
|
||||||
|
https: input.https ?? false,
|
||||||
|
port: input.port,
|
||||||
|
category: input.category ?? null,
|
||||||
|
icon: input.icon ?? null,
|
||||||
|
favicon: input.favicon ?? null,
|
||||||
|
description: input.description ?? null,
|
||||||
|
favorite: input.favorite ?? false,
|
||||||
|
alias: JSON.stringify(input.alias ?? []),
|
||||||
|
order: input.order ?? 0,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return getService(id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert einen Dienst anhand von Benutzereingaben (z. B. über die Admin-UI).
|
||||||
|
* Für automatische Scan-Ergebnisse siehe upsertServiceFromScan() unten – die
|
||||||
|
* NIEMALS displayName, category, favorite, order, alias oder icon überschreibt.
|
||||||
|
*/
|
||||||
|
export function updateService(id: string, input: ServiceUpdateInput): Service | null {
|
||||||
|
const existing = getService(id);
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
db.update(services)
|
||||||
|
.set({
|
||||||
|
...(input.displayName !== undefined && { displayName: input.displayName }),
|
||||||
|
...(input.hostname !== undefined && { hostname: input.hostname }),
|
||||||
|
...(input.url !== undefined && { url: input.url }),
|
||||||
|
...(input.https !== undefined && { https: input.https }),
|
||||||
|
...(input.port !== undefined && { port: input.port }),
|
||||||
|
...(input.category !== undefined && { category: input.category }),
|
||||||
|
...(input.icon !== undefined && { icon: input.icon }),
|
||||||
|
...(input.favicon !== undefined && { favicon: input.favicon }),
|
||||||
|
...(input.description !== undefined && { description: input.description }),
|
||||||
|
...(input.favorite !== undefined && { favorite: input.favorite }),
|
||||||
|
...(input.alias !== undefined && { alias: JSON.stringify(input.alias) }),
|
||||||
|
...(input.order !== undefined && { order: input.order }),
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
})
|
||||||
|
.where(eq(services.id, id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return getService(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteService(id: string): boolean {
|
||||||
|
const result = db.delete(services).where(eq(services.id, id)).run();
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceScanInput {
|
||||||
|
deviceId: string;
|
||||||
|
hostname: string;
|
||||||
|
url: string;
|
||||||
|
https: boolean;
|
||||||
|
port: number;
|
||||||
|
favicon?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
/** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird. */
|
||||||
|
suggestedDisplayName: string;
|
||||||
|
/** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird. */
|
||||||
|
suggestedCategory?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanUpsertResult {
|
||||||
|
service: Service;
|
||||||
|
created: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findByDeviceAndPort(deviceId: string, port: number): Service | null {
|
||||||
|
return listServicesByDevice(deviceId).find((s) => s.port === port) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt einen per Scan gefundenen Dienst an oder aktualisiert die scan-eigenen
|
||||||
|
* Felder eines bereits bekannten Dienstes (abgeglichen über deviceId + port).
|
||||||
|
*
|
||||||
|
* displayName, category, favorite, order, alias und icon werden bei einem
|
||||||
|
* bestehenden Dienst NIEMALS verändert – nur beim erstmaligen Anlegen dienen
|
||||||
|
* suggestedDisplayName/suggestedCategory als sinnvoller Startwert.
|
||||||
|
*/
|
||||||
|
export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult {
|
||||||
|
const existing = findByDeviceAndPort(input.deviceId, input.port);
|
||||||
|
const timestamp = nowIso();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
db.update(services)
|
||||||
|
.set({
|
||||||
|
hostname: input.hostname,
|
||||||
|
url: input.url,
|
||||||
|
https: input.https,
|
||||||
|
favicon: input.favicon ?? existing.favicon,
|
||||||
|
description: input.description ?? existing.description,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
})
|
||||||
|
.where(eq(services.id, existing.id))
|
||||||
|
.run();
|
||||||
|
return { service: getService(existing.id)!, created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
db.insert(services)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
deviceId: input.deviceId,
|
||||||
|
displayName: input.suggestedDisplayName,
|
||||||
|
category: input.suggestedCategory ?? null,
|
||||||
|
favorite: false,
|
||||||
|
order: 0,
|
||||||
|
alias: "[]",
|
||||||
|
icon: null,
|
||||||
|
hostname: input.hostname,
|
||||||
|
url: input.url,
|
||||||
|
https: input.https,
|
||||||
|
port: input.port,
|
||||||
|
favicon: input.favicon ?? null,
|
||||||
|
description: input.description ?? null,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return { service: getService(id)!, created: true };
|
||||||
|
}
|
||||||
73
apps/backend/src/db/schema.ts
Normal file
73
apps/backend/src/db/schema.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein physisches/logisches Gerät im Netzwerk (z. B. per FritzBox-Scan gefunden).
|
||||||
|
*/
|
||||||
|
export const devices = sqliteTable("devices", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
hostname: text("hostname").notNull(),
|
||||||
|
ip: text("ip").notNull(),
|
||||||
|
mac: text("mac"),
|
||||||
|
manufacturer: text("manufacturer"),
|
||||||
|
model: text("model"),
|
||||||
|
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
||||||
|
source: text("source").notNull(), // fritzbox | dns | http | https | portscan | manual
|
||||||
|
lastScan: text("last_scan"), // ISO-8601
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
updatedAt: text("updated_at").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Dienst, der auf einem Gerät läuft (z. B. Frigate, Portainer, Gitea ...).
|
||||||
|
* Benutzeränderungen (displayName, category, favorite, order, alias, icon)
|
||||||
|
* dürfen von Scans NIEMALS überschrieben werden – siehe README/Spezifikation.
|
||||||
|
*/
|
||||||
|
export const services = sqliteTable("services", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
deviceId: text("device_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => devices.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
// vom Benutzer gepflegt, wird von Scans nicht überschrieben
|
||||||
|
displayName: text("display_name").notNull(),
|
||||||
|
category: text("category"),
|
||||||
|
favorite: integer("favorite", { mode: "boolean" }).notNull().default(false),
|
||||||
|
order: real("order").notNull().default(0),
|
||||||
|
alias: text("alias").notNull().default("[]"), // JSON-Array als String
|
||||||
|
icon: text("icon"),
|
||||||
|
|
||||||
|
// von Scans aktualisierbar
|
||||||
|
hostname: text("hostname").notNull(),
|
||||||
|
url: text("url").notNull(),
|
||||||
|
https: integer("https", { mode: "boolean" }).notNull().default(false),
|
||||||
|
port: integer("port").notNull(),
|
||||||
|
favicon: text("favicon"),
|
||||||
|
description: text("description"),
|
||||||
|
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
updatedAt: text("updated_at").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frei definierbare Kategorien (Erstellen/Löschen/Umbenennen/Sortieren per Drag & Drop).
|
||||||
|
*/
|
||||||
|
export const categories = sqliteTable("categories", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
order: real("order").notNull().default(0),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
updatedAt: text("updated_at").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protokoll jedes Scan-Versuchs (Geräte-Netzwerk-Scan oder FritzBox-Scan),
|
||||||
|
* angezeigt im Adminbereich unter "Logs".
|
||||||
|
*/
|
||||||
|
export const scanLogs = sqliteTable("scan_logs", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
type: text("type").notNull(), // "device" | "fritzbox"
|
||||||
|
targetId: text("target_id"), // Geräte-ID bei type "device", sonst null
|
||||||
|
level: text("level").notNull(), // "info" | "error"
|
||||||
|
message: text("message").notNull(),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
});
|
||||||
11
apps/backend/src/env.ts
Normal file
11
apps/backend/src/env.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { config } from "dotenv";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
|
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// apps/backend/src/env.ts (bzw. apps/backend/dist/env.js) -> Repo-Root liegt
|
||||||
|
// drei Verzeichnisebenen höher. Existiert dort keine .env (z. B. im
|
||||||
|
// Docker-Image), passiert einfach nichts – die Werte kommen dann bereits aus
|
||||||
|
// der Container-Umgebung (docker-compose env_file/environment).
|
||||||
|
config({ path: resolve(currentDir, "../../../.env") });
|
||||||
52
apps/backend/src/index.ts
Normal file
52
apps/backend/src/index.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import "./env.js";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import cors from "@fastify/cors";
|
||||||
|
import { ensureSchema } from "./db/client.js";
|
||||||
|
import { healthRoutes } from "./routes/health.js";
|
||||||
|
import { deviceRoutes } from "./routes/devices.js";
|
||||||
|
import { serviceRoutes } from "./routes/services.js";
|
||||||
|
import { categoryRoutes } from "./routes/categories.js";
|
||||||
|
import { scanRoutes } from "./routes/scan.js";
|
||||||
|
import { logRoutes } from "./routes/logs.js";
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT ?? 3001);
|
||||||
|
const HOST = process.env.HOST ?? "0.0.0.0";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const app = Fastify({
|
||||||
|
logger: {
|
||||||
|
level: process.env.LOG_LEVEL ?? "info",
|
||||||
|
transport:
|
||||||
|
process.env.NODE_ENV !== "production"
|
||||||
|
? { target: "pino-pretty", options: { colorize: true } }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(cors, {
|
||||||
|
origin: process.env.CORS_ORIGIN ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
ensureSchema();
|
||||||
|
|
||||||
|
await app.register(healthRoutes);
|
||||||
|
await app.register(deviceRoutes);
|
||||||
|
await app.register(serviceRoutes);
|
||||||
|
await app.register(categoryRoutes);
|
||||||
|
await app.register(scanRoutes);
|
||||||
|
await app.register(logRoutes);
|
||||||
|
|
||||||
|
app.get("/", async () => {
|
||||||
|
return { name: "LaunchPad API", status: "running" };
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.listen({ port: PORT, host: HOST });
|
||||||
|
app.log.info(`LaunchPad backend läuft auf http://${HOST}:${PORT}`);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
53
apps/backend/src/routes/categories.ts
Normal file
53
apps/backend/src/routes/categories.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import {
|
||||||
|
CategoryCreateSchema,
|
||||||
|
CategoryReorderSchema,
|
||||||
|
CategoryUpdateSchema,
|
||||||
|
} from "@launchpad/shared";
|
||||||
|
import * as categoryRepo from "../db/repositories/categories.js";
|
||||||
|
|
||||||
|
export async function categoryRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/api/categories", async () => {
|
||||||
|
return categoryRepo.listCategories();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/categories", async (request, reply) => {
|
||||||
|
const parsed = CategoryCreateSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
const category = categoryRepo.createCategory(parsed.data);
|
||||||
|
return reply.code(201).send(category);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Muss vor der /:id-Route registriert sein, damit "reorder" nicht als ID interpretiert wird.
|
||||||
|
app.patch("/api/categories/reorder", async (request, reply) => {
|
||||||
|
const parsed = CategoryReorderSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
return categoryRepo.reorderCategories(parsed.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/categories/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const parsed = CategoryUpdateSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
const category = categoryRepo.updateCategory(id, parsed.data);
|
||||||
|
if (!category) {
|
||||||
|
return reply.code(404).send({ error: "Kategorie nicht gefunden" });
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/categories/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const deleted = categoryRepo.deleteCategory(id);
|
||||||
|
if (!deleted) {
|
||||||
|
return reply.code(404).send({ error: "Kategorie nicht gefunden" });
|
||||||
|
}
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
53
apps/backend/src/routes/devices.ts
Normal file
53
apps/backend/src/routes/devices.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { DeviceCreateSchema, DeviceUpdateSchema } from "@launchpad/shared";
|
||||||
|
import * as deviceRepo from "../db/repositories/devices.js";
|
||||||
|
import * as serviceRepo from "../db/repositories/services.js";
|
||||||
|
|
||||||
|
export async function deviceRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/api/devices", async () => {
|
||||||
|
return deviceRepo.listDevices().map((device) => ({
|
||||||
|
...device,
|
||||||
|
services: serviceRepo.listServicesByDevice(device.id),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/devices/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const device = deviceRepo.getDevice(id);
|
||||||
|
if (!device) {
|
||||||
|
return reply.code(404).send({ error: "Gerät nicht gefunden" });
|
||||||
|
}
|
||||||
|
return { ...device, services: serviceRepo.listServicesByDevice(id) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/devices", async (request, reply) => {
|
||||||
|
const parsed = DeviceCreateSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
const device = deviceRepo.createDevice(parsed.data);
|
||||||
|
return reply.code(201).send(device);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/devices/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const parsed = DeviceUpdateSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
const device = deviceRepo.updateDevice(id, parsed.data);
|
||||||
|
if (!device) {
|
||||||
|
return reply.code(404).send({ error: "Gerät nicht gefunden" });
|
||||||
|
}
|
||||||
|
return device;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/devices/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const deleted = deviceRepo.deleteDevice(id);
|
||||||
|
if (!deleted) {
|
||||||
|
return reply.code(404).send({ error: "Gerät nicht gefunden" });
|
||||||
|
}
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
15
apps/backend/src/routes/health.ts
Normal file
15
apps/backend/src/routes/health.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { HealthStatus } from "@launchpad/shared";
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
export async function healthRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/api/health", async (): Promise<HealthStatus> => {
|
||||||
|
return {
|
||||||
|
status: "ok",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptimeSeconds: Math.round((Date.now() - startedAt) / 1000),
|
||||||
|
version: "0.1.0",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
10
apps/backend/src/routes/logs.ts
Normal file
10
apps/backend/src/routes/logs.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import * as logRepo from "../db/repositories/logs.js";
|
||||||
|
|
||||||
|
export async function logRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/api/logs", async (request) => {
|
||||||
|
const query = request.query as { limit?: string };
|
||||||
|
const limit = query.limit ? Math.min(Number(query.limit), 500) : 100;
|
||||||
|
return logRepo.listLogs(limit);
|
||||||
|
});
|
||||||
|
}
|
||||||
126
apps/backend/src/routes/scan.ts
Normal file
126
apps/backend/src/routes/scan.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import * as deviceRepo from "../db/repositories/devices.js";
|
||||||
|
import * as serviceRepo from "../db/repositories/services.js";
|
||||||
|
import * as logRepo from "../db/repositories/logs.js";
|
||||||
|
import { scanDeviceServices } from "../scanner/networkScanner.js";
|
||||||
|
import { fetchFritzBoxHosts } from "../scanner/fritzbox.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan-Endpunkte. Werden ausschließlich manuell per Knopfdruck ("Jetzt
|
||||||
|
* scannen") aus der Admin-UI ausgelöst – es gibt keinerlei automatischen/
|
||||||
|
* zeitgesteuerten Scan. Jeder Versuch (Erfolg oder Fehler) wird in scan_logs
|
||||||
|
* protokolliert und ist unter Admin -> Logs einsehbar.
|
||||||
|
*/
|
||||||
|
export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.post("/api/scan/devices/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const device = deviceRepo.getDevice(id);
|
||||||
|
if (!device) {
|
||||||
|
return reply.code(404).send({ error: "Gerät nicht gefunden" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const discovered = await scanDeviceServices(device);
|
||||||
|
|
||||||
|
const results = discovered.map((found) =>
|
||||||
|
serviceRepo.upsertServiceFromScan({
|
||||||
|
deviceId: device.id,
|
||||||
|
hostname: found.hostname,
|
||||||
|
url: found.url,
|
||||||
|
https: found.https,
|
||||||
|
port: found.port,
|
||||||
|
favicon: found.favicon,
|
||||||
|
description: found.description,
|
||||||
|
suggestedDisplayName: found.suggestedDisplayName,
|
||||||
|
suggestedCategory: found.category,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
deviceRepo.upsertDeviceFromScan({
|
||||||
|
hostname: device.hostname,
|
||||||
|
ip: device.ip,
|
||||||
|
mac: device.mac,
|
||||||
|
manufacturer: device.manufacturer,
|
||||||
|
model: device.model,
|
||||||
|
online: discovered.length > 0,
|
||||||
|
source: device.source,
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = results.filter((r) => r.created).length;
|
||||||
|
const updated = results.filter((r) => !r.created).length;
|
||||||
|
|
||||||
|
logRepo.logScan({
|
||||||
|
type: "device",
|
||||||
|
targetId: device.id,
|
||||||
|
level: "info",
|
||||||
|
message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden, ${created} neu, ${updated} aktualisiert`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceId: device.id,
|
||||||
|
scannedPorts: discovered.length,
|
||||||
|
created,
|
||||||
|
updated,
|
||||||
|
services: results.map((r) => r.service),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const detail = err instanceof Error ? err.message : String(err);
|
||||||
|
logRepo.logScan({
|
||||||
|
type: "device",
|
||||||
|
targetId: device.id,
|
||||||
|
level: "error",
|
||||||
|
message: `${device.hostname} (${device.ip}): Scan fehlgeschlagen – ${detail}`,
|
||||||
|
});
|
||||||
|
request.log.error(err);
|
||||||
|
return reply.code(500).send({ error: "Geräte-Scan fehlgeschlagen", detail });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// FritzBox-Scan: liest die Geräteliste per TR-064 und legt/aktualisiert Geräte.
|
||||||
|
// Erfordert FRITZBOX_HOST / FRITZBOX_USERNAME / FRITZBOX_PASSWORD (optional
|
||||||
|
// FRITZBOX_PORT, Default 49000) als Umgebungsvariablen.
|
||||||
|
app.post("/api/scan/fritzbox", async (request, reply) => {
|
||||||
|
const host = process.env.FRITZBOX_HOST;
|
||||||
|
const username = process.env.FRITZBOX_USERNAME;
|
||||||
|
const password = process.env.FRITZBOX_PASSWORD;
|
||||||
|
|
||||||
|
if (!host || !username || !password) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error:
|
||||||
|
"FritzBox nicht konfiguriert. Bitte FRITZBOX_HOST, FRITZBOX_USERNAME und FRITZBOX_PASSWORD setzen.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = process.env.FRITZBOX_PORT ? Number(process.env.FRITZBOX_PORT) : 49000;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hosts = await fetchFritzBoxHosts({ host, port, username, password });
|
||||||
|
const devices = hosts.map((h) =>
|
||||||
|
deviceRepo.upsertDeviceFromScan({
|
||||||
|
hostname: h.hostname,
|
||||||
|
ip: h.ip,
|
||||||
|
mac: h.mac,
|
||||||
|
online: h.online,
|
||||||
|
source: "fritzbox",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
logRepo.logScan({
|
||||||
|
type: "fritzbox",
|
||||||
|
level: "info",
|
||||||
|
message: `FritzBox-Scan: ${hosts.length} Gerät(e) gefunden`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { found: hosts.length, devices };
|
||||||
|
} catch (err) {
|
||||||
|
const detail = err instanceof Error ? err.message : String(err);
|
||||||
|
logRepo.logScan({
|
||||||
|
type: "fritzbox",
|
||||||
|
level: "error",
|
||||||
|
message: `FritzBox-Scan fehlgeschlagen – ${detail}`,
|
||||||
|
});
|
||||||
|
request.log.error(err);
|
||||||
|
return reply.code(502).send({ error: "FritzBox-Scan fehlgeschlagen", detail });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
67
apps/backend/src/routes/services.ts
Normal file
67
apps/backend/src/routes/services.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { ServiceCreateSchema, ServiceUpdateSchema } from "@launchpad/shared";
|
||||||
|
import * as deviceRepo from "../db/repositories/devices.js";
|
||||||
|
import * as serviceRepo from "../db/repositories/services.js";
|
||||||
|
|
||||||
|
interface ServiceListQuery {
|
||||||
|
deviceId?: string;
|
||||||
|
category?: string;
|
||||||
|
favorite?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/api/services", async (request) => {
|
||||||
|
const query = request.query as ServiceListQuery;
|
||||||
|
return serviceRepo.listServices({
|
||||||
|
deviceId: query.deviceId,
|
||||||
|
category: query.category,
|
||||||
|
favorite: query.favorite === undefined ? undefined : query.favorite === "true",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/services/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const service = serviceRepo.getService(id);
|
||||||
|
if (!service) {
|
||||||
|
return reply.code(404).send({ error: "Dienst nicht gefunden" });
|
||||||
|
}
|
||||||
|
return service;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/services", async (request, reply) => {
|
||||||
|
const parsed = ServiceCreateSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = deviceRepo.getDevice(parsed.data.deviceId);
|
||||||
|
if (!device) {
|
||||||
|
return reply.code(400).send({ error: `Gerät ${parsed.data.deviceId} existiert nicht` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = serviceRepo.createService(parsed.data);
|
||||||
|
return reply.code(201).send(service);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/services/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const parsed = ServiceUpdateSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
const service = serviceRepo.updateService(id, parsed.data);
|
||||||
|
if (!service) {
|
||||||
|
return reply.code(404).send({ error: "Dienst nicht gefunden" });
|
||||||
|
}
|
||||||
|
return service;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/services/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const deleted = serviceRepo.deleteService(id);
|
||||||
|
if (!deleted) {
|
||||||
|
return reply.code(404).send({ error: "Dienst nicht gefunden" });
|
||||||
|
}
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
139
apps/backend/src/scanner/digestAuth.ts
Normal file
139
apps/backend/src/scanner/digestAuth.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import http, { type IncomingHttpHeaders } from "node:http";
|
||||||
|
import https from "node:https";
|
||||||
|
|
||||||
|
export interface DigestAuthOptions {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
https?: boolean;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DigestChallenge {
|
||||||
|
realm: string;
|
||||||
|
nonce: string;
|
||||||
|
qop?: string;
|
||||||
|
opaque?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function md5(input: string): string {
|
||||||
|
return createHash("md5").update(input).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDigestHeader(header: string): DigestChallenge {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
const regex = /(\w+)=("([^"]*)"|[^,]*)/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = regex.exec(header))) {
|
||||||
|
params[match[1]] = match[3] ?? match[2];
|
||||||
|
}
|
||||||
|
return { realm: params.realm, nonce: params.nonce, qop: params.qop, opaque: params.opaque };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawResponse {
|
||||||
|
status: number;
|
||||||
|
headers: IncomingHttpHeaders;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawRequest(
|
||||||
|
options: DigestAuthOptions,
|
||||||
|
path: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
body: string
|
||||||
|
): Promise<RawResponse> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const client = options.https ? https : http;
|
||||||
|
const req = client.request(
|
||||||
|
{
|
||||||
|
host: options.host,
|
||||||
|
port: options.port,
|
||||||
|
path,
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...headers, "Content-Length": Buffer.byteLength(body) },
|
||||||
|
timeout: options.timeoutMs ?? 4000,
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (chunk: Buffer) => (data += chunk.toString("utf-8")));
|
||||||
|
res.on("end", () =>
|
||||||
|
resolve({ status: res.statusCode ?? 0, headers: res.headers, body: data })
|
||||||
|
);
|
||||||
|
res.on("error", reject);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
req.on("timeout", () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error("Zeitüberschreitung bei der Verbindung zur FritzBox"));
|
||||||
|
});
|
||||||
|
req.on("error", reject);
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt einen TR-064-SOAP-Request gegen `/upnp/control/hosts` aus.
|
||||||
|
* Läuft zunächst unauthentifiziert; antwortet die FritzBox mit 401 und einer
|
||||||
|
* WWW-Authenticate-Digest-Challenge, wird automatisch mit korrekt berechnetem
|
||||||
|
* Digest-Response-Header erneut angefragt (RFC 2617).
|
||||||
|
*/
|
||||||
|
export async function soapRequest(
|
||||||
|
options: DigestAuthOptions,
|
||||||
|
soapAction: string,
|
||||||
|
body: string
|
||||||
|
): Promise<string> {
|
||||||
|
const path = "/upnp/control/hosts";
|
||||||
|
const baseHeaders = {
|
||||||
|
"Content-Type": 'text/xml; charset="utf-8"',
|
||||||
|
SOAPACTION: soapAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = await rawRequest(options, path, baseHeaders, body);
|
||||||
|
if (first.status === 200) {
|
||||||
|
return first.body;
|
||||||
|
}
|
||||||
|
if (first.status !== 401) {
|
||||||
|
throw new Error(`Unerwarteter Status von der FritzBox: ${first.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wwwAuth = first.headers["www-authenticate"];
|
||||||
|
if (!wwwAuth) {
|
||||||
|
throw new Error("FritzBox verlangt Authentifizierung, sendet aber keine Digest-Challenge");
|
||||||
|
}
|
||||||
|
const challenge = parseDigestHeader(Array.isArray(wwwAuth) ? wwwAuth[0] : wwwAuth);
|
||||||
|
if (!challenge.realm || !challenge.nonce) {
|
||||||
|
throw new Error("Digest-Challenge der FritzBox konnte nicht gelesen werden");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ha1 = md5(`${options.username}:${challenge.realm}:${options.password}`);
|
||||||
|
const ha2 = md5(`POST:${path}`);
|
||||||
|
const nc = "00000001";
|
||||||
|
const cnonce = randomBytes(8).toString("hex");
|
||||||
|
const response = challenge.qop
|
||||||
|
? md5(`${ha1}:${challenge.nonce}:${nc}:${cnonce}:${challenge.qop}:${ha2}`)
|
||||||
|
: md5(`${ha1}:${challenge.nonce}:${ha2}`);
|
||||||
|
|
||||||
|
const authHeader =
|
||||||
|
`Digest username="${options.username}", realm="${challenge.realm}", ` +
|
||||||
|
`nonce="${challenge.nonce}", uri="${path}", response="${response}"` +
|
||||||
|
(challenge.qop ? `, qop=${challenge.qop}, nc=${nc}, cnonce="${cnonce}"` : "") +
|
||||||
|
(challenge.opaque ? `, opaque="${challenge.opaque}"` : "");
|
||||||
|
|
||||||
|
const second = await rawRequest(
|
||||||
|
options,
|
||||||
|
path,
|
||||||
|
{ ...baseHeaders, Authorization: authHeader },
|
||||||
|
body
|
||||||
|
);
|
||||||
|
|
||||||
|
if (second.status !== 200) {
|
||||||
|
throw new Error(`FritzBox-Authentifizierung fehlgeschlagen (Status ${second.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return second.body;
|
||||||
|
}
|
||||||
25
apps/backend/src/scanner/dns.ts
Normal file
25
apps/backend/src/scanner/dns.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { lookup } from "node:dns/promises";
|
||||||
|
|
||||||
|
export interface DnsResolution {
|
||||||
|
hostname: string;
|
||||||
|
ip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versucht der Spezifikation folgend: hostname, hostname.home, hostname.local.
|
||||||
|
* Gibt die erste erfolgreich aufgelöste Variante zurück, sonst null.
|
||||||
|
*/
|
||||||
|
export async function resolveHostname(shortName: string): Promise<DnsResolution | null> {
|
||||||
|
const candidates = [shortName, `${shortName}.home`, `${shortName}.local`];
|
||||||
|
|
||||||
|
for (const hostname of candidates) {
|
||||||
|
try {
|
||||||
|
const { address } = await lookup(hostname);
|
||||||
|
return { hostname, ip: address };
|
||||||
|
} catch {
|
||||||
|
// nächste Variante versuchen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
66
apps/backend/src/scanner/fritzbox.ts
Normal file
66
apps/backend/src/scanner/fritzbox.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { soapRequest, type DigestAuthOptions } from "./digestAuth.js";
|
||||||
|
|
||||||
|
const SERVICE_TYPE = "urn:dslforum-org:service:Hosts:1";
|
||||||
|
|
||||||
|
function actionEnvelope(action: string, params: Record<string, string | number> = {}): string {
|
||||||
|
const args = Object.entries(params)
|
||||||
|
.map(([key, value]) => `<${key}>${value}</${key}>`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<?xml version="1.0" encoding="utf-8"?>` +
|
||||||
|
`<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" ` +
|
||||||
|
`s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">` +
|
||||||
|
`<s:Body><u:${action} xmlns:u="${SERVICE_TYPE}">${args}</u:${action}></s:Body>` +
|
||||||
|
`</s:Envelope>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTag(xml: string, tag: string): string | null {
|
||||||
|
const match = xml.match(new RegExp(`<${tag}>([^<]*)</${tag}>`, "i"));
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FritzBoxHost {
|
||||||
|
ip: string;
|
||||||
|
mac: string | null;
|
||||||
|
hostname: string;
|
||||||
|
online: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die vollständige Geräteliste der FritzBox über die TR-064-Aktionen
|
||||||
|
* GetHostNumberOfEntries + GetGenericHostEntry (urn:dslforum-org:service:Hosts:1).
|
||||||
|
*/
|
||||||
|
export async function fetchFritzBoxHosts(options: DigestAuthOptions): Promise<FritzBoxHost[]> {
|
||||||
|
const countXml = await soapRequest(
|
||||||
|
options,
|
||||||
|
`${SERVICE_TYPE}#GetHostNumberOfEntries`,
|
||||||
|
actionEnvelope("GetHostNumberOfEntries")
|
||||||
|
);
|
||||||
|
const countStr = extractTag(countXml, "NewHostNumberOfEntries");
|
||||||
|
const count = countStr ? parseInt(countStr, 10) : 0;
|
||||||
|
|
||||||
|
const hosts: FritzBoxHost[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
const entryXml = await soapRequest(
|
||||||
|
options,
|
||||||
|
`${SERVICE_TYPE}#GetGenericHostEntry`,
|
||||||
|
actionEnvelope("GetGenericHostEntry", { NewIndex: index })
|
||||||
|
);
|
||||||
|
|
||||||
|
const ip = extractTag(entryXml, "NewIPAddress");
|
||||||
|
const hostname = extractTag(entryXml, "NewHostName");
|
||||||
|
if (!ip || !hostname) continue; // inaktive/unvollständige Einträge überspringen
|
||||||
|
|
||||||
|
hosts.push({
|
||||||
|
ip,
|
||||||
|
mac: extractTag(entryXml, "NewMACAddress"),
|
||||||
|
hostname,
|
||||||
|
online: extractTag(entryXml, "NewActive") === "1",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return hosts;
|
||||||
|
}
|
||||||
84
apps/backend/src/scanner/http.ts
Normal file
84
apps/backend/src/scanner/http.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
import https from "node:https";
|
||||||
|
|
||||||
|
export interface HttpProbeResult {
|
||||||
|
ok: boolean;
|
||||||
|
status?: number;
|
||||||
|
title?: string;
|
||||||
|
faviconUrl?: string;
|
||||||
|
server?: string;
|
||||||
|
bodySnippet?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 65_536;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruft eine URL ab und liest <title> sowie das Favicon aus dem HTML.
|
||||||
|
* Für HTTPS werden selbstsignierte Zertifikate akzeptiert (rejectUnauthorized:
|
||||||
|
* false) – in Homelabs üblich, es werden keine sensiblen Daten übertragen.
|
||||||
|
*/
|
||||||
|
export function probeHttp(baseUrl: string, timeoutMs = 2000): Promise<HttpProbeResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const isHttps = baseUrl.startsWith("https://");
|
||||||
|
const client = isHttps ? https : http;
|
||||||
|
|
||||||
|
const req = client.get(
|
||||||
|
baseUrl,
|
||||||
|
{
|
||||||
|
timeout: timeoutMs,
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let body = "";
|
||||||
|
let received = 0;
|
||||||
|
|
||||||
|
res.on("data", (chunk: Buffer) => {
|
||||||
|
received += chunk.length;
|
||||||
|
if (received <= MAX_BODY_BYTES) {
|
||||||
|
body += chunk.toString("utf-8");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on("end", () => {
|
||||||
|
const serverHeader = res.headers.server;
|
||||||
|
resolve({
|
||||||
|
ok: (res.statusCode ?? 0) < 400,
|
||||||
|
status: res.statusCode,
|
||||||
|
title: extractTitle(body),
|
||||||
|
faviconUrl: extractFaviconUrl(body, baseUrl),
|
||||||
|
server: Array.isArray(serverHeader) ? serverHeader[0] : serverHeader,
|
||||||
|
bodySnippet: body,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on("error", () => resolve({ ok: false }));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
req.on("timeout", () => {
|
||||||
|
req.destroy();
|
||||||
|
resolve({ ok: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("error", () => resolve({ ok: false }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTitle(html: string): string | undefined {
|
||||||
|
const match = html.match(/<title[^>]*>([^<]*)<\/title>/i);
|
||||||
|
const title = match?.[1]?.trim();
|
||||||
|
return title ? title : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFaviconUrl(html: string, baseUrl: string): string | undefined {
|
||||||
|
const match = html.match(
|
||||||
|
/<link[^>]+rel=["'](?:shortcut icon|icon)["'][^>]*href=["']([^"']+)["']/i
|
||||||
|
);
|
||||||
|
const href = match?.[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(href ?? "/favicon.ico", baseUrl).toString();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
apps/backend/src/scanner/networkScanner.ts
Normal file
66
apps/backend/src/scanner/networkScanner.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { resolveHostname } from "./dns.js";
|
||||||
|
import { isPortOpen, TYPICAL_PORTS } from "./ports.js";
|
||||||
|
import { probeHttp } from "./http.js";
|
||||||
|
import { detectSoftware } from "./softwareDetection.js";
|
||||||
|
|
||||||
|
export interface ScanTarget {
|
||||||
|
hostname: string;
|
||||||
|
ip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoveredService {
|
||||||
|
hostname: string;
|
||||||
|
url: string;
|
||||||
|
https: boolean;
|
||||||
|
port: number;
|
||||||
|
favicon?: string;
|
||||||
|
description?: string;
|
||||||
|
suggestedDisplayName: string;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scannt ein einzelnes Gerät: versucht zuerst eine schönere DNS-Adresse
|
||||||
|
* (hostname / hostname.home / hostname.local) aufzulösen, prüft dann Port 80,
|
||||||
|
* 443 sowie die typischen Ports, liest bei offenen Ports Titel + Favicon aus
|
||||||
|
* und versucht die Software zu erkennen.
|
||||||
|
*/
|
||||||
|
export async function scanDeviceServices(
|
||||||
|
device: ScanTarget,
|
||||||
|
extraPorts: number[] = TYPICAL_PORTS
|
||||||
|
): Promise<DiscoveredService[]> {
|
||||||
|
const dnsResult = await resolveHostname(device.hostname);
|
||||||
|
// Fällt auf die IP zurück, falls keine DNS-Variante auflösbar ist.
|
||||||
|
const address = dnsResult?.hostname ?? device.ip;
|
||||||
|
|
||||||
|
const candidatePorts = Array.from(new Set([80, 443, ...extraPorts]));
|
||||||
|
const found: DiscoveredService[] = [];
|
||||||
|
|
||||||
|
for (const port of candidatePorts) {
|
||||||
|
const open = await isPortOpen(device.ip, port);
|
||||||
|
if (!open) continue;
|
||||||
|
|
||||||
|
const isHttps = port === 443 || port === 9443;
|
||||||
|
const baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
|
||||||
|
const probe = await probeHttp(baseUrl);
|
||||||
|
|
||||||
|
const software = detectSoftware({
|
||||||
|
server: probe.server,
|
||||||
|
body: probe.bodySnippet,
|
||||||
|
port,
|
||||||
|
});
|
||||||
|
|
||||||
|
found.push({
|
||||||
|
hostname: address,
|
||||||
|
url: baseUrl,
|
||||||
|
https: isHttps,
|
||||||
|
port,
|
||||||
|
favicon: probe.faviconUrl,
|
||||||
|
description: software ? `${software.name} (automatisch erkannt)` : probe.title,
|
||||||
|
suggestedDisplayName: software?.name ?? probe.title ?? `${address}:${port}`,
|
||||||
|
category: software?.category,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
26
apps/backend/src/scanner/ports.ts
Normal file
26
apps/backend/src/scanner/ports.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { connect } from "node:net";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typische Ports für den optionalen Portscanner, gemäß Spezifikation.
|
||||||
|
*/
|
||||||
|
export const TYPICAL_PORTS = [80, 443, 3000, 3001, 5000, 5001, 8080, 8123, 9000, 9443];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft per TCP-Connect, ob ein Port offen ist. Kein Protokoll-Handshake,
|
||||||
|
* nur "kann eine Verbindung aufgebaut werden" – schnell und protokollunabhängig.
|
||||||
|
*/
|
||||||
|
export function isPortOpen(host: string, port: number, timeoutMs = 800): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = connect({ host, port, timeout: timeoutMs });
|
||||||
|
|
||||||
|
const finish = (result: boolean) => {
|
||||||
|
socket.removeAllListeners();
|
||||||
|
socket.destroy();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.once("connect", () => finish(true));
|
||||||
|
socket.once("timeout", () => finish(false));
|
||||||
|
socket.once("error", () => finish(false));
|
||||||
|
});
|
||||||
|
}
|
||||||
49
apps/backend/src/scanner/softwareDetection.ts
Normal file
49
apps/backend/src/scanner/softwareDetection.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
export interface SoftwareSignatureInput {
|
||||||
|
server?: string;
|
||||||
|
body?: string;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SoftwareSignature {
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
matches: (input: SoftwareSignatureInput) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyContains(input: SoftwareSignatureInput, pattern: RegExp): boolean {
|
||||||
|
return !!input.body && pattern.test(input.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatische Softwareerkennung anhand von HTTP-Response-Merkmalen
|
||||||
|
* (Server-Header, HTML-Inhalt). Liste gemäß Spezifikation.
|
||||||
|
*/
|
||||||
|
export const SOFTWARE_SIGNATURES: SoftwareSignature[] = [
|
||||||
|
{ name: "Home Assistant", category: "Smart Home", matches: (i) => bodyContains(i, /home\s*assistant/i) },
|
||||||
|
{
|
||||||
|
name: "Synology DSM",
|
||||||
|
category: "NAS",
|
||||||
|
matches: (i) => bodyContains(i, /synology/i) || (!!i.server && /synology/i.test(i.server)),
|
||||||
|
},
|
||||||
|
{ name: "Frigate", category: "Überwachung", matches: (i) => bodyContains(i, /frigate/i) },
|
||||||
|
{ name: "Portainer", category: "Container", matches: (i) => bodyContains(i, /portainer/i) },
|
||||||
|
{ name: "Grafana", category: "Monitoring", matches: (i) => bodyContains(i, /grafana/i) },
|
||||||
|
{ name: "Proxmox VE", category: "Virtualisierung", matches: (i) => bodyContains(i, /proxmox/i) },
|
||||||
|
{ name: "Immich", category: "Fotos", matches: (i) => bodyContains(i, /immich/i) },
|
||||||
|
{ name: "Paperless-ngx", category: "Dokumente", matches: (i) => bodyContains(i, /paperless/i) },
|
||||||
|
{ name: "Gitea", category: "Entwicklung", matches: (i) => bodyContains(i, /gitea/i) },
|
||||||
|
{
|
||||||
|
name: "Vaultwarden",
|
||||||
|
category: "Passwörter",
|
||||||
|
matches: (i) => bodyContains(i, /vaultwarden|bitwarden/i),
|
||||||
|
},
|
||||||
|
{ name: "Jellyfin", category: "Medien", matches: (i) => bodyContains(i, /jellyfin/i) },
|
||||||
|
{ name: "Nextcloud", category: "Cloud", matches: (i) => bodyContains(i, /nextcloud/i) },
|
||||||
|
{ name: "Pi-hole", category: "DNS", matches: (i) => bodyContains(i, /pi-?hole/i) },
|
||||||
|
{ name: "AdGuard Home", category: "DNS", matches: (i) => bodyContains(i, /adguard/i) },
|
||||||
|
{ name: "UniFi Network", category: "Netzwerk", matches: (i) => bodyContains(i, /unifi/i) },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function detectSoftware(input: SoftwareSignatureInput): SoftwareSignature | null {
|
||||||
|
return SOFTWARE_SIGNATURES.find((signature) => signature.matches(input)) ?? null;
|
||||||
|
}
|
||||||
11
apps/backend/tsconfig.json
Normal file
11
apps/backend/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
30
apps/frontend/Dockerfile
Normal file
30
apps/frontend/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:20-alpine AS base
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# ---- deps --------------------------------------------------------------
|
||||||
|
FROM base AS deps
|
||||||
|
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* tsconfig.base.json ./
|
||||||
|
COPY packages/shared/package.json packages/shared/package.json
|
||||||
|
COPY packages/ui/package.json packages/ui/package.json
|
||||||
|
COPY apps/backend/package.json apps/backend/package.json
|
||||||
|
COPY apps/frontend/package.json apps/frontend/package.json
|
||||||
|
RUN pnpm install --frozen-lockfile || pnpm install
|
||||||
|
|
||||||
|
# ---- build ---------------------------------------------------------------
|
||||||
|
FROM deps AS build
|
||||||
|
COPY packages/shared packages/shared
|
||||||
|
COPY packages/ui packages/ui
|
||||||
|
COPY apps/frontend apps/frontend
|
||||||
|
RUN pnpm --filter @launchpad/shared build
|
||||||
|
RUN pnpm --filter @launchpad/ui build
|
||||||
|
RUN pnpm --filter @launchpad/frontend build
|
||||||
|
|
||||||
|
# ---- runtime: nginx --------------------------------------------------------
|
||||||
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
COPY apps/frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/apps/frontend/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
17
apps/frontend/index.html
Normal file
17
apps/frontend/index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#0a0a0a" />
|
||||||
|
<meta name="description" content="Schneller, minimalistischer Homelab-Launcher" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<title>LaunchPad</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-white dark:bg-black">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
39
apps/frontend/nginx.conf
Normal file
39
apps/frontend/nginx.conf
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# PWA / SPA: alle unbekannten Routen auf index.html zurückführen
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API-Anfragen an den Backend-Container weiterleiten
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:3001/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Service Worker und Manifest dürfen nicht langfristig gecacht werden,
|
||||||
|
# sonst bekommen installierte PWA-Nutzer nie ein Update mit.
|
||||||
|
location = /sw.js {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /manifest.webmanifest {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
default_type application/manifest+json;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(?:css|js|svg|png|jpg|jpeg|gif|ico|woff2?)$ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, max-age=604800, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/frontend/package.json
Normal file
31
apps/frontend/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@launchpad/frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0 --port 5173",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@launchpad/shared": "workspace:*",
|
||||||
|
"@launchpad/ui": "workspace:*",
|
||||||
|
"@tanstack/react-query": "^5.51.23",
|
||||||
|
"@tanstack/react-router": "^1.58.3",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.41",
|
||||||
|
"tailwindcss": "^3.4.10",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.1",
|
||||||
|
"vite-plugin-pwa": "^0.20.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/frontend/postcss.config.js
Normal file
6
apps/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
apps/frontend/public/apple-touch-icon.png
Normal file
BIN
apps/frontend/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
5
apps/frontend/public/favicon.svg
Normal file
5
apps/frontend/public/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="512" height="512" rx="112" fill="#0a0a0a"/>
|
||||||
|
<path d="M 200 140 L 200 372 L 372 256 Z" fill="#ffffff"/>
|
||||||
|
<circle cx="150" cy="256" r="26" fill="#ffffff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 267 B |
BIN
apps/frontend/public/icons/icon-192.png
Normal file
BIN
apps/frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
BIN
apps/frontend/public/icons/icon-512.png
Normal file
BIN
apps/frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
apps/frontend/public/icons/icon-maskable-192.png
Normal file
BIN
apps/frontend/public/icons/icon-maskable-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
apps/frontend/public/icons/icon-maskable-512.png
Normal file
BIN
apps/frontend/public/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
34
apps/frontend/src/hooks/useBackendHealth.ts
Normal file
34
apps/frontend/src/hooks/useBackendHealth.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { HealthStatus } from "@launchpad/shared";
|
||||||
|
|
||||||
|
export function useBackendHealth() {
|
||||||
|
const [health, setHealth] = useState<HealthStatus | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/health");
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data: HealthStatus = await res.json();
|
||||||
|
if (!cancelled) {
|
||||||
|
setHealth(data);
|
||||||
|
setError(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check();
|
||||||
|
const interval = setInterval(check, 10_000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { health, error };
|
||||||
|
}
|
||||||
17
apps/frontend/src/hooks/useCategories.ts
Normal file
17
apps/frontend/src/hooks/useCategories.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { Category } from "@launchpad/shared";
|
||||||
|
|
||||||
|
async function fetchCategories(): Promise<Category[]> {
|
||||||
|
const res = await fetch("/api/categories");
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Kategorien konnten nicht geladen werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCategories() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["categories"],
|
||||||
|
queryFn: fetchCategories,
|
||||||
|
});
|
||||||
|
}
|
||||||
21
apps/frontend/src/hooks/useDevices.ts
Normal file
21
apps/frontend/src/hooks/useDevices.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { Device, Service } from "@launchpad/shared";
|
||||||
|
|
||||||
|
export interface DeviceWithServices extends Device {
|
||||||
|
services: Service[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDevices(): Promise<DeviceWithServices[]> {
|
||||||
|
const res = await fetch("/api/devices");
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Geräte konnten nicht geladen werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDevices() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["devices"],
|
||||||
|
queryFn: fetchDevices,
|
||||||
|
});
|
||||||
|
}
|
||||||
18
apps/frontend/src/hooks/useLogs.ts
Normal file
18
apps/frontend/src/hooks/useLogs.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { ScanLogEntry } from "@launchpad/shared";
|
||||||
|
|
||||||
|
async function fetchLogs(): Promise<ScanLogEntry[]> {
|
||||||
|
const res = await fetch("/api/logs");
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Logs konnten nicht geladen werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogs() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["logs"],
|
||||||
|
queryFn: fetchLogs,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
17
apps/frontend/src/hooks/useServices.ts
Normal file
17
apps/frontend/src/hooks/useServices.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { Service } from "@launchpad/shared";
|
||||||
|
|
||||||
|
async function fetchServices(): Promise<Service[]> {
|
||||||
|
const res = await fetch("/api/services");
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Dienste konnten nicht geladen werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServices() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["services"],
|
||||||
|
queryFn: fetchServices,
|
||||||
|
});
|
||||||
|
}
|
||||||
22
apps/frontend/src/hooks/useTheme.ts
Normal file
22
apps/frontend/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export type Theme = "light" | "dark";
|
||||||
|
|
||||||
|
export function useTheme(): [Theme, () => void] {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
if (typeof window === "undefined") return "dark";
|
||||||
|
const stored = window.localStorage.getItem("launchpad-theme");
|
||||||
|
if (stored === "light" || stored === "dark") return stored;
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||||
|
window.localStorage.setItem("launchpad-theme", theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggle = () => setTheme((t) => (t === "dark" ? "light" : "dark"));
|
||||||
|
return [theme, toggle];
|
||||||
|
}
|
||||||
18
apps/frontend/src/index.css
Normal file
18
apps/frontend/src/index.css
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
"Inter",
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
23
apps/frontend/src/main.tsx
Normal file
23
apps/frontend/src/main.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { RouterProvider } from "@tanstack/react-router";
|
||||||
|
import { router } from "./router.js";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
107
apps/frontend/src/router.tsx
Normal file
107
apps/frontend/src/router.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { Outlet, createRootRoute, createRoute, createRouter, redirect } from "@tanstack/react-router";
|
||||||
|
import { HomePage } from "./routes/HomePage.js";
|
||||||
|
import { AdminLayout } from "./routes/admin/AdminLayout.js";
|
||||||
|
import { DashboardPage } from "./routes/admin/DashboardPage.js";
|
||||||
|
import { DevicesPage } from "./routes/admin/DevicesPage.js";
|
||||||
|
import { ServicesPage } from "./routes/admin/ServicesPage.js";
|
||||||
|
import { CategoriesPage } from "./routes/admin/CategoriesPage.js";
|
||||||
|
import { ScannerPage } from "./routes/admin/ScannerPage.js";
|
||||||
|
import { PluginsPage } from "./routes/admin/PluginsPage.js";
|
||||||
|
import { SettingsPage } from "./routes/admin/SettingsPage.js";
|
||||||
|
import { LogsPage } from "./routes/admin/LogsPage.js";
|
||||||
|
|
||||||
|
const rootRoute = createRootRoute({
|
||||||
|
component: () => <Outlet />,
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/",
|
||||||
|
component: HomePage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/admin",
|
||||||
|
component: AdminLayout,
|
||||||
|
});
|
||||||
|
|
||||||
|
// /admin ohne weiteren Pfad -> Dashboard
|
||||||
|
const adminIndexRoute = createRoute({
|
||||||
|
getParentRoute: () => adminRoute,
|
||||||
|
path: "/",
|
||||||
|
loader: () => {
|
||||||
|
throw redirect({ to: "/admin/dashboard" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminDashboardRoute = createRoute({
|
||||||
|
getParentRoute: () => adminRoute,
|
||||||
|
path: "/dashboard",
|
||||||
|
component: DashboardPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminDevicesRoute = createRoute({
|
||||||
|
getParentRoute: () => adminRoute,
|
||||||
|
path: "/devices",
|
||||||
|
component: DevicesPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminServicesRoute = createRoute({
|
||||||
|
getParentRoute: () => adminRoute,
|
||||||
|
path: "/services",
|
||||||
|
component: ServicesPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminCategoriesRoute = createRoute({
|
||||||
|
getParentRoute: () => adminRoute,
|
||||||
|
path: "/categories",
|
||||||
|
component: CategoriesPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminScannerRoute = createRoute({
|
||||||
|
getParentRoute: () => adminRoute,
|
||||||
|
path: "/scanner",
|
||||||
|
component: ScannerPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminPluginsRoute = createRoute({
|
||||||
|
getParentRoute: () => adminRoute,
|
||||||
|
path: "/plugins",
|
||||||
|
component: PluginsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminSettingsRoute = createRoute({
|
||||||
|
getParentRoute: () => adminRoute,
|
||||||
|
path: "/settings",
|
||||||
|
component: SettingsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminLogsRoute = createRoute({
|
||||||
|
getParentRoute: () => adminRoute,
|
||||||
|
path: "/logs",
|
||||||
|
component: LogsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const routeTree = rootRoute.addChildren([
|
||||||
|
indexRoute,
|
||||||
|
adminRoute.addChildren([
|
||||||
|
adminIndexRoute,
|
||||||
|
adminDashboardRoute,
|
||||||
|
adminDevicesRoute,
|
||||||
|
adminServicesRoute,
|
||||||
|
adminCategoriesRoute,
|
||||||
|
adminScannerRoute,
|
||||||
|
adminPluginsRoute,
|
||||||
|
adminSettingsRoute,
|
||||||
|
adminLogsRoute,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const router = createRouter({ routeTree });
|
||||||
|
|
||||||
|
declare module "@tanstack/react-router" {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router;
|
||||||
|
}
|
||||||
|
}
|
||||||
161
apps/frontend/src/routes/HomePage.tsx
Normal file
161
apps/frontend/src/routes/HomePage.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { SearchInput, StatusBadge, ResultsList } from "@launchpad/ui";
|
||||||
|
import { rankServices, type Service } from "@launchpad/shared";
|
||||||
|
import { useServices } from "../hooks/useServices.js";
|
||||||
|
import { useBackendHealth } from "../hooks/useBackendHealth.js";
|
||||||
|
import { useTheme } from "../hooks/useTheme.js";
|
||||||
|
|
||||||
|
function openService(service: Service) {
|
||||||
|
window.open(service.url, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleServiceFavorite(service: Service): Promise<Service> {
|
||||||
|
const res = await fetch(`/api/services/${service.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ favorite: !service.favorite }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Favorit konnte nicht aktualisiert werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
const [theme, toggleTheme] = useTheme();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const { health, error: healthError } = useBackendHealth();
|
||||||
|
const { data: services, isLoading, isError } = useServices();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const toggleFavorite = useMutation({
|
||||||
|
mutationFn: toggleServiceFavorite,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = useMemo(
|
||||||
|
() => rankServices(services ?? [], query),
|
||||||
|
[services, query]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auswahl zurücksetzen, sobald sich die Trefferliste ändert
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [results.length, query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
const isSlash = e.key === "/" && document.activeElement !== inputRef.current;
|
||||||
|
const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
|
||||||
|
|
||||||
|
if (isSlash || isCmdK) {
|
||||||
|
e.preventDefault();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.activeElement !== inputRef.current) return;
|
||||||
|
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((i) => Math.min(i + 1, Math.max(results.length - 1, 0)));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((i) => Math.max(i - 1, 0));
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = results[selectedIndex];
|
||||||
|
if (target) openService(target);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
inputRef.current?.blur();
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [results, selectedIndex]);
|
||||||
|
|
||||||
|
const isOnline = !healthError && health?.status === "ok";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-start gap-8 bg-gradient-to-b from-white to-neutral-100 px-6 pt-[15vh] dark:from-black dark:to-neutral-950">
|
||||||
|
<div className="fixed right-6 top-6 flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
aria-label="Adminbereich öffnen"
|
||||||
|
className="rounded-full border border-black/10 p-2 text-black/60 transition-colors
|
||||||
|
hover:bg-black/5 dark:border-white/10 dark:text-white/60 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
⚙️
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
aria-label="Theme wechseln"
|
||||||
|
className="rounded-full border border-black/10 p-2 text-black/60 transition-colors
|
||||||
|
hover:bg-black/5 dark:border-white/10 dark:text-white/60 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
{theme === "dark" ? "☀️" : "🌙"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<h1 className="text-4xl font-semibold tracking-tight text-black dark:text-white">
|
||||||
|
LaunchPad
|
||||||
|
</h1>
|
||||||
|
<p className="text-black/50 dark:text-white/50">
|
||||||
|
Tippe, um deine Homelab-Dienste sofort zu öffnen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-xl">
|
||||||
|
<SearchInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Dienst suchen … z. B. „frigate“"
|
||||||
|
hint="⌘K"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="mt-4 text-center text-sm text-black/40 dark:text-white/40">
|
||||||
|
Lade Dienste …
|
||||||
|
</p>
|
||||||
|
) : isError ? (
|
||||||
|
<p className="mt-4 text-center text-sm text-red-500">
|
||||||
|
Dienste konnten nicht geladen werden.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ResultsList
|
||||||
|
services={results}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
onHover={setSelectedIndex}
|
||||||
|
onOpen={openService}
|
||||||
|
onToggleFavorite={(service) => toggleFavorite.mutate(service)}
|
||||||
|
emptyLabel={
|
||||||
|
(services?.length ?? 0) === 0
|
||||||
|
? "Noch keine Dienste angelegt. Füge welche im Adminbereich hinzu."
|
||||||
|
: "Keine Treffer für deine Suche."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-1 pb-8">
|
||||||
|
<StatusBadge online={isOnline} label={isOnline ? "Backend verbunden" : "Backend nicht erreichbar"} />
|
||||||
|
{health ? (
|
||||||
|
<span className="text-xs text-black/30 dark:text-white/30">
|
||||||
|
v{health.version} · läuft seit {health.uptimeSeconds}s
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
apps/frontend/src/routes/admin/AdminLayout.tsx
Normal file
64
apps/frontend/src/routes/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Link, Outlet, useRouterState } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ to: "/admin/dashboard", label: "Dashboard", icon: "📊" },
|
||||||
|
{ to: "/admin/devices", label: "Geräte", icon: "🖥️" },
|
||||||
|
{ to: "/admin/services", label: "Dienste", icon: "🔗" },
|
||||||
|
{ to: "/admin/scanner", label: "Scanner", icon: "🔍" },
|
||||||
|
{ to: "/admin/categories", label: "Kategorien", icon: "🏷️" },
|
||||||
|
{ to: "/admin/plugins", label: "Plugins", icon: "🧩" },
|
||||||
|
{ to: "/admin/settings", label: "Einstellungen", icon: "⚙️" },
|
||||||
|
{ to: "/admin/logs", label: "Logs", icon: "📜" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function AdminLayout() {
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-neutral-50 dark:bg-neutral-950">
|
||||||
|
<aside className="flex w-56 shrink-0 flex-col border-r border-black/10 bg-white/70 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div className="flex items-center gap-2 border-b border-black/10 px-5 py-4 dark:border-white/10">
|
||||||
|
<Link to="/" className="text-lg font-semibold text-black dark:text-white">
|
||||||
|
LaunchPad
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-1 flex-col gap-0.5 p-3">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const active = pathname.startsWith(item.to);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={`flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-black/5 text-black dark:bg-white/10 dark:text-white"
|
||||||
|
: "text-black/60 hover:bg-black/5 hover:text-black dark:text-white/60 dark:hover:bg-white/5 dark:hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden>{item.icon}</span>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="border-t border-black/10 p-3 dark:border-white/10">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium
|
||||||
|
text-black/60 transition-colors hover:bg-black/5 hover:text-black
|
||||||
|
dark:text-white/60 dark:hover:bg-white/5 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
<span aria-hidden>←</span>
|
||||||
|
Zurück zur Suche
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto p-8">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/frontend/src/routes/admin/AdminPageHeader.tsx
Normal file
21
apps/frontend/src/routes/admin/AdminPageHeader.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface AdminPageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminPageHeader({ title, description, actions }: AdminPageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-black dark:text-white">{title}</h1>
|
||||||
|
{description ? (
|
||||||
|
<p className="mt-1 text-sm text-black/50 dark:text-white/50">{description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
apps/frontend/src/routes/admin/CategoriesPage.tsx
Normal file
243
apps/frontend/src/routes/admin/CategoriesPage.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import { useState, type DragEvent, type FormEvent } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@launchpad/ui";
|
||||||
|
import type { Category } from "@launchpad/shared";
|
||||||
|
import { useCategories } from "../../hooks/useCategories.js";
|
||||||
|
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||||
|
|
||||||
|
async function createCategory(name: string): Promise<Category> {
|
||||||
|
const res = await fetch("/api/categories", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error ?? `Kategorie konnte nicht angelegt werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameCategory(id: string, name: string): Promise<Category> {
|
||||||
|
const res = await fetch(`/api/categories/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Kategorie konnte nicht umbenannt werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCategoryRequest(id: string) {
|
||||||
|
const res = await fetch(`/api/categories/${id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok && res.status !== 404) {
|
||||||
|
throw new Error(`Kategorie konnte nicht gelöscht werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reorderCategories(entries: { id: string; order: number }[]) {
|
||||||
|
const res = await fetch("/api/categories/reorder", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(entries),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Reihenfolge konnte nicht gespeichert werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryRow({
|
||||||
|
category,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
isDragging,
|
||||||
|
}: {
|
||||||
|
category: Category;
|
||||||
|
onDragStart: (e: DragEvent<HTMLLIElement>) => void;
|
||||||
|
onDragOver: (e: DragEvent<HTMLLIElement>) => void;
|
||||||
|
onDrop: (e: DragEvent<HTMLLIElement>) => void;
|
||||||
|
isDragging: boolean;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [name, setName] = useState(category.name);
|
||||||
|
|
||||||
|
const renameMutation = useMutation({
|
||||||
|
mutationFn: () => renameCategory(category.id, name.trim()),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||||
|
setEditing(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => deleteCategoryRequest(category.id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
draggable
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
data-category-id={category.id}
|
||||||
|
className={`flex items-center gap-3 border-b border-black/5 px-4 py-3 last:border-0
|
||||||
|
dark:border-white/5 ${isDragging ? "opacity-40" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="cursor-grab select-none text-black/30 dark:text-white/30" aria-hidden>
|
||||||
|
⠿⠿
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
className="flex-1 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||||||
|
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="primary" onClick={() => renameMutation.mutate()} disabled={renameMutation.isPending}>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="flex-1 text-sm font-medium text-black dark:text-white">{category.name}</span>
|
||||||
|
<Button size="sm" onClick={() => setEditing(true)}>
|
||||||
|
Umbenennen
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending}>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoriesPage() {
|
||||||
|
const { data: categories, isLoading, isError } = useCategories();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||||
|
const [localOrder, setLocalOrder] = useState<Category[] | null>(null);
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => createCategory(newName.trim()),
|
||||||
|
onSuccess: () => {
|
||||||
|
setNewName("");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reorderMutation = useMutation({
|
||||||
|
mutationFn: reorderCategories,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||||
|
setLocalOrder(null);
|
||||||
|
},
|
||||||
|
onError: () => setLocalOrder(null),
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = localOrder ?? categories ?? [];
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
createMutation.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragStart(id: string) {
|
||||||
|
return (_e: DragEvent<HTMLLIElement>) => setDraggedId(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(targetId: string) {
|
||||||
|
return (e: DragEvent<HTMLLIElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!draggedId || draggedId === targetId) return;
|
||||||
|
|
||||||
|
const current = localOrder ?? categories ?? [];
|
||||||
|
const fromIndex = current.findIndex((c) => c.id === draggedId);
|
||||||
|
const toIndex = current.findIndex((c) => c.id === targetId);
|
||||||
|
if (fromIndex === -1 || toIndex === -1) return;
|
||||||
|
|
||||||
|
const next = [...current];
|
||||||
|
const [moved] = next.splice(fromIndex, 1);
|
||||||
|
next.splice(toIndex, 0, moved);
|
||||||
|
setLocalOrder(next);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop() {
|
||||||
|
return (e: DragEvent<HTMLLIElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDraggedId(null);
|
||||||
|
const current = localOrder ?? categories ?? [];
|
||||||
|
reorderMutation.mutate(current.map((c, index) => ({ id: c.id, order: index })));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AdminPageHeader
|
||||||
|
title="Kategorien"
|
||||||
|
description="Per Drag & Drop sortieren. Favoriten erscheinen in der Suche trotzdem immer zuerst."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="mb-6 flex items-end gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
|
||||||
|
Neue Kategorie
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder="z. B. Medien"
|
||||||
|
className="rounded-lg border border-black/10 bg-white px-3 py-1.5 text-sm
|
||||||
|
text-black outline-none focus:border-black/30 dark:border-white/10
|
||||||
|
dark:bg-white/5 dark:text-white dark:focus:border-white/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="primary" disabled={createMutation.isPending}>
|
||||||
|
Anlegen
|
||||||
|
</Button>
|
||||||
|
{createMutation.isError ? (
|
||||||
|
<span className="text-xs text-red-500">{(createMutation.error as Error).message}</span>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-black/40 dark:text-white/40">Lade Kategorien …</p>
|
||||||
|
) : isError ? (
|
||||||
|
<p className="text-sm text-red-500">Kategorien konnten nicht geladen werden.</p>
|
||||||
|
) : list.length > 0 ? (
|
||||||
|
<ul className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
|
||||||
|
{list.map((category) => (
|
||||||
|
<CategoryRow
|
||||||
|
key={category.id}
|
||||||
|
category={category}
|
||||||
|
isDragging={draggedId === category.id}
|
||||||
|
onDragStart={handleDragStart(category.id)}
|
||||||
|
onDragOver={handleDragOver(category.id)}
|
||||||
|
onDrop={handleDrop()}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-black/40 dark:text-white/40">Noch keine Kategorien angelegt.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
apps/frontend/src/routes/admin/DashboardPage.tsx
Normal file
84
apps/frontend/src/routes/admin/DashboardPage.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useServices } from "../../hooks/useServices.js";
|
||||||
|
import { useDevices } from "../../hooks/useDevices.js";
|
||||||
|
import { useCategories } from "../../hooks/useCategories.js";
|
||||||
|
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||||
|
|
||||||
|
function StatCard({ label, value }: { label: string; value: number | string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-black/10 bg-white p-5 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div className="text-3xl font-semibold text-black dark:text-white">{value}</div>
|
||||||
|
<div className="mt-1 text-sm text-black/50 dark:text-white/50">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const { data: services } = useServices();
|
||||||
|
const { data: devices } = useDevices();
|
||||||
|
const { data: categories } = useCategories();
|
||||||
|
|
||||||
|
const onlineDevices = devices?.filter((d) => d.online).length ?? 0;
|
||||||
|
const favoriteServices = services?.filter((s) => s.favorite).length ?? 0;
|
||||||
|
|
||||||
|
const recentlyScanned = [...(devices ?? [])]
|
||||||
|
.filter((d) => d.lastScan)
|
||||||
|
.sort((a, b) => (b.lastScan ?? "").localeCompare(a.lastScan ?? ""))
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AdminPageHeader
|
||||||
|
title="Dashboard"
|
||||||
|
description="Überblick über dein Homelab."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<StatCard label="Geräte" value={devices?.length ?? 0} />
|
||||||
|
<StatCard label="davon online" value={onlineDevices} />
|
||||||
|
<StatCard label="Dienste" value={services?.length ?? 0} />
|
||||||
|
<StatCard label="Favoriten" value={favoriteServices} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-black/60 dark:text-white/60">
|
||||||
|
Zuletzt gescannte Geräte
|
||||||
|
</h2>
|
||||||
|
{recentlyScanned.length === 0 ? (
|
||||||
|
<p className="text-sm text-black/40 dark:text-white/40">
|
||||||
|
Noch keine Scans durchgeführt. Starte einen Scan unter „Geräte“ oder „Scanner“.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{recentlyScanned.map((device) => (
|
||||||
|
<tr
|
||||||
|
key={device.id}
|
||||||
|
className="border-b border-black/5 last:border-0 dark:border-white/5"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-medium text-black dark:text-white">
|
||||||
|
{device.hostname}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-black/40 dark:text-white/40">{device.ip}</td>
|
||||||
|
<td className="px-4 py-3 text-black/40 dark:text-white/40">
|
||||||
|
{device.lastScan ? new Date(device.lastScan).toLocaleString("de-DE") : "–"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-black/60 dark:text-white/60">
|
||||||
|
Kategorien
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-black/40 dark:text-white/40">
|
||||||
|
{categories?.length ?? 0} Kategorie(n) angelegt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
apps/frontend/src/routes/admin/DevicesPage.tsx
Normal file
220
apps/frontend/src/routes/admin/DevicesPage.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@launchpad/ui";
|
||||||
|
import { useDevices, type DeviceWithServices } from "../../hooks/useDevices.js";
|
||||||
|
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||||
|
|
||||||
|
async function createDevice(input: { hostname: string; ip: string }) {
|
||||||
|
const res = await fetch("/api/devices", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error ?? `Gerät konnte nicht angelegt werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDevice(id: string) {
|
||||||
|
const res = await fetch(`/api/devices/${id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok && res.status !== 404) {
|
||||||
|
throw new Error(`Gerät konnte nicht gelöscht werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScanResult {
|
||||||
|
scannedPorts: number;
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanDevice(id: string): Promise<ScanResult> {
|
||||||
|
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST" });
|
||||||
|
const body = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceRow({ device }: { device: DeviceWithServices }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [scanMessage, setScanMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const scanMutation = useMutation({
|
||||||
|
mutationFn: () => scanDevice(device.id),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setScanMessage(
|
||||||
|
`${result.scannedPorts} Port(s) offen · ${result.created} neu · ${result.updated} aktualisiert`
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => setScanMessage(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => deleteDevice(device.id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["devices"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-black/5 last:border-0 dark:border-white/5">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium text-black dark:text-white">{device.hostname}</div>
|
||||||
|
<div className="text-xs text-black/40 dark:text-white/40">{device.ip}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 text-xs ${
|
||||||
|
device.online ? "text-emerald-600 dark:text-emerald-400" : "text-black/40 dark:text-white/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${device.online ? "bg-emerald-500" : "bg-black/20 dark:bg-white/20"}`}
|
||||||
|
/>
|
||||||
|
{device.online ? "Online" : "Offline"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-black/60 dark:text-white/60">{device.services.length}</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-black/40 dark:text-white/40">
|
||||||
|
{device.lastScan ? new Date(device.lastScan).toLocaleString("de-DE") : "nie gescannt"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{scanMessage ? (
|
||||||
|
<span className="max-w-[16rem] truncate text-xs text-black/40 dark:text-white/40" title={scanMessage}>
|
||||||
|
{scanMessage}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setScanMessage(null);
|
||||||
|
scanMutation.mutate();
|
||||||
|
}}
|
||||||
|
disabled={scanMutation.isPending}
|
||||||
|
>
|
||||||
|
{scanMutation.isPending ? "Scanne …" : "Jetzt scannen"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => deleteMutation.mutate()}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddDeviceForm() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [hostname, setHostname] = useState("");
|
||||||
|
const [ip, setIp] = useState("");
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: createDevice,
|
||||||
|
onSuccess: () => {
|
||||||
|
setHostname("");
|
||||||
|
setIp("");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!hostname.trim() || !ip.trim()) return;
|
||||||
|
mutation.mutate({ hostname: hostname.trim(), ip: ip.trim() });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="flex items-end gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
|
||||||
|
Hostname
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={hostname}
|
||||||
|
onChange={(e) => setHostname(e.target.value)}
|
||||||
|
placeholder="z. B. synology"
|
||||||
|
className="rounded-lg border border-black/10 bg-white px-3 py-1.5 text-sm
|
||||||
|
text-black outline-none focus:border-black/30 dark:border-white/10
|
||||||
|
dark:bg-white/5 dark:text-white dark:focus:border-white/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
|
||||||
|
IP-Adresse
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={ip}
|
||||||
|
onChange={(e) => setIp(e.target.value)}
|
||||||
|
placeholder="192.168.1.10"
|
||||||
|
className="rounded-lg border border-black/10 bg-white px-3 py-1.5 text-sm
|
||||||
|
text-black outline-none focus:border-black/30 dark:border-white/10
|
||||||
|
dark:bg-white/5 dark:text-white dark:focus:border-white/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="primary" disabled={mutation.isPending}>
|
||||||
|
Gerät hinzufügen
|
||||||
|
</Button>
|
||||||
|
{mutation.isError ? (
|
||||||
|
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DevicesPage() {
|
||||||
|
const { data: devices, isLoading, isError } = useDevices();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AdminPageHeader
|
||||||
|
title="Geräte"
|
||||||
|
description="Alle bekannten Geräte in deinem Netzwerk. Scans laufen nur auf Knopfdruck."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<AddDeviceForm />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-black/40 dark:text-white/40">Lade Geräte …</p>
|
||||||
|
) : isError ? (
|
||||||
|
<p className="text-sm text-red-500">Geräte konnten nicht geladen werden.</p>
|
||||||
|
) : devices && devices.length > 0 ? (
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-black/10 bg-black/[0.02] text-left text-xs
|
||||||
|
uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
|
||||||
|
<th className="px-4 py-2 font-medium">Gerät</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Status</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Dienste</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Letzter Scan</th>
|
||||||
|
<th className="px-4 py-2" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{devices.map((device) => (
|
||||||
|
<DeviceRow key={device.id} device={device} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-black/40 dark:text-white/40">
|
||||||
|
Noch keine Geräte angelegt. Füge oben ein Gerät hinzu oder nutze den FritzBox-Scan
|
||||||
|
unter „Scanner“.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
apps/frontend/src/routes/admin/LogsPage.tsx
Normal file
59
apps/frontend/src/routes/admin/LogsPage.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useLogs } from "../../hooks/useLogs.js";
|
||||||
|
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||||
|
|
||||||
|
export function LogsPage() {
|
||||||
|
const { data: logs, isLoading, isError } = useLogs();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AdminPageHeader
|
||||||
|
title="Logs"
|
||||||
|
description="Protokoll aller Scan-Versuche (Geräte-Scan und FritzBox-Scan)."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-black/40 dark:text-white/40">Lade Logs …</p>
|
||||||
|
) : isError ? (
|
||||||
|
<p className="text-sm text-red-500">Logs konnten nicht geladen werden.</p>
|
||||||
|
) : logs && logs.length > 0 ? (
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-black/10 bg-black/[0.02] text-left text-xs
|
||||||
|
uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
|
||||||
|
<th className="px-4 py-2 font-medium">Zeit</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Typ</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Nachricht</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logs.map((log) => (
|
||||||
|
<tr key={log.id} className="border-b border-black/5 last:border-0 dark:border-white/5">
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-xs text-black/40 dark:text-white/40">
|
||||||
|
{new Date(log.createdAt).toLocaleString("de-DE")}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||||
|
log.level === "error"
|
||||||
|
? "bg-red-500/10 text-red-600 dark:text-red-400"
|
||||||
|
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{log.type === "fritzbox" ? "FritzBox" : "Gerät"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-black/70 dark:text-white/70">{log.message}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-black/40 dark:text-white/40">
|
||||||
|
Noch keine Scans durchgeführt. Starte einen Scan unter „Geräte" oder „Scanner".
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/frontend/src/routes/admin/PluginsPage.tsx
Normal file
18
apps/frontend/src/routes/admin/PluginsPage.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||||
|
|
||||||
|
export function PluginsPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AdminPageHeader title="Plugins" />
|
||||||
|
<div className="rounded-2xl border border-dashed border-black/15 p-8 text-center dark:border-white/15">
|
||||||
|
<p className="text-black/60 dark:text-white/60">
|
||||||
|
Das Plugin-System (Scanner registrieren, Geräte importieren, Menüs erweitern,
|
||||||
|
Icons bereitstellen) ist noch nicht gebaut.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-black/40 dark:text-white/40">
|
||||||
|
Geplant als eigener Commit – siehe <code className="rounded bg-black/5 px-1 dark:bg-white/10">docs/ROADMAP.md</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
apps/frontend/src/routes/admin/ScannerPage.tsx
Normal file
121
apps/frontend/src/routes/admin/ScannerPage.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@launchpad/ui";
|
||||||
|
import { useDevices } from "../../hooks/useDevices.js";
|
||||||
|
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||||
|
|
||||||
|
async function scanFritzBox() {
|
||||||
|
const res = await fetch("/api/scan/fritzbox", { method: "POST" });
|
||||||
|
const body = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(body.error ?? `FritzBox-Scan fehlgeschlagen (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return body as { found: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanDeviceById(id: string) {
|
||||||
|
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST" });
|
||||||
|
const body = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return body as { created: number; updated: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScannerPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: devices } = useDevices();
|
||||||
|
const [bulkStatus, setBulkStatus] = useState<string | null>(null);
|
||||||
|
const [bulkRunning, setBulkRunning] = useState(false);
|
||||||
|
|
||||||
|
const fritzboxMutation = useMutation({
|
||||||
|
mutationFn: scanFritzBox,
|
||||||
|
onSuccess: (result) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["logs"] });
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function scanAllDevices() {
|
||||||
|
if (!devices || devices.length === 0) return;
|
||||||
|
setBulkRunning(true);
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
for (const device of devices) {
|
||||||
|
try {
|
||||||
|
const result = await scanDeviceById(device.id);
|
||||||
|
created += result.created;
|
||||||
|
updated += result.updated;
|
||||||
|
setBulkStatus(`Scanne ${device.hostname} … (${created} neu, ${updated} aktualisiert bisher)`);
|
||||||
|
} catch {
|
||||||
|
// einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setBulkStatus(`Fertig: ${devices.length} Gerät(e) gescannt, ${created} neue Dienste, ${updated} aktualisiert.`);
|
||||||
|
setBulkRunning(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["logs"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AdminPageHeader
|
||||||
|
title="Scanner"
|
||||||
|
description="Scans laufen ausschließlich manuell – nie automatisch oder zeitgesteuert."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
|
||||||
|
<h2 className="font-medium text-black dark:text-white">FritzBox-Scan</h2>
|
||||||
|
<p className="mt-1 text-sm text-black/50 dark:text-white/50">
|
||||||
|
Liest die Geräteliste der FritzBox per TR-064 und legt/aktualisiert Geräte.
|
||||||
|
Erfordert <code className="rounded bg-black/5 px-1 dark:bg-white/10">FRITZBOX_HOST</code>,{" "}
|
||||||
|
<code className="rounded bg-black/5 px-1 dark:bg-white/10">FRITZBOX_USERNAME</code> und{" "}
|
||||||
|
<code className="rounded bg-black/5 px-1 dark:bg-white/10">FRITZBOX_PASSWORD</code> in
|
||||||
|
der <code className="rounded bg-black/5 px-1 dark:bg-white/10">.env</code>.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => fritzboxMutation.mutate()}
|
||||||
|
disabled={fritzboxMutation.isPending}
|
||||||
|
>
|
||||||
|
{fritzboxMutation.isPending ? "Scanne …" : "FritzBox jetzt scannen"}
|
||||||
|
</Button>
|
||||||
|
{fritzboxMutation.isSuccess ? (
|
||||||
|
<p className="mt-2 text-sm text-emerald-600 dark:text-emerald-400">
|
||||||
|
{fritzboxMutation.data.found} Gerät(e) gefunden.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{fritzboxMutation.isError ? (
|
||||||
|
<p className="mt-2 text-sm text-red-500">{(fritzboxMutation.error as Error).message}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
|
||||||
|
<h2 className="font-medium text-black dark:text-white">Alle Geräte scannen</h2>
|
||||||
|
<p className="mt-1 text-sm text-black/50 dark:text-white/50">
|
||||||
|
Führt den Netzwerk-Scan (DNS, Ports, Titel/Favicon, Softwareerkennung) nacheinander
|
||||||
|
für alle {devices?.length ?? 0} bekannten Geräte aus. Für ein einzelnes Gerät lieber
|
||||||
|
den Button in der Geräte-Tabelle nutzen.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={scanAllDevices}
|
||||||
|
disabled={bulkRunning || !devices || devices.length === 0}
|
||||||
|
>
|
||||||
|
{bulkRunning ? "Scanne …" : "Alle Geräte jetzt scannen"}
|
||||||
|
</Button>
|
||||||
|
{bulkStatus ? (
|
||||||
|
<p className="mt-2 text-sm text-black/50 dark:text-white/50">{bulkStatus}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
apps/frontend/src/routes/admin/ServicesPage.tsx
Normal file
218
apps/frontend/src/routes/admin/ServicesPage.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@launchpad/ui";
|
||||||
|
import type { Service } from "@launchpad/shared";
|
||||||
|
import { useServices } from "../../hooks/useServices.js";
|
||||||
|
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||||
|
|
||||||
|
interface ServicePatch {
|
||||||
|
displayName?: string;
|
||||||
|
category?: string | null;
|
||||||
|
alias?: string[];
|
||||||
|
order?: number;
|
||||||
|
favorite?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchService(id: string, patch: ServicePatch): Promise<Service> {
|
||||||
|
const res = await fetch(`/api/services/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Dienst konnte nicht aktualisiert werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteServiceRequest(id: string) {
|
||||||
|
const res = await fetch(`/api/services/${id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok && res.status !== 404) {
|
||||||
|
throw new Error(`Dienst konnte nicht gelöscht werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditForm({ service, onDone }: { service: Service; onDone: () => void }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [displayName, setDisplayName] = useState(service.displayName);
|
||||||
|
const [category, setCategory] = useState(service.category ?? "");
|
||||||
|
const [alias, setAlias] = useState(service.alias.join(", "));
|
||||||
|
const [order, setOrder] = useState(String(service.order));
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
patchService(service.id, {
|
||||||
|
displayName: displayName.trim(),
|
||||||
|
category: category.trim() || null,
|
||||||
|
alias: alias
|
||||||
|
.split(",")
|
||||||
|
.map((a) => a.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
order: Number(order) || 0,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||||
|
onDone();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-black/5 bg-black/[0.02] last:border-0 dark:border-white/5 dark:bg-white/5">
|
||||||
|
<td colSpan={6} className="px-4 py-3">
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label>
|
||||||
|
<input
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||||||
|
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Kategorie</label>
|
||||||
|
<input
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||||||
|
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">
|
||||||
|
Alias (kommagetrennt)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={alias}
|
||||||
|
onChange={(e) => setAlias(e.target.value)}
|
||||||
|
className="w-48 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||||||
|
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Reihenfolge</label>
|
||||||
|
<input
|
||||||
|
value={order}
|
||||||
|
onChange={(e) => setOrder(e.target.value)}
|
||||||
|
type="number"
|
||||||
|
className="w-20 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||||||
|
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending}>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={onDone}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServiceRow({ service }: { service: Service }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
const favoriteMutation = useMutation({
|
||||||
|
mutationFn: () => patchService(service.id, { favorite: !service.favorite }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => deleteServiceRequest(service.id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return <EditForm service={service} onDone={() => setEditing(false)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-black/5 last:border-0 dark:border-white/5">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => favoriteMutation.mutate()}
|
||||||
|
aria-label={service.favorite ? "Favorit entfernen" : "Als Favorit markieren"}
|
||||||
|
className={`text-lg ${service.favorite ? "text-amber-500" : "text-black/15 hover:text-amber-400 dark:text-white/15"}`}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium text-black dark:text-white">{service.displayName}</div>
|
||||||
|
<div className="text-xs text-black/40 dark:text-white/40">{service.hostname}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-black/60 dark:text-white/60">{service.category ?? "–"}</td>
|
||||||
|
<td className="px-4 py-3 text-black/60 dark:text-white/60">
|
||||||
|
{service.alias.length > 0 ? service.alias.join(", ") : "–"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<a
|
||||||
|
href={service.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-black/60 underline decoration-black/20 hover:text-black dark:text-white/60 dark:decoration-white/20 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
öffnen
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button size="sm" onClick={() => setEditing(true)}>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending}>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServicesPage() {
|
||||||
|
const { data: services, isLoading, isError } = useServices();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AdminPageHeader
|
||||||
|
title="Dienste"
|
||||||
|
description="Name, Kategorie, Alias und Reihenfolge bleiben bei erneuten Scans erhalten."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-black/40 dark:text-white/40">Lade Dienste …</p>
|
||||||
|
) : isError ? (
|
||||||
|
<p className="text-sm text-red-500">Dienste konnten nicht geladen werden.</p>
|
||||||
|
) : services && services.length > 0 ? (
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-black/10 bg-black/[0.02] text-left text-xs
|
||||||
|
uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
|
||||||
|
<th className="px-4 py-2 font-medium" />
|
||||||
|
<th className="px-4 py-2 font-medium">Dienst</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Kategorie</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Alias</th>
|
||||||
|
<th className="px-4 py-2 font-medium">URL</th>
|
||||||
|
<th className="px-4 py-2" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{services.map((service) => (
|
||||||
|
<ServiceRow key={service.id} service={service} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-black/40 dark:text-white/40">
|
||||||
|
Noch keine Dienste vorhanden. Scanne ein Gerät unter „Geräte“, um automatisch welche
|
||||||
|
zu finden.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
apps/frontend/src/routes/admin/SettingsPage.tsx
Normal file
55
apps/frontend/src/routes/admin/SettingsPage.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useBackendHealth } from "../../hooks/useBackendHealth.js";
|
||||||
|
import { useTheme } from "../../hooks/useTheme.js";
|
||||||
|
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between border-b border-black/5 py-3 last:border-0 dark:border-white/5">
|
||||||
|
<span className="text-sm text-black/50 dark:text-white/50">{label}</span>
|
||||||
|
<span className="text-sm font-medium text-black dark:text-white">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const { health, error } = useBackendHealth();
|
||||||
|
const [theme, toggleTheme] = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AdminPageHeader title="Einstellungen" />
|
||||||
|
|
||||||
|
<div className="max-w-lg space-y-6">
|
||||||
|
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
|
||||||
|
<h2 className="mb-2 font-medium text-black dark:text-white">Darstellung</h2>
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<span className="text-sm text-black/50 dark:text-white/50">Theme</span>
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="rounded-lg border border-black/10 px-3 py-1.5 text-sm text-black
|
||||||
|
transition-colors hover:bg-black/5 dark:border-white/10 dark:text-white
|
||||||
|
dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{theme === "dark" ? "🌙 Dunkel" : "☀️ Hell"} – wechseln
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
|
||||||
|
<h2 className="mb-2 font-medium text-black dark:text-white">Backend</h2>
|
||||||
|
{error ? (
|
||||||
|
<p className="text-sm text-red-500">Backend nicht erreichbar.</p>
|
||||||
|
) : health ? (
|
||||||
|
<div>
|
||||||
|
<InfoRow label="Status" value={health.status} />
|
||||||
|
<InfoRow label="Version" value={health.version} />
|
||||||
|
<InfoRow label="Läuft seit" value={`${health.uptimeSeconds}s`} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-black/40 dark:text-white/40">Lade …</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/frontend/tailwind.config.ts
Normal file
24
apps/frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
darkMode: "class",
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{ts,tsx}",
|
||||||
|
"../../packages/ui/src/**/*.{ts,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: [
|
||||||
|
"Inter",
|
||||||
|
"-apple-system",
|
||||||
|
"BlinkMacSystemFont",
|
||||||
|
"Segoe UI",
|
||||||
|
"sans-serif",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
} satisfies Config;
|
||||||
12
apps/frontend/tsconfig.json
Normal file
12
apps/frontend/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
67
apps/frontend/vite.config.ts
Normal file
67
apps/frontend/vite.config.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: "autoUpdate",
|
||||||
|
includeAssets: ["favicon.svg", "apple-touch-icon.png"],
|
||||||
|
manifest: {
|
||||||
|
name: "LaunchPad",
|
||||||
|
short_name: "LaunchPad",
|
||||||
|
description: "Schneller, minimalistischer Homelab-Launcher",
|
||||||
|
lang: "de",
|
||||||
|
theme_color: "#0a0a0a",
|
||||||
|
background_color: "#0a0a0a",
|
||||||
|
display: "standalone",
|
||||||
|
start_url: "/",
|
||||||
|
icons: [
|
||||||
|
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||||
|
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
|
||||||
|
{
|
||||||
|
src: "/icons/icon-maskable-192.png",
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "maskable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/icons/icon-maskable-512.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "maskable",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
// Zeigt beim Offline-Öffnen weiterhin die (gecachte) App-Shell an.
|
||||||
|
navigateFallback: "/index.html",
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
// Zuletzt geladene Geräte/Dienste/Kategorien bleiben offline verfügbar
|
||||||
|
// (NetworkFirst: frische Daten, wenn erreichbar, sonst letzter Cache-Stand).
|
||||||
|
urlPattern: ({ url }: { url: URL }) => url.pathname.startsWith("/api/"),
|
||||||
|
handler: "NetworkFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "launchpad-api-cache",
|
||||||
|
networkTimeoutSeconds: 3,
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: process.env.BACKEND_URL ?? "http://localhost:3001",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/backend/Dockerfile
|
||||||
|
container_name: launchpad-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: ./.env
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
- DATABASE_PATH=/data/launchpad.db
|
||||||
|
- PORT=3001
|
||||||
|
- HOST=0.0.0.0
|
||||||
|
- NODE_ENV=production
|
||||||
|
volumes:
|
||||||
|
- launchpad-data:/data
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/frontend/Dockerfile
|
||||||
|
container_name: launchpad-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
launchpad-data:
|
||||||
|
name: launchpad-data
|
||||||
8
docker/README.md
Normal file
8
docker/README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# docker/
|
||||||
|
|
||||||
|
Die eigentlichen Dockerfiles liegen bei ihren Apps (`apps/backend/Dockerfile`,
|
||||||
|
`apps/frontend/Dockerfile`), die Orchestrierung im root-`docker-compose.yml`.
|
||||||
|
|
||||||
|
Dieses Verzeichnis ist für gemeinsam genutzte Docker-Hilfsdateien vorgesehen,
|
||||||
|
z. B. zukünftige `docker-compose.override.yml` für lokale Entwicklung, oder
|
||||||
|
gemeinsame Healthcheck-/Entrypoint-Skripte für Scanner-Plugins.
|
||||||
98
docs/ROADMAP.md
Normal file
98
docs/ROADMAP.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
Geplante Reihenfolge der nächsten Commits, aufbauend auf dem lauffähigen
|
||||||
|
Grundgerüst aus Commit 1.
|
||||||
|
|
||||||
|
## ✅ Commit 2 — Geräte- & Dienste-API (erledigt)
|
||||||
|
|
||||||
|
- REST-Endpunkte (Fastify) für `GET/POST/PATCH/DELETE` auf `devices` und `services`
|
||||||
|
- Zod-Validierung der Request-Bodies (`packages/shared/src/schemas.ts`)
|
||||||
|
- Repository-Layer über Drizzle (`apps/backend/src/db/repositories`)
|
||||||
|
|
||||||
|
## ✅ Commit 3 — Suche im Frontend (erledigt)
|
||||||
|
|
||||||
|
- Anbindung von `rankServices` aus `packages/shared` an echte Backend-Daten
|
||||||
|
- TanStack Query für Datenhaltung (`apps/frontend/src/hooks/useServices.ts`)
|
||||||
|
- Tastatur-Navigation (Pfeiltasten wählen, Enter öffnet Dienst in neuem Tab, Escape leert Suche)
|
||||||
|
- Neue UI-Komponente `ResultsList` in `packages/ui`
|
||||||
|
|
||||||
|
> TanStack Router wird bewusst erst mit dem Adminbereich (Commit 6) eingeführt,
|
||||||
|
> da es vorher keine zweite Route gibt, die er sinnvoll verwalten könnte.
|
||||||
|
|
||||||
|
## ✅ Commit 4 — Kategorien & Favoriten (erledigt)
|
||||||
|
|
||||||
|
- Kategorien-API: Erstellen, Umbenennen, Löschen, Bulk-Reorder (`PATCH /api/categories/reorder`)
|
||||||
|
- Löschen einer Kategorie setzt `category` bei betroffenen Diensten auf `null`,
|
||||||
|
löscht die Dienste aber nicht
|
||||||
|
- Favoriten-Toggle direkt in der Trefferliste des Frontends (Stern anklicken,
|
||||||
|
per `useMutation` + Cache-Invalidierung)
|
||||||
|
|
||||||
|
> Die eigentliche Kategorien-*Verwaltungsoberfläche* (Erstellen/Umbenennen/
|
||||||
|
> Drag & Drop in der UI) wandert in Commit 6 (Adminbereich) – die Startseite
|
||||||
|
> bleibt bewusst die minimalistische Suche, kein Verwaltungs-UI dort.
|
||||||
|
|
||||||
|
## ✅ Commit 5 — Scanner (erledigt)
|
||||||
|
|
||||||
|
- `apps/backend/src/scanner/`: DNS-Kandidaten-Auflösung, TCP-Portscan (80, 443 +
|
||||||
|
typische Ports), HTTP-Titel-/Favicon-Extraktion, Softwareerkennung per
|
||||||
|
Signatur-Liste, FritzBox-TR-064-Client (inkl. selbst implementierter
|
||||||
|
HTTP-Digest-Authentifizierung)
|
||||||
|
- `POST /api/scan/devices/:id` und `POST /api/scan/fritzbox` – ausschließlich
|
||||||
|
manuell auslösbar, kein automatischer/zeitgesteuerter Scan
|
||||||
|
- `upsertDeviceFromScan` / `upsertServiceFromScan` in den Repositories:
|
||||||
|
garantiert, dass Benutzerfelder (displayName, category, favorite, order,
|
||||||
|
alias, icon) bei erneuten Scans nie überschrieben werden – End-to-End getestet
|
||||||
|
(Service manuell umbenannt/kategorisiert/favorisiert, erneut gescannt,
|
||||||
|
Werte blieben erhalten)
|
||||||
|
|
||||||
|
> Alle Module wurden gegen echte, lokal gestartete Test-Server verifiziert
|
||||||
|
> (Port-Erkennung, Titel/Favicon-Parsing, Softwareerkennung, kompletter
|
||||||
|
> FritzBox-SOAP-/Digest-Auth-Ablauf inkl. Fehlerfall bei falschem Passwort).
|
||||||
|
> Ein Test gegen eine echte FritzBox war in dieser Umgebung nicht möglich.
|
||||||
|
|
||||||
|
## ✅ Commit 6 — Adminbereich (erledigt)
|
||||||
|
|
||||||
|
- TanStack Router (code-basiert): `/` (Startseite) + `/admin/*`
|
||||||
|
- Menüpunkte wie spezifiziert: Dashboard, Geräte, Dienste, Scanner, Kategorien,
|
||||||
|
Plugins, Einstellungen, Logs
|
||||||
|
- Geräte-Seite: anlegen, löschen, **„Jetzt scannen"-Button** pro Gerät
|
||||||
|
- Dienste-Seite: Inline-Bearbeitung von Name/Kategorie/Alias/Reihenfolge, Favoriten-Toggle
|
||||||
|
- Kategorien-Seite: anlegen, umbenennen, löschen, **natives Drag & Drop** (kein
|
||||||
|
zusätzliches Package nötig)
|
||||||
|
- Scanner-Seite: FritzBox-Trigger + Sammel-Scan aller Geräte
|
||||||
|
- Logs-Seite: Scan-Historie (neue `scan_logs`-Tabelle, jeder Scan-Versuch wird protokolliert)
|
||||||
|
- Einstellungen: Live-Backend-Status + Theme-Umschalter
|
||||||
|
- Plugins: ehrlicher Hinweis, dass das Plugin-System noch nicht existiert
|
||||||
|
(statt einer vorgetäuschten Funktion)
|
||||||
|
|
||||||
|
> Verifiziert per Docker-Build-Simulation (Frontend + Backend inkl.
|
||||||
|
> `pnpm deploy --prod`) und Backend-Smoke-Test aller von den Admin-Seiten
|
||||||
|
> verwendeten Endpunkte. Die UI selbst konnte in dieser Umgebung nicht in
|
||||||
|
> einem echten Browser durchgeklickt werden (kein Browser-Tool verfügbar) –
|
||||||
|
> Verifikation stützt sich auf erfolgreichen TypeScript-/Vite-Build und
|
||||||
|
> Codeprüfung.
|
||||||
|
|
||||||
|
## ✅ Commit 7 — PWA (erledigt)
|
||||||
|
|
||||||
|
- `vite-plugin-pwa` mit generiertem Manifest (`manifest.webmanifest`) und
|
||||||
|
Service Worker (Workbox, `generateSW`-Modus)
|
||||||
|
- Echte Icons (192/512, jeweils normal + maskable, `apple-touch-icon.png`,
|
||||||
|
`favicon.svg`) unter `apps/frontend/public/`
|
||||||
|
- App-Shell wird precached; `/api/*` läuft über `NetworkFirst` mit 3s-Timeout
|
||||||
|
und Cache-Fallback – Suche funktioniert damit auch offline mit dem zuletzt
|
||||||
|
geladenen Datenstand
|
||||||
|
- `nginx.conf`: `sw.js`/`manifest.webmanifest` explizit von der 7-Tage-Cache-Regel
|
||||||
|
ausgenommen (sonst kommen Updates bei installierten Nutzern nie an), korrekter
|
||||||
|
MIME-Type für das Manifest gesetzt
|
||||||
|
|
||||||
|
> Verifiziert: Manifest gegen Installierbarkeits-Anforderungen geprüft (512er-Icon,
|
||||||
|
> maskable Icon, alle Pflichtfelder vorhanden), alle Assets über einen echten
|
||||||
|
> HTTP-Server abgerufen (200 OK, korrekte Content-Types), generierter Service-Worker-
|
||||||
|
> Code inspiziert (Precache-Liste + NetworkFirst-Route bestätigt), vollständige
|
||||||
|
> Docker-Build-Simulation durchlaufen. Die tatsächliche "Zum Homescreen hinzufügen"-
|
||||||
|
> Installation konnte mangels Browser in dieser Umgebung nicht getestet werden.
|
||||||
|
|
||||||
|
## Commit 8 — Plugin-System
|
||||||
|
|
||||||
|
- Plugin-Schnittstelle zum Registrieren von Scannern, Import von Geräten,
|
||||||
|
Erweitern von Menüs, Bereitstellen von Icons
|
||||||
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "launchpad",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "LaunchPad – ein schneller, minimalistischer Homelab-Launcher",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm --parallel --filter ./apps/* dev",
|
||||||
|
"dev:backend": "pnpm --filter @launchpad/backend dev",
|
||||||
|
"dev:frontend": "pnpm --filter @launchpad/frontend dev",
|
||||||
|
"build": "pnpm --filter @launchpad/shared build && pnpm --filter @launchpad/ui build && pnpm --filter @launchpad/backend build && pnpm --filter @launchpad/frontend build",
|
||||||
|
"build:shared": "pnpm --filter @launchpad/shared build",
|
||||||
|
"build:ui": "pnpm --filter @launchpad/ui build",
|
||||||
|
"build:backend": "pnpm --filter @launchpad/backend build",
|
||||||
|
"build:frontend": "pnpm --filter @launchpad/frontend build",
|
||||||
|
"typecheck": "pnpm -r typecheck",
|
||||||
|
"lint": "pnpm -r lint"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.7.0"
|
||||||
|
}
|
||||||
18
packages/shared/package.json
Normal file
18
packages/shared/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@launchpad/shared",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
117
packages/shared/src/index.ts
Normal file
117
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* @launchpad/shared
|
||||||
|
*
|
||||||
|
* Gemeinsame Typen und Logik, die sowohl vom Backend (apps/backend)
|
||||||
|
* als auch vom Frontend (apps/frontend) verwendet werden.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./schemas.js";
|
||||||
|
|
||||||
|
|
||||||
|
export interface Device {
|
||||||
|
id: string;
|
||||||
|
hostname: string;
|
||||||
|
ip: string;
|
||||||
|
mac: string | null;
|
||||||
|
manufacturer: string | null;
|
||||||
|
model: string | null;
|
||||||
|
online: boolean;
|
||||||
|
source: DeviceSource;
|
||||||
|
lastScan: string | null; // ISO-8601 Zeitstempel
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeviceSource = "fritzbox" | "dns" | "http" | "https" | "portscan" | "manual";
|
||||||
|
|
||||||
|
export interface Service {
|
||||||
|
id: string;
|
||||||
|
deviceId: string;
|
||||||
|
displayName: string;
|
||||||
|
hostname: string;
|
||||||
|
url: string;
|
||||||
|
https: boolean;
|
||||||
|
port: number;
|
||||||
|
category: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
favicon: string | null;
|
||||||
|
description: string | null;
|
||||||
|
favorite: boolean;
|
||||||
|
alias: string[];
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthStatus {
|
||||||
|
status: "ok" | "error";
|
||||||
|
timestamp: string;
|
||||||
|
uptimeSeconds: number;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanLogEntry {
|
||||||
|
id: string;
|
||||||
|
type: "device" | "fritzbox";
|
||||||
|
targetId: string | null;
|
||||||
|
level: "info" | "error";
|
||||||
|
message: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ranking-Stufen für die Suche, gemäß Spezifikation:
|
||||||
|
* 1. Displayname beginnt mit Suchtext
|
||||||
|
* 2. Alias beginnt mit Suchtext
|
||||||
|
* 3. Hostname beginnt mit Suchtext
|
||||||
|
* 4. Displayname enthält Suchtext
|
||||||
|
* 5. Alias enthält Suchtext
|
||||||
|
* 6. Beschreibung enthält Suchtext
|
||||||
|
*
|
||||||
|
* Niedrigere Werte sind relevanter. `null` bedeutet: kein Treffer.
|
||||||
|
*/
|
||||||
|
export function rankService(service: Service, query: string): number | null {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (q.length === 0) return null;
|
||||||
|
|
||||||
|
const displayName = service.displayName.toLowerCase();
|
||||||
|
const hostname = service.hostname.toLowerCase();
|
||||||
|
const description = (service.description ?? "").toLowerCase();
|
||||||
|
const alias = service.alias.map((a) => a.toLowerCase());
|
||||||
|
|
||||||
|
if (displayName.startsWith(q)) return 1;
|
||||||
|
if (alias.some((a) => a.startsWith(q))) return 2;
|
||||||
|
if (hostname.startsWith(q)) return 3;
|
||||||
|
if (displayName.includes(q)) return 4;
|
||||||
|
if (alias.some((a) => a.includes(q))) return 5;
|
||||||
|
if (description.includes(q)) return 6;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sortiert und filtert eine Liste von Diensten anhand des Suchtexts.
|
||||||
|
* Favoriten werden bei gleichem Rang bevorzugt, danach die definierte Reihenfolge.
|
||||||
|
*/
|
||||||
|
export function rankServices(services: Service[], query: string): Service[] {
|
||||||
|
const q = query.trim();
|
||||||
|
|
||||||
|
if (q.length === 0) {
|
||||||
|
return [...services].sort((a, b) => {
|
||||||
|
if (a.favorite !== b.favorite) return a.favorite ? -1 : 1;
|
||||||
|
return a.order - b.order;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return services
|
||||||
|
.map((service) => ({ service, rank: rankService(service, q) }))
|
||||||
|
.filter((entry): entry is { service: Service; rank: number } => entry.rank !== null)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.rank !== b.rank) return a.rank - b.rank;
|
||||||
|
if (a.service.favorite !== b.service.favorite) return a.service.favorite ? -1 : 1;
|
||||||
|
return a.service.order - b.service.order;
|
||||||
|
})
|
||||||
|
.map((entry) => entry.service);
|
||||||
|
}
|
||||||
67
packages/shared/src/schemas.ts
Normal file
67
packages/shared/src/schemas.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const deviceSourceValues = [
|
||||||
|
"fritzbox",
|
||||||
|
"dns",
|
||||||
|
"http",
|
||||||
|
"https",
|
||||||
|
"portscan",
|
||||||
|
"manual",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const DeviceSourceSchema = z.enum(deviceSourceValues);
|
||||||
|
|
||||||
|
export const DeviceCreateSchema = z.object({
|
||||||
|
hostname: z.string().min(1, "hostname darf nicht leer sein"),
|
||||||
|
ip: z.string().min(1, "ip darf nicht leer sein"),
|
||||||
|
mac: z.string().optional(),
|
||||||
|
manufacturer: z.string().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
online: z.boolean().optional(),
|
||||||
|
source: DeviceSourceSchema.optional(),
|
||||||
|
});
|
||||||
|
export type DeviceCreateInput = z.infer<typeof DeviceCreateSchema>;
|
||||||
|
|
||||||
|
export const DeviceUpdateSchema = DeviceCreateSchema.partial();
|
||||||
|
export type DeviceUpdateInput = z.infer<typeof DeviceUpdateSchema>;
|
||||||
|
|
||||||
|
export const ServiceCreateSchema = z.object({
|
||||||
|
deviceId: z.string().min(1, "deviceId darf nicht leer sein"),
|
||||||
|
displayName: z.string().min(1, "displayName darf nicht leer sein"),
|
||||||
|
hostname: z.string().min(1, "hostname darf nicht leer sein"),
|
||||||
|
url: z.string().url("url muss eine gültige URL sein"),
|
||||||
|
https: z.boolean().optional(),
|
||||||
|
port: z.number().int().min(1).max(65535),
|
||||||
|
category: z.string().optional(),
|
||||||
|
icon: z.string().optional(),
|
||||||
|
favicon: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
favorite: z.boolean().optional(),
|
||||||
|
alias: z.array(z.string()).optional(),
|
||||||
|
order: z.number().optional(),
|
||||||
|
});
|
||||||
|
export type ServiceCreateInput = z.infer<typeof ServiceCreateSchema>;
|
||||||
|
|
||||||
|
export const ServiceUpdateSchema = ServiceCreateSchema.omit({
|
||||||
|
deviceId: true,
|
||||||
|
}).partial();
|
||||||
|
export type ServiceUpdateInput = z.infer<typeof ServiceUpdateSchema>;
|
||||||
|
|
||||||
|
export const CategoryCreateSchema = z.object({
|
||||||
|
name: z.string().min(1, "name darf nicht leer sein"),
|
||||||
|
});
|
||||||
|
export type CategoryCreateInput = z.infer<typeof CategoryCreateSchema>;
|
||||||
|
|
||||||
|
export const CategoryUpdateSchema = CategoryCreateSchema.partial();
|
||||||
|
export type CategoryUpdateInput = z.infer<typeof CategoryUpdateSchema>;
|
||||||
|
|
||||||
|
/** Für Drag & Drop: neue Reihenfolge mehrerer Kategorien auf einmal setzen. */
|
||||||
|
export const CategoryReorderSchema = z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
order: z.number(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1, "mindestens ein Eintrag erforderlich");
|
||||||
|
export type CategoryReorderInput = z.infer<typeof CategoryReorderSchema>;
|
||||||
8
packages/shared/tsconfig.json
Normal file
8
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
20
packages/ui/package.json
Normal file
20
packages/ui/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "@launchpad/ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@launchpad/shared": "workspace:*",
|
||||||
|
"react": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
38
packages/ui/src/Button.tsx
Normal file
38
packages/ui/src/Button.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { forwardRef, type ButtonHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||||
|
size?: "sm" | "md";
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT_CLASSES: Record<NonNullable<ButtonProps["variant"]>, string> = {
|
||||||
|
primary:
|
||||||
|
"bg-black text-white hover:bg-black/80 dark:bg-white dark:text-black dark:hover:bg-white/80",
|
||||||
|
secondary:
|
||||||
|
"bg-black/5 text-black hover:bg-black/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/20",
|
||||||
|
danger: "bg-red-500/10 text-red-600 hover:bg-red-500/20 dark:text-red-400",
|
||||||
|
ghost:
|
||||||
|
"bg-transparent text-black/60 hover:bg-black/5 dark:text-white/60 dark:hover:bg-white/10",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIZE_CLASSES: Record<NonNullable<ButtonProps["size"]>, string> = {
|
||||||
|
sm: "px-3 py-1.5 text-sm",
|
||||||
|
md: "px-4 py-2 text-sm",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ variant = "secondary", size = "md", className = "", disabled, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`inline-flex items-center justify-center gap-2 rounded-lg font-medium
|
||||||
|
transition-colors disabled:cursor-not-allowed disabled:opacity-50
|
||||||
|
${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]} ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Button.displayName = "Button";
|
||||||
114
packages/ui/src/ResultsList.tsx
Normal file
114
packages/ui/src/ResultsList.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import type { KeyboardEvent } from "react";
|
||||||
|
import type { Service } from "@launchpad/shared";
|
||||||
|
|
||||||
|
export interface ResultsListProps {
|
||||||
|
services: Service[];
|
||||||
|
selectedIndex: number;
|
||||||
|
emptyLabel?: string;
|
||||||
|
onHover: (index: number) => void;
|
||||||
|
onOpen: (service: Service) => void;
|
||||||
|
onToggleFavorite?: (service: Service) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt die (bereits per rankServices sortierten) Suchtreffer an.
|
||||||
|
* Die Tastatur-Navigation (Pfeiltasten/Enter) wird vom Elternelement
|
||||||
|
* gesteuert; diese Komponente ist rein darstellend + klick-/tastaturbar.
|
||||||
|
*/
|
||||||
|
export function ResultsList({
|
||||||
|
services,
|
||||||
|
selectedIndex,
|
||||||
|
emptyLabel = "Keine Dienste gefunden.",
|
||||||
|
onHover,
|
||||||
|
onOpen,
|
||||||
|
onToggleFavorite,
|
||||||
|
}: ResultsListProps) {
|
||||||
|
if (services.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mt-4 rounded-2xl border border-black/5 bg-white/50 px-5 py-8 text-center
|
||||||
|
text-sm text-black/40 dark:border-white/5 dark:bg-white/5 dark:text-white/40"
|
||||||
|
>
|
||||||
|
{emptyLabel}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
role="listbox"
|
||||||
|
className="mt-4 flex max-h-[60vh] flex-col overflow-y-auto rounded-2xl border
|
||||||
|
border-black/10 bg-white/80 shadow-lg backdrop-blur-md dark:border-white/10
|
||||||
|
dark:bg-white/5"
|
||||||
|
>
|
||||||
|
{services.map((service, index) => {
|
||||||
|
const active = index === selectedIndex;
|
||||||
|
return (
|
||||||
|
<li key={service.id} role="option" aria-selected={active}>
|
||||||
|
<div
|
||||||
|
tabIndex={-1}
|
||||||
|
onMouseEnter={() => onHover(index)}
|
||||||
|
onClick={() => onOpen(service)}
|
||||||
|
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.key === "Enter") onOpen(service);
|
||||||
|
}}
|
||||||
|
className={`flex w-full cursor-pointer items-center gap-3 px-5 py-3 text-left
|
||||||
|
transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-black/5 dark:bg-white/10"
|
||||||
|
: "hover:bg-black/[0.03] dark:hover:bg-white/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{service.favicon ? (
|
||||||
|
<img src={service.favicon} alt="" className="h-5 w-5 shrink-0 rounded" />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded
|
||||||
|
bg-black/10 text-[10px] font-medium text-black/50 dark:bg-white/10
|
||||||
|
dark:text-white/50"
|
||||||
|
>
|
||||||
|
{service.displayName.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-medium text-black dark:text-white">
|
||||||
|
{service.displayName}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-black/40 dark:text-white/40">
|
||||||
|
{service.hostname}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{service.category ? (
|
||||||
|
<span className="shrink-0 text-xs text-black/30 dark:text-white/30">
|
||||||
|
{service.category}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleFavorite?.(service);
|
||||||
|
}}
|
||||||
|
disabled={!onToggleFavorite}
|
||||||
|
aria-pressed={service.favorite}
|
||||||
|
aria-label={
|
||||||
|
service.favorite ? "Als Favorit entfernen" : "Als Favorit markieren"
|
||||||
|
}
|
||||||
|
className={`shrink-0 text-lg leading-none transition-colors ${
|
||||||
|
service.favorite
|
||||||
|
? "text-amber-500"
|
||||||
|
: "text-black/15 hover:text-amber-400 dark:text-white/15 dark:hover:text-amber-400"
|
||||||
|
} ${onToggleFavorite ? "" : "cursor-default"}`}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
packages/ui/src/SearchInput.tsx
Normal file
54
packages/ui/src/SearchInput.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { forwardRef, type InputHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
export interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||||
|
/** Wird links im Suchfeld angezeigt, z. B. ein Tastaturkürzel-Hinweis. */
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrales Sucheingabefeld im Raycast/Spotlight-Stil.
|
||||||
|
* Bewusst schlicht gehalten: großer Text, viel Weißraum, keine Ablenkung.
|
||||||
|
*/
|
||||||
|
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||||
|
({ hint, className = "", ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 rounded-2xl border border-black/10 bg-white/80
|
||||||
|
px-5 py-4 shadow-lg backdrop-blur-md transition-colors
|
||||||
|
focus-within:border-black/20 dark:border-white/10 dark:bg-white/5
|
||||||
|
dark:focus-within:border-white/20 ${className}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="h-5 w-5 shrink-0 text-black/40 dark:text-white/40"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="w-full bg-transparent text-lg text-black outline-none placeholder:text-black/30
|
||||||
|
dark:text-white dark:placeholder:text-white/30"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{hint ? (
|
||||||
|
<span className="shrink-0 rounded-md border border-black/10 px-1.5 py-0.5 text-xs
|
||||||
|
text-black/40 dark:border-white/10 dark:text-white/40">
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
SearchInput.displayName = "SearchInput";
|
||||||
21
packages/ui/src/StatusBadge.tsx
Normal file
21
packages/ui/src/StatusBadge.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export interface StatusBadgeProps {
|
||||||
|
online: boolean;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kleiner Statuspunkt (online/offline), z. B. für den Backend-Health-Check
|
||||||
|
* oder später für einzelne Dienste.
|
||||||
|
*/
|
||||||
|
export function StatusBadge({ online, label }: StatusBadgeProps) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-2 text-sm text-black/60 dark:text-white/60">
|
||||||
|
<span
|
||||||
|
className={`h-2 w-2 rounded-full ${
|
||||||
|
online ? "bg-emerald-500" : "bg-red-500"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{label ?? (online ? "Online" : "Offline")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
packages/ui/src/index.ts
Normal file
11
packages/ui/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export { SearchInput } from "./SearchInput.js";
|
||||||
|
export type { SearchInputProps } from "./SearchInput.js";
|
||||||
|
|
||||||
|
export { StatusBadge } from "./StatusBadge.js";
|
||||||
|
export type { StatusBadgeProps } from "./StatusBadge.js";
|
||||||
|
|
||||||
|
export { ResultsList } from "./ResultsList.js";
|
||||||
|
export type { ResultsListProps } from "./ResultsList.js";
|
||||||
|
|
||||||
|
export { Button } from "./Button.js";
|
||||||
|
export type { ButtonProps } from "./Button.js";
|
||||||
11
packages/ui/tsconfig.json
Normal file
11
packages/ui/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
6303
pnpm-lock.yaml
generated
Normal file
6303
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
18
tsconfig.base.json
Normal file
18
tsconfig.base.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user