Compare commits

..

14 Commits

Author SHA1 Message Date
6a7eb3dfe5 Commit 7: PWA (Manifest, Service Worker, Icons, Offline-Grundfunktion) 2026-07-19 03:13:55 +02:00
63af09f43a Commit 6: Adminbereich (TanStack Router, 8 Menüpunkte, Scan-Logs) 2026-07-19 03:13:23 +02:00
7ae5a353cd Commit 7: PWA (Manifest, Service Worker, Icons, Offline-Grundfunktion) 2026-07-19 03:07:21 +02:00
2f31996bbb Commit 6: Adminbereich (TanStack Router, 8 Menüpunkte, Scan-Logs) 2026-07-19 02:53:04 +02:00
b0ff551ca0 Zentrale .env-Konfiguration für alle Umgebungsvariablen 2026-07-19 02:35:29 +02:00
28bccb9d16 Commit 5: Scanner-Engine (DNS, Portscan, Titel/Favicon, Softwareerkennung, FritzBox TR-064) 2026-07-19 02:22:54 +02:00
129b4253b5 Commit 4: Kategorien-API (CRUD + Reorder) und Favoriten-Toggle im Frontend 2026-07-19 02:10:08 +02:00
f963f7eb4e Commit 3: Suche im Frontend mit echten Backend-Daten, Ranking, Tastatur-Navigation 2026-07-19 02:00:13 +02:00
a5b998b421 Commit 2: REST-API für Geräte und Dienste (Zod-Validierung, Drizzle-Repositories) 2026-07-19 01:53:43 +02:00
3a7f423f60 Fix: tsconfig.base.json im Docker-Build verfügbar machen, react als echte Dependency in packages/ui 2026-07-19 01:21:25 +02:00
7b8723729b Erstes lauffähiges Grundgerüst: Monorepo, Fastify-API, React-Startseite, Docker 2026-07-19 01:14:29 +02:00
657496fe49 Sprint 1 - Project scaffold 2026-07-19 00:44:54 +02:00
ab49999649 Initial project structure 2026-07-19 00:30:44 +02:00
affb9aef16 Initial commit 2026-07-19 00:26:38 +02:00
80 changed files with 10559 additions and 0 deletions

41
.env.example Normal file
View 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
View 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
View 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
View 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"]

View 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
View 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"
}
}

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

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

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

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

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

View 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
View 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
View 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();

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

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

View 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",
};
});
}

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

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

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

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

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

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

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

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

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

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

View 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
View 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
View 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
View 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";
}
}

View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

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

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

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

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

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

View 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];
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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;

View 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"]
}

View 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
View 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
View 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
View 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
View 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"
}

View 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"
}
}

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

View 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>;

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

20
packages/ui/package.json Normal file
View 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"
}
}

View 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";

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

View 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";

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"

18
tsconfig.base.json Normal file
View 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
}
}