generated from Dicken/dickendock
Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50cab7ff0c | |||
| ee7bd9fcbf | |||
| 1e90eb2905 | |||
| 0a1052f217 | |||
| 2ed39d6865 | |||
| 3e27c54199 | |||
| 8046120548 | |||
| ff87f9812c | |||
| 538fba5fc3 | |||
| c05562c4cd | |||
| 9abfdba707 | |||
| fb5874d7d0 | |||
| eef4ddc775 | |||
| a7036849b1 | |||
| 403a052a0f | |||
| 0a1f3143d7 | |||
| 75bc5725e4 | |||
| ca8c8c1d3b | |||
| c7398ce2ba | |||
| edc4acaf3b | |||
| 2bc5d9550c | |||
| 8dd3115353 | |||
| 0287a7cc5c | |||
| cf31ea4402 | |||
| 28fedb2572 | |||
| 2e15669098 | |||
| eed5089a56 | |||
| 6792f5b7ea | |||
| 59bb4a30b8 | |||
| d4ff497c8c | |||
| 6457475e26 | |||
| 301c25571a | |||
| 505353cd26 | |||
| 5deeeaec1d | |||
| 38f09e862e | |||
| 45a01bd32e | |||
| b32a60be64 | |||
| 645dcf3af8 | |||
| fedb3e9127 | |||
| 70da526921 | |||
| ba057e7911 | |||
| e4cc6987be | |||
| 87fed21348 | |||
| 2106c17301 | |||
| f5845ec762 | |||
| 430b79763c | |||
| 0fc063b2a2 | |||
| 458f58af6f | |||
| 6a2bf24498 | |||
| d87885f16f | |||
| b098007742 | |||
| 6a66128172 | |||
| 68ca9ec5e1 | |||
| ec49534b60 | |||
| bae9e48fc1 | |||
| 050616a5cf | |||
| 7035d4ebda | |||
| 70edeeed38 | |||
| 2772c29036 | |||
| 1f8218dff1 | |||
| 54f224b650 | |||
| 5f150119b2 | |||
| 7cf64404c5 | |||
| 724f076c8c | |||
| 0580d46000 | |||
| cc4b285aea | |||
| 96cf29fef4 | |||
| dc91a9aba9 | |||
| 31e17ff77b | |||
| a695159b1d | |||
| fc1ea4fa70 | |||
| 20ea4d382d | |||
| d65897b8e1 | |||
| 986de50963 | |||
| 6a7eb3dfe5 | |||
| 63af09f43a | |||
| 7ae5a353cd | |||
| 2f31996bbb | |||
| b0ff551ca0 | |||
| 28bccb9d16 | |||
| 129b4253b5 | |||
| f963f7eb4e | |||
| a5b998b421 | |||
| 3a7f423f60 | |||
| 7b8723729b | |||
| 657496fe49 | |||
| ab49999649 | |||
| affb9aef16 |
52
.env.example
Normal file
52
.env.example
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# ─── HTTPS im lokalen Netz ──────────────────────────────────────────
|
||||||
|
# IP-Adresse oder Hostname, unter der du LaunchPad im LAN aufrufst
|
||||||
|
# (z. B. https://192.168.1.49:8443). Der Frontend-Container erzeugt daraus
|
||||||
|
# automatisch eine eigene lokale Root-CA + ein davon signiertes Zertifikat
|
||||||
|
# (komplett offline, keine externen Dienste). Die CA liegt danach zum
|
||||||
|
# Download unter http://<LAUNCHPAD_HOST>:8080/ca.crt bereit – einmal pro
|
||||||
|
# Gerät importiert/als vertrauenswürdig eingestuft, verschwindet die
|
||||||
|
# Browser-Warnung dauerhaft. Siehe README für die genauen Schritte.
|
||||||
|
# Ändern erfordert einen Neustart: docker compose up -d --force-recreate frontend
|
||||||
|
LAUNCHPAD_HOST=192.168.1.49
|
||||||
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
.DS_Store
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.tsbuildinfo
|
||||||
|
coverage/
|
||||||
364
README.md
Normal file
364
README.md
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
# 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 (HTTPS: https://localhost:8443, siehe unten)
|
||||||
|
- Backend / Health-API: http://localhost:3001/api/health
|
||||||
|
|
||||||
|
## HTTPS im lokalen Netz
|
||||||
|
|
||||||
|
LaunchPad ist per HTTPS auf Port `8443` erreichbar (`https://<deine-ip>:8443`),
|
||||||
|
zusätzlich zu HTTP auf `8080` – kein erzwungener Redirect, kein externer
|
||||||
|
Dienst beteiligt.
|
||||||
|
|
||||||
|
Der Frontend-Container erzeugt beim ersten Start automatisch eine **eigene
|
||||||
|
lokale Root-CA** (`LaunchPad Local CA`) und signiert damit ein Server-
|
||||||
|
Zertifikat für die in `.env` hinterlegte `LAUNCHPAD_HOST` (IP oder Hostname).
|
||||||
|
Beides liegt in einem Docker-Volume (`launchpad-certs`) und bleibt über
|
||||||
|
Neustarts und `--build` hinweg stabil – die CA wird nur einmal erzeugt, nicht
|
||||||
|
bei jedem Deploy neu.
|
||||||
|
|
||||||
|
**Ohne weiteren Schritt** funktioniert HTTPS sofort, der Browser zeigt aber
|
||||||
|
eine Warnung (unbekannte CA). Für eine echte, warnungsfreie Verbindung – und
|
||||||
|
damit Chrome die PWA auch wirklich "installiert" statt nur eine Verknüpfung
|
||||||
|
anzulegen – reicht **ein einziger, pro Gerät einmaliger Schritt**: die
|
||||||
|
Root-CA importieren. Es gibt dafür keinen Weg drumherum, der nicht doch einen
|
||||||
|
externen Dienst braucht (siehe Kasten unten) – aber so ist es so einfach wie
|
||||||
|
möglich gehalten.
|
||||||
|
|
||||||
|
### Root-CA einmal pro Gerät importieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env: IP/Hostname eintragen, unter der du zugreifst
|
||||||
|
LAUNCHPAD_HOST=192.168.1.49
|
||||||
|
|
||||||
|
docker compose up -d --force-recreate frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
**Am Handy (Android):** `http://192.168.1.49:8080/ca.crt` im Browser öffnen
|
||||||
|
(bewusst HTTP, dafür braucht's noch kein Vertrauen) → Android bietet direkt
|
||||||
|
"Zertifikat installieren" an → als "VPN und App" oder "WLAN"-Zertifikat
|
||||||
|
speichern (je nach Android-Version heißt die Kategorie leicht anders,
|
||||||
|
Hauptsache es landet unter "Von Nutzer installierte Zertifikate"). Danach ggf.
|
||||||
|
kurz Chrome neu starten.
|
||||||
|
|
||||||
|
**Am PC (Windows):** dieselbe URL im Browser öffnen, `ca.crt` herunterladen,
|
||||||
|
Datei doppelklicken → "Zertifikat installieren" → "Lokaler Computer" →
|
||||||
|
"Alle Zertifikate in folgendem Speicher speichern" → "Vertrauenswürdige
|
||||||
|
Stammzertifizierungsstellen" → Fertigstellen. Kein zusätzliches Tool nötig.
|
||||||
|
|
||||||
|
**macOS:** `ca.crt` herunterladen, doppelklicken (öffnet Schlüsselbundverwaltung),
|
||||||
|
dort öffnen, Vertrauenseinstellung auf "Immer vertrauen" setzen.
|
||||||
|
|
||||||
|
Danach zeigt `https://192.168.1.49:8443` keine Warnung mehr, und die
|
||||||
|
PWA-Installation sollte funktionieren.
|
||||||
|
|
||||||
|
> **Warum es keinen Weg ganz ohne diesen Schritt gibt:** Damit ein Browser
|
||||||
|
> einem Zertifikat *automatisch*, ohne jeden manuellen Trust-Schritt, vertraut,
|
||||||
|
> muss es entweder von einer öffentlich vorinstallierten CA stammen (z. B.
|
||||||
|
> Let's Encrypt) – das erfordert zwangsläufig einen externen Dienst und meist
|
||||||
|
> einen öffentlich auflösbaren Domainnamen – oder eben eine lokale CA, die du
|
||||||
|
> einmal manuell für vertrauenswürdig erklärst. Es gibt keine dritte Option.
|
||||||
|
> Diese Lösung minimiert den Aufwand auf "eine Datei öffnen, einmal pro Gerät",
|
||||||
|
> mehr geht ohne externe Dienste nicht.
|
||||||
|
|
||||||
|
### Zertifikat wechseln / eigenes einbinden
|
||||||
|
|
||||||
|
Falls du lieber ein eigenes Zertifikat verwendest (z. B. von einer internen
|
||||||
|
CA, die du schon hast): `fullchain.pem` + `privkey.pem` ins Volume
|
||||||
|
`launchpad-certs` legen (z. B. `docker cp fullchain.pem launchpad-frontend:/etc/nginx/certs/` bei laufendem Container, oder das Volume direkt mounten),
|
||||||
|
danach `docker compose restart frontend`. Liegt dabei keine `ca-cert.pem` im
|
||||||
|
selben Verzeichnis, generiert der Entrypoint nichts automatisch nach.
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
- ✅ Plugin-System: echte, ladbare Plugins unter `apps/backend/plugins/*`
|
||||||
|
(Bind-Mount, kein Rebuild nötig). Plugins können die Softwareerkennung des
|
||||||
|
Scanners erweitern (inkl. eigenem Icon) und eigene Geräte-Importquellen
|
||||||
|
bereitstellen. Zwei funktionierende Beispiel-Plugins liegen bei.
|
||||||
|
|
||||||
|
Noch **nicht** enthalten:
|
||||||
|
|
||||||
|
- shadcn/ui, React Hook Form (aktuell einfache kontrollierte Formulare)
|
||||||
|
- Eigene Admin-Routen/Menüpunkte pro Plugin (aktuell gesammelt auf einer Seite)
|
||||||
|
|
||||||
|
### 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/reorder Body: [{ id, order }, ...]
|
||||||
|
PATCH /api/services/:id
|
||||||
|
DELETE /api/services/:id
|
||||||
|
|
||||||
|
GET /api/bookmarks
|
||||||
|
POST /api/bookmarks Titel/Favicon/Beschreibung werden automatisch
|
||||||
|
geladen, falls nicht angegeben
|
||||||
|
PATCH /api/bookmarks/reorder Body: [{ id, order }, ...]
|
||||||
|
PATCH /api/bookmarks/:id
|
||||||
|
DELETE /api/bookmarks/:id
|
||||||
|
|
||||||
|
GET /api/categories
|
||||||
|
POST /api/categories optional color (Hex, z. B. "#3b82f6")
|
||||||
|
PATCH /api/categories/reorder Body: [{ id, order }, ...]
|
||||||
|
PATCH /api/categories/:id Umbenennen (aktualisiert automatisch alle Dienste mit altem Namen)
|
||||||
|
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; Antwort enthält
|
||||||
|
zusätzlich staleServices (nicht mehr gefundene
|
||||||
|
Dienste), nameChanges (abweichender Name/
|
||||||
|
Kategorie ggü. gespeichertem Wert) und
|
||||||
|
deviceNameSuggestion (per Reverse-DNS) -
|
||||||
|
nichts davon wird automatisch übernommen,
|
||||||
|
nur zur Bestätigung im Adminbereich zurückgegeben
|
||||||
|
POST /api/scan/fritzbox Liest Geräteliste der FritzBox per TR-064;
|
||||||
|
Antwort enthält found (rohe Einträge) und
|
||||||
|
uniqueDevices (nach MAC/IP zusammengeführt,
|
||||||
|
kann kleiner als found sein - kein Datenverlust),
|
||||||
|
zusätzlich staleDevices und deviceChanges
|
||||||
|
(abweichender Name/IP ggü. gespeichertem Wert,
|
||||||
|
nichts davon wird automatisch übernommen)
|
||||||
|
|
||||||
|
GET /api/logs optional ?limit= (Default 100, Max 500)
|
||||||
|
|
||||||
|
GET /api/transfer/export ?format=csv|xlsx|json (Default csv). Geräte
|
||||||
|
UND Dienste in einer Datei, Spalte "type"
|
||||||
|
unterscheidet die Zeilen.
|
||||||
|
POST /api/transfer/import Body: { format, content: base64 }. Legt nur
|
||||||
|
neue Geräte/Dienste an, überschreibt/
|
||||||
|
dupliziert nie Bestehendes.
|
||||||
|
|
||||||
|
GET /api/recent-visits optional ?limit=
|
||||||
|
POST /api/recent-visits Body: { itemType: "service"|"bookmark", itemId }
|
||||||
|
DELETE /api/recent-visits
|
||||||
|
|
||||||
|
GET /api/settings
|
||||||
|
PATCH /api/settings Body: { recentVisitsLimit }
|
||||||
|
|
||||||
|
GET /api/read-later
|
||||||
|
POST /api/read-later Titel/Favicon werden automatisch geladen
|
||||||
|
PATCH /api/read-later/:id
|
||||||
|
DELETE /api/read-later/:id
|
||||||
|
POST /api/read-later/:id/promote verschiebt zu Lesezeichen (als Favorit)
|
||||||
|
|
||||||
|
POST /api/reset Löscht ALLE Geräte + Dienste (Cascade). Erfordert
|
||||||
|
Body { "confirm": true }, sonst 400.
|
||||||
|
|
||||||
|
GET /api/plugins geladene Plugins mit Capabilities
|
||||||
|
POST /api/plugins/:name/import löst importDevices() eines Plugins aus
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend-Routen
|
||||||
|
|
||||||
|
```
|
||||||
|
/ Startseite: minimalistische Suche
|
||||||
|
/admin -> redirect zu /admin/dashboard
|
||||||
|
/admin/dashboard
|
||||||
|
/admin/devices
|
||||||
|
/admin/services
|
||||||
|
/admin/bookmarks
|
||||||
|
/admin/categories
|
||||||
|
/admin/scanner
|
||||||
|
/admin/plugins Hinweis: Plugin-System noch nicht gebaut
|
||||||
|
/admin/settings
|
||||||
|
/admin/logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Als Browser-Suchmaschine einrichten
|
||||||
|
|
||||||
|
LaunchPad lässt sich als eigene Suchmaschine mit Kürzel in Chrome, Edge und
|
||||||
|
Firefox hinterlegen – danach reicht z. B. `lp proxmox` in der Adresszeile,
|
||||||
|
um direkt beim passenden Dienst zu landen (ohne LaunchPad überhaupt erst zu
|
||||||
|
öffnen). Das ist ein **einmaliger, manueller Schritt pro Browser/Gerät** –
|
||||||
|
kein Browser lässt eine Website das automatisch für sich einrichten,
|
||||||
|
aus gutem Grund (Sicherheit).
|
||||||
|
|
||||||
|
**Chrome / Edge:**
|
||||||
|
1. Einstellungen → „Suchmaschine" → „Suchmaschinen verwalten"
|
||||||
|
2. „Website-Suchmaschine hinzufügen"
|
||||||
|
3. Suchmaschine: `LaunchPad`, Kürzel: `lp`, URL: `https://<deine-domain-oder-ip>:8443/search?q=%s`
|
||||||
|
|
||||||
|
**Firefox:**
|
||||||
|
1. Einmal `https://<deine-domain-oder-ip>:8443/` öffnen (Firefox erkennt den
|
||||||
|
hinterlegten OpenSearch-Eintrag automatisch)
|
||||||
|
2. Einstellungen → „Suche" → bei „LaunchPad" ein Schlagwort (z. B. `lp`)
|
||||||
|
eintragen
|
||||||
|
|
||||||
|
Danach: `lp <suchbegriff>` in die Adresszeile, Enter – landet bei eindeutigem
|
||||||
|
Treffer direkt beim Dienst, sonst auf der LaunchPad-Startseite mit bereits
|
||||||
|
eingetragenem Suchbegriff.
|
||||||
|
|
||||||
|
**Android:** Firefox für Android unterstützt Schlagwort-Suchen (auch keine
|
||||||
|
synchronisierten Desktop-Kürzel) grundsätzlich nicht – eine bekannte
|
||||||
|
Firefox-Einschränkung, kein LaunchPad-Problem. Auf Android funktioniert die
|
||||||
|
Chrome-Anleitung oben unverändert. Alternative für jeden Browser: die
|
||||||
|
LaunchPad-Seite über „Zum Startbildschirm hinzufügen" als App-Symbol
|
||||||
|
ablegen (LaunchPad ist als PWA installierbar) – ein Fingertipp genügt dann
|
||||||
|
bis zum Suchfeld.
|
||||||
|
|
||||||
|
## Deployment auf unterschiedlichen Geräten
|
||||||
|
|
||||||
|
Läuft überall, wo Docker Compose verfügbar ist. Ein paar konkrete Wege:
|
||||||
|
|
||||||
|
### Generischer Linux-Server / LXC-Container (Proxmox, etc.)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> LaunchPad
|
||||||
|
cd LaunchPad
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Synology NAS mit Container Manager (Portainer-ähnliche Oberfläche, DSM 7.2+)
|
||||||
|
|
||||||
|
1. Repo per `git clone` auf die NAS holen (z. B. per SSH, oder mit File
|
||||||
|
Station hochladen) – Zielordner z. B. `/volume1/docker/LaunchPad`.
|
||||||
|
2. In Container Manager → „Projekt" → „Erstellen" → als Pfad den geklonten
|
||||||
|
Ordner wählen. Container Manager erkennt die `docker-compose.yml`
|
||||||
|
automatisch und bietet an, sie als Projekt zu importieren.
|
||||||
|
3. `.env` vorher (per File Station oder SSH) aus `.env.example` anlegen und
|
||||||
|
anpassen – Container Manager selbst bietet dafür keine Oberfläche.
|
||||||
|
4. Projekt starten. Ports `8080`/`8443` müssen frei sein (in DSM ggf. mit
|
||||||
|
bereits belegten NAS-eigenen Ports abgleichen, notfalls in
|
||||||
|
`docker-compose.yml` die linke Seite der Port-Zuordnung ändern, z. B.
|
||||||
|
`18080:80`).
|
||||||
|
|
||||||
|
### Synology NAS mit Portainer (falls separat installiert)
|
||||||
|
|
||||||
|
1. Repo wie oben auf die NAS holen.
|
||||||
|
2. Portainer → „Stacks" → „Add stack" → „Repository" (Git-URL direkt
|
||||||
|
eintragen) oder „Upload" (`docker-compose.yml` hochladen).
|
||||||
|
3. Unter „Environment variables" die Inhalte aus `.env.example` eintragen
|
||||||
|
(Portainer braucht dafür keine separate `.env`-Datei, nimmt die Stack-Vars).
|
||||||
|
4. „Deploy the stack".
|
||||||
|
|
||||||
|
### DockHand / andere Docker-Compose-Oberflächen
|
||||||
|
|
||||||
|
Jede Oberfläche, die eine vorhandene `docker-compose.yml` importieren oder
|
||||||
|
auf einen Git-Ordner zeigen kann, funktioniert grundsätzlich gleich: Repo
|
||||||
|
bereitstellen (lokal geklont oder per Git-Integration der jeweiligen
|
||||||
|
Oberfläche), `.env` aus `.env.example` befüllen, Compose-Datei
|
||||||
|
starten/deployen. Es gibt keine LaunchPad-spezifischen Besonderheiten
|
||||||
|
außer den Standard-Docker-Compose-Grundlagen (Ports, Volumes, `.env`).
|
||||||
|
|
||||||
|
## 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.
|
||||||
90
apps/backend/Dockerfile
Normal file
90
apps/backend/Dockerfile
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# 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
|
||||||
|
# node:dns/promises (lookup/reverse, siehe scanner/dns.ts) läuft NICHT über
|
||||||
|
# normales nicht-blockierendes I/O, sondern über getaddrinfo() im
|
||||||
|
# Betriebssystem - Node schickt das über den libuv-Threadpool ab, der
|
||||||
|
# standardmäßig nur 4 Threads hat. Beim Sammel-Scan mit bis zu 10 parallel
|
||||||
|
# gescannten Geräten (siehe routes/scan.ts CONCURRENT_DEVICES) und je bis zu
|
||||||
|
# 4 DNS-Abfragen pro Gerät reichte das nicht annähernd und führte zu einem
|
||||||
|
# Rückstau, der wie ein hängender Sammel-Scan aussah (siehe Bugreport - Scan
|
||||||
|
# blieb nach erfolgreichem masscan-/httpx-Vorabscan minutenlang in der
|
||||||
|
# eigentlichen Geräte-Schleife stehen). MUSS hier als Umgebungsvariable
|
||||||
|
# gesetzt werden, nicht im JS-Code selbst: das Projekt läuft als natives ESM
|
||||||
|
# ("type": "module"), dort werden alle import-Anweisungen VOR jeglichem
|
||||||
|
# sonstigen Code im Modul ausgeführt (auch wenn dieser Code textuell davor
|
||||||
|
# steht) - ein Setzen von process.env.UV_THREADPOOL_SIZE am Dateianfang von
|
||||||
|
# index.ts käme damit zu spät.
|
||||||
|
ENV UV_THREADPOOL_SIZE=32
|
||||||
|
|
||||||
|
# iputils-ping stellt einen "echten" ping-Befehl mit ICMP-Unterstützung
|
||||||
|
# bereit (nicht nur die BusyBox-Variante) - für den optionalen Live-Status-
|
||||||
|
# Heartbeat (siehe src/liveStatus.ts, standardmäßig deaktiviert).
|
||||||
|
# masscan beschleunigt den vollständigen Portscan (1-65535, siehe
|
||||||
|
# scanner/masscan.ts) erheblich gegenüber reinem TCP-Connect-Scanning - wird
|
||||||
|
# automatisch genutzt, falls vorhanden, sonst fällt der Scanner auf die
|
||||||
|
# eingebaute (langsamere, aber ohne Zusatzrechte auskommende) Methode zurück.
|
||||||
|
RUN apk add --no-cache iputils-ping masscan
|
||||||
|
|
||||||
|
# httpx (ProjectDiscovery) prüft eine Liste offener Ports und liefert NUR
|
||||||
|
# die, die tatsächlich HTTP/HTTPS sprechen, inkl. Status/Titel/Server-Header
|
||||||
|
# in einem einzigen, hochparallelen Durchlauf (siehe scanner/httpx.ts) -
|
||||||
|
# deutlich schneller als eigenes Protokoll-Raten pro Port, und verhindert,
|
||||||
|
# dass Nicht-Web-Ports (SSH, SMB, NFS, ...) überhaupt erst per HTTP
|
||||||
|
# angesprochen werden. Kein Alpine-Paket vorhanden, daher als
|
||||||
|
# Binary-Release von GitHub geladen. Bei Fehlschlag (z. B. kein
|
||||||
|
# Internetzugang beim Bauen des Images) läuft der Build trotzdem durch -
|
||||||
|
# der Scanner erkennt ein fehlendes httpx zur Laufzeit automatisch und
|
||||||
|
# fällt auf die eingebaute Methode zurück.
|
||||||
|
RUN apk add --no-cache --virtual .httpx-build curl unzip && \
|
||||||
|
( \
|
||||||
|
HTTPX_URL=$(curl -fsSL https://api.github.com/repos/projectdiscovery/httpx/releases/latest \
|
||||||
|
| grep -o 'https://[^"]*httpx_[0-9][^"]*_linux_amd64\.zip' | head -n1) && \
|
||||||
|
test -n "$HTTPX_URL" && \
|
||||||
|
curl -fsSL "$HTTPX_URL" -o /tmp/httpx.zip && \
|
||||||
|
unzip -o /tmp/httpx.zip -d /usr/local/bin httpx && \
|
||||||
|
chmod +x /usr/local/bin/httpx \
|
||||||
|
|| echo "httpx-Installation fehlgeschlagen - Scanner nutzt automatisch die eingebaute Methode" \
|
||||||
|
) && \
|
||||||
|
rm -f /tmp/httpx.zip && \
|
||||||
|
apk del .httpx-build
|
||||||
|
|
||||||
|
COPY --from=build /app/deploy/ ./
|
||||||
|
COPY --from=build /app/apps/backend/dist ./dist
|
||||||
|
COPY --from=build /app/packages/shared/dist ./node_modules/@launchpad/shared/dist
|
||||||
|
|
||||||
|
RUN mkdir -p /data
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
10
apps/backend/drizzle.config.ts
Normal file
10
apps/backend/drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "./src/db/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dialect: "sqlite",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_PATH ?? "./data/launchpad.db",
|
||||||
|
},
|
||||||
|
});
|
||||||
31
apps/backend/package.json
Normal file
31
apps/backend/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
78
apps/backend/plugins/README.md
Normal file
78
apps/backend/plugins/README.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# LaunchPad-Plugins
|
||||||
|
|
||||||
|
Jeder Unterordner hier ist ein Plugin. Erkannt wird alles, was eine
|
||||||
|
`index.js` mit einem passenden Default-Export enthält – kein Rebuild des
|
||||||
|
Docker-Images nötig, dieser Ordner wird zur Laufzeit als Volume eingebunden
|
||||||
|
(siehe `docker-compose.yml`). Nach dem Hinzufügen/Ändern eines Plugins
|
||||||
|
reicht ein Neustart des Backend-Containers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Minimalstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/backend/plugins/mein-plugin/
|
||||||
|
└── index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
name: "mein-plugin",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "Kurze Beschreibung, erscheint unter Admin -> Plugins",
|
||||||
|
|
||||||
|
// optional: Scanner erweitern
|
||||||
|
setup(ctx) {
|
||||||
|
ctx.registerSoftwareSignature({
|
||||||
|
name: "Meine Software",
|
||||||
|
category: "Sonstiges",
|
||||||
|
icon: "🔧", // Emoji oder Icon-URL
|
||||||
|
matches: (input) => !!input.body && /meine-software/i.test(input.body),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// optional: eigene Geräte-Importquelle
|
||||||
|
async importDevices() {
|
||||||
|
return [
|
||||||
|
{ hostname: "beispiel", ip: "192.168.1.99", source: "plugin" },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Beide Funktionen (`setup`, `importDevices`) sind optional – ein Plugin kann
|
||||||
|
nur die eine, nur die andere, oder beide implementieren. Reines JavaScript
|
||||||
|
(ESM), kein Build-Schritt nötig.
|
||||||
|
|
||||||
|
## Was Plugins aktuell können
|
||||||
|
|
||||||
|
- **Scanner registrieren**: `ctx.registerSoftwareSignature(...)` in `setup()`
|
||||||
|
fügt der automatischen Softwareerkennung (siehe `apps/backend/src/scanner/softwareDetection.ts`)
|
||||||
|
eine zusätzliche Signatur hinzu, inklusive eigenem Icon. Wird bei jedem
|
||||||
|
Geräte-Scan berücksichtigt.
|
||||||
|
- **Geräte importieren**: `importDevices()` liefert eine Liste von Geräten,
|
||||||
|
die per Knopfdruck unter Admin -> Plugins importiert werden
|
||||||
|
(`POST /api/plugins/:name/import`). Abgeglichen wird wie bei Scans über
|
||||||
|
MAC/IP – ein erneuter Import überschreibt keine Gerätefelder, die der
|
||||||
|
Nutzer zwischenzeitlich geändert hat, außer den scan-eigenen (siehe
|
||||||
|
`upsertDeviceFromScan`).
|
||||||
|
- **Icons bereitstellen**: über `icon` an einer registrierten Signatur. Wird
|
||||||
|
als Startwert für neu angelegte Dienste übernommen (wie `category` –
|
||||||
|
niemals nachträglich überschrieben, siehe README Hauptprojekt).
|
||||||
|
|
||||||
|
## Was (noch) nicht geht
|
||||||
|
|
||||||
|
- Eigene Admin-Menüpunkte/Unterseiten (aktuell erscheinen alle Plugins
|
||||||
|
gesammelt auf einer Seite unter Admin -> Plugins, keine eigene Route pro
|
||||||
|
Plugin).
|
||||||
|
- Kein Sandboxing: Plugin-Code läuft mit vollem Zugriff im Backend-Prozess.
|
||||||
|
Nur Plugins aus vertrauenswürdiger Quelle einbinden.
|
||||||
|
|
||||||
|
## Beispiel-Plugins
|
||||||
|
|
||||||
|
- `example-signatures/` – registriert zwei zusätzliche Software-Signaturen
|
||||||
|
(Homebridge, Uptime Kuma).
|
||||||
|
- `static-import/` – importiert Geräte aus einer lokalen `devices.json`
|
||||||
|
(standardmäßig leer, siehe `devices.example.json` für das Format).
|
||||||
29
apps/backend/plugins/example-signatures/index.js
Normal file
29
apps/backend/plugins/example-signatures/index.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Beispiel-Plugin: registriert zwei zusätzliche Softwareerkennungen, die
|
||||||
|
* nicht in der eingebauten Liste enthalten sind, inklusive eigenem Icon.
|
||||||
|
*
|
||||||
|
* Zeigt das setup()-Pattern: ein Plugin bekommt beim Laden einen Kontext mit
|
||||||
|
* registerSoftwareSignature() und kann darüber den Scanner erweitern.
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
name: "example-signatures",
|
||||||
|
version: "1.0.0",
|
||||||
|
description:
|
||||||
|
"Erkennt Homebridge und Uptime Kuma zusätzlich zu den eingebauten Signaturen.",
|
||||||
|
|
||||||
|
setup(ctx) {
|
||||||
|
ctx.registerSoftwareSignature({
|
||||||
|
name: "Homebridge",
|
||||||
|
category: "Smart Home",
|
||||||
|
icon: "🏠",
|
||||||
|
matches: (input) => !!input.body && /homebridge/i.test(input.body),
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.registerSoftwareSignature({
|
||||||
|
name: "Uptime Kuma",
|
||||||
|
category: "Monitoring",
|
||||||
|
icon: "📈",
|
||||||
|
matches: (input) => !!input.body && /uptime\s*kuma/i.test(input.body),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
9
apps/backend/plugins/static-import/devices.example.json
Normal file
9
apps/backend/plugins/static-import/devices.example.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"hostname": "nas",
|
||||||
|
"ip": "192.168.1.20",
|
||||||
|
"mac": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"manufacturer": "Synology",
|
||||||
|
"model": "DS920+"
|
||||||
|
}
|
||||||
|
]
|
||||||
1
apps/backend/plugins/static-import/devices.json
Normal file
1
apps/backend/plugins/static-import/devices.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
40
apps/backend/plugins/static-import/index.js
Normal file
40
apps/backend/plugins/static-import/index.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beispiel-Plugin: importiert Geräte aus einer lokalen devices.json neben
|
||||||
|
* diesem Plugin. Zeigt das importDevices()-Pattern für eigene Datenquellen
|
||||||
|
* (z. B. ein Inventar-Export oder eine externe API) – im Unterschied zu den
|
||||||
|
* Netzwerk-Scannern rein datengetrieben, kein eigener Netzwerkzugriff nötig.
|
||||||
|
*
|
||||||
|
* Standardmäßig ist devices.json leer, damit beim ersten Start keine
|
||||||
|
* Fantasiegeräte auftauchen. Zum Ausprobieren einfach Einträge ergänzen und
|
||||||
|
* unter Admin -> Plugins auf "Jetzt importieren" klicken.
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
name: "static-import",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "Importiert Geräte aus plugins/static-import/devices.json.",
|
||||||
|
|
||||||
|
async importDevices() {
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(join(currentDir, "devices.json"), "utf-8");
|
||||||
|
const entries = JSON.parse(raw);
|
||||||
|
|
||||||
|
return entries.map((entry) => ({
|
||||||
|
hostname: entry.hostname,
|
||||||
|
ip: entry.ip,
|
||||||
|
mac: entry.mac ?? undefined,
|
||||||
|
manufacturer: entry.manufacturer ?? undefined,
|
||||||
|
model: entry.model ?? undefined,
|
||||||
|
online: false,
|
||||||
|
source: "plugin",
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
224
apps/backend/src/db/client.ts
Normal file
224
apps/backend/src/db/client.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
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,
|
||||||
|
last_ping 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,
|
||||||
|
visible INTEGER NOT NULL DEFAULT 1,
|
||||||
|
display_name_edited_manually INTEGER NOT NULL DEFAULT 0,
|
||||||
|
category_edited_manually INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_suggested_display_name TEXT,
|
||||||
|
last_suggested_category TEXT,
|
||||||
|
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,
|
||||||
|
color TEXT,
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category TEXT,
|
||||||
|
icon TEXT,
|
||||||
|
favicon TEXT,
|
||||||
|
favorite INTEGER NOT NULL DEFAULT 0,
|
||||||
|
alias TEXT NOT NULL DEFAULT '[]',
|
||||||
|
"order" REAL NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recent_visits (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
item_type TEXT NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
visited_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS read_later (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
favicon TEXT,
|
||||||
|
saved_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS detected_apis (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
service_id TEXT NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
status INTEGER NOT NULL,
|
||||||
|
detected_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scan_dismissals (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
dedupe_key TEXT NOT NULL,
|
||||||
|
dismissed_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Leichte Migration für Datenbanken, die vor Einführung von "visible"/
|
||||||
|
// "color" angelegt wurden: CREATE TABLE IF NOT EXISTS rüstet bei bereits
|
||||||
|
// existierenden Tabellen keine neuen Spalten nach, das übernehmen wir hier
|
||||||
|
// manuell. Bestehende Dienste werden dabei auf sichtbar (1) gesetzt, damit
|
||||||
|
// sich am bisherigen Verhalten nichts unerwartet ändert.
|
||||||
|
ensureColumn("services", "visible", "INTEGER NOT NULL DEFAULT 1");
|
||||||
|
ensureColumn("categories", "color", "TEXT");
|
||||||
|
// Für Datenbanken vor Einführung der Unterscheidung "manuell angepasst" vs.
|
||||||
|
// "vom Scan abweichend" bei displayName/category (siehe schema.ts). Default
|
||||||
|
// 0 (nicht manuell bearbeitet) ist für Bestandsdaten die richtige Annahme.
|
||||||
|
ensureColumn("services", "display_name_edited_manually", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
ensureColumn("services", "category_edited_manually", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
ensureColumn("services", "last_suggested_display_name", "TEXT");
|
||||||
|
ensureColumn("services", "last_suggested_category", "TEXT");
|
||||||
|
ensureColumn("devices", "last_ping", "TEXT");
|
||||||
|
|
||||||
|
deduplicateServicesByDeviceAndPort();
|
||||||
|
|
||||||
|
// Verhindert künftig auf Datenbankebene, dass zwei Dienste mit demselben
|
||||||
|
// (Gerät, Port) gleichzeitig existieren - kann sonst passieren, wenn zwei
|
||||||
|
// Scans desselben Geräts überlappen (z. B. "Alle Geräte scannen" läuft
|
||||||
|
// noch, während gleichzeitig auch "Jetzt scannen" für genau dieses eine
|
||||||
|
// Gerät geklickt wird) und beide parallel einen "existiert noch nicht"-Fund
|
||||||
|
// zu einem CREATE statt UPDATE machen. Erst NACH der Bereinigung oben
|
||||||
|
// anlegen, sonst würde das Erstellen des Index an vorhandenen Duplikaten
|
||||||
|
// scheitern.
|
||||||
|
sqlite.exec(
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_services_device_port ON services(device_id, port)`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verhindert doppelte Einträge für denselben Vorschlag (z. B. wenn zwei
|
||||||
|
// Browser fast gleichzeitig denselben neuen Dienst bestätigen) - der
|
||||||
|
// Dismiss-Endpunkt selbst nutzt ohnehin "INSERT OR IGNORE" (siehe
|
||||||
|
// db/repositories/scanDismissals.ts), dieser Index macht das zusätzlich
|
||||||
|
// auf Datenbankebene verbindlich.
|
||||||
|
sqlite.exec(
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_scan_dismissals_kind_key ON scan_dismissals(kind, dedupe_key)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt doppelte Dienst-Einträge für dasselbe (Gerät, Port) - kann bei
|
||||||
|
* älteren Datenbanken vorkommen, wenn zwei überlappende Scans vor Einführung
|
||||||
|
* des UNIQUE-Index (s. o.) beide gleichzeitig einen neuen Dienst angelegt
|
||||||
|
* haben. Behält den Eintrag mit den meisten ausgefüllten Feldern (Favicon,
|
||||||
|
* Kategorie, ...), bei Gleichstand den ältesten (kleinste created_at).
|
||||||
|
*/
|
||||||
|
function deduplicateServicesByDeviceAndPort(): void {
|
||||||
|
const rows = sqlite
|
||||||
|
.prepare(`SELECT * FROM services ORDER BY created_at ASC`)
|
||||||
|
.all() as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
const groups = new Map<string, Array<Record<string, unknown>>>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = `${row.device_id}::${row.port}`;
|
||||||
|
const list = groups.get(key) ?? [];
|
||||||
|
list.push(row);
|
||||||
|
groups.set(key, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function richness(row: Record<string, unknown>): number {
|
||||||
|
return ["favicon", "category", "description", "icon"].filter((f) => !!row[f]).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of groups.values()) {
|
||||||
|
if (group.length <= 1) continue;
|
||||||
|
|
||||||
|
const keeper = [...group].sort((a, b) => richness(b) - richness(a))[0];
|
||||||
|
const toDelete = group.filter((r) => r.id !== keeper.id);
|
||||||
|
for (const row of toDelete) {
|
||||||
|
sqlite.prepare(`DELETE FROM services WHERE id = ?`).run(row.id as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureColumn(table: string, column: string, definition: string): void {
|
||||||
|
const existingColumns = sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
const hasColumn = existingColumns.some((col) => col.name === column);
|
||||||
|
|
||||||
|
if (!hasColumn) {
|
||||||
|
sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
apps/backend/src/db/repositories/apis.ts
Normal file
77
apps/backend/src/db/repositories/apis.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "../client.js";
|
||||||
|
import { detectedApis } from "../schema.js";
|
||||||
|
|
||||||
|
export interface DetectedApiEntry {
|
||||||
|
id: string;
|
||||||
|
serviceId: string;
|
||||||
|
path: string;
|
||||||
|
type: string;
|
||||||
|
status: number;
|
||||||
|
detectedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRow(row: typeof detectedApis.$inferSelect): DetectedApiEntry {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
serviceId: row.serviceId,
|
||||||
|
path: row.path,
|
||||||
|
type: row.type,
|
||||||
|
status: row.status,
|
||||||
|
detectedAt: row.detectedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDetectedApis(): DetectedApiEntry[] {
|
||||||
|
return db.select().from(detectedApis).all().map(mapRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApisForService(serviceId: string): DetectedApiEntry[] {
|
||||||
|
return db.select().from(detectedApis).where(eq(detectedApis.serviceId, serviceId)).all().map(mapRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ersetzt die gespeicherten API-Funde eines Dienstes komplett durch die
|
||||||
|
* neuen Ergebnisse eines Scan-Laufs - kein Anhäufen von veralteten
|
||||||
|
* Einträgen, wenn sich z. B. der API-Pfad einer Software mal ändert.
|
||||||
|
*
|
||||||
|
* Liefert zusätzlich `added` zurück: die Einträge, die VORHER noch nicht
|
||||||
|
* gespeichert waren (nach Pfad+Typ verglichen) - damit der Scanner auf der
|
||||||
|
* Scanner-Seite nach dem ersten Mal nur noch NEUE/geänderte Funde anzeigen
|
||||||
|
* kann, statt bei jedem erneuten Scan die komplette (meist unveränderte)
|
||||||
|
* Liste erneut runterzurattern. Die vollständige Liste bleibt jederzeit
|
||||||
|
* unter Admin -> APIs einsehbar.
|
||||||
|
*/
|
||||||
|
export function replaceApisForService(
|
||||||
|
serviceId: string,
|
||||||
|
found: { path: string; type: string; status: number }[]
|
||||||
|
): { saved: DetectedApiEntry[]; added: DetectedApiEntry[] } {
|
||||||
|
const before = getApisForService(serviceId);
|
||||||
|
const beforeKeys = new Set(before.map((e) => `${e.path}::${e.type}`));
|
||||||
|
|
||||||
|
db.delete(detectedApis).where(eq(detectedApis.serviceId, serviceId)).run();
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const rows = found.map((f) => ({
|
||||||
|
id: randomUUID(),
|
||||||
|
serviceId,
|
||||||
|
path: f.path,
|
||||||
|
type: f.type,
|
||||||
|
status: f.status,
|
||||||
|
detectedAt: timestamp,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
db.insert(detectedApis).values(rows).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = rows.map(mapRow);
|
||||||
|
const added = saved.filter((e) => !beforeKeys.has(`${e.path}::${e.type}`));
|
||||||
|
|
||||||
|
return { saved, added };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteApisForService(serviceId: string): void {
|
||||||
|
db.delete(detectedApis).where(eq(detectedApis.serviceId, serviceId)).run();
|
||||||
|
}
|
||||||
116
apps/backend/src/db/repositories/bookmarks.ts
Normal file
116
apps/backend/src/db/repositories/bookmarks.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Bookmark, BookmarkCreateInput, BookmarkUpdateInput } from "@launchpad/shared";
|
||||||
|
import { db } from "../client.js";
|
||||||
|
import { bookmarks } from "../schema.js";
|
||||||
|
|
||||||
|
function nowIso(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRow(row: typeof bookmarks.$inferSelect): Bookmark {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
url: row.url,
|
||||||
|
displayName: row.displayName,
|
||||||
|
hostname: row.hostname,
|
||||||
|
description: row.description,
|
||||||
|
category: row.category,
|
||||||
|
icon: row.icon,
|
||||||
|
favicon: row.favicon,
|
||||||
|
favorite: row.favorite,
|
||||||
|
alias: JSON.parse(row.alias) as string[],
|
||||||
|
order: row.order,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractHostname(url: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBookmarks(): Bookmark[] {
|
||||||
|
return db.select().from(bookmarks).all().map(mapRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBookmark(id: string): Bookmark | null {
|
||||||
|
const row = db.select().from(bookmarks).where(eq(bookmarks.id, id)).get();
|
||||||
|
return row ? mapRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateBookmarkOptions {
|
||||||
|
displayName: string;
|
||||||
|
favicon?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBookmark(
|
||||||
|
input: BookmarkCreateInput,
|
||||||
|
resolved: CreateBookmarkOptions
|
||||||
|
): Bookmark {
|
||||||
|
const id = randomUUID();
|
||||||
|
const timestamp = nowIso();
|
||||||
|
|
||||||
|
db.insert(bookmarks)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
url: input.url,
|
||||||
|
displayName: resolved.displayName,
|
||||||
|
hostname: extractHostname(input.url),
|
||||||
|
description: input.description ?? null,
|
||||||
|
category: input.category ?? null,
|
||||||
|
icon: input.icon ?? null,
|
||||||
|
favicon: resolved.favicon ?? null,
|
||||||
|
favorite: input.favorite ?? false,
|
||||||
|
alias: JSON.stringify(input.alias ?? []),
|
||||||
|
order: input.order ?? 0,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return getBookmark(id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBookmark(id: string, input: BookmarkUpdateInput): Bookmark | null {
|
||||||
|
const existing = getBookmark(id);
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
db.update(bookmarks)
|
||||||
|
.set({
|
||||||
|
...(input.url !== undefined && { url: input.url, hostname: extractHostname(input.url) }),
|
||||||
|
...(input.displayName !== undefined && { displayName: input.displayName }),
|
||||||
|
...(input.description !== undefined && { description: input.description }),
|
||||||
|
...(input.category !== undefined && { category: input.category }),
|
||||||
|
...(input.icon !== undefined && { icon: input.icon }),
|
||||||
|
...(input.favicon !== undefined && { favicon: input.favicon }),
|
||||||
|
...(input.favorite !== undefined && { favorite: input.favorite }),
|
||||||
|
...(input.alias !== undefined && { alias: JSON.stringify(input.alias) }),
|
||||||
|
...(input.order !== undefined && { order: input.order }),
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
})
|
||||||
|
.where(eq(bookmarks.id, id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return getBookmark(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBookmark(id: string): boolean {
|
||||||
|
const result = db.delete(bookmarks).where(eq(bookmarks.id, id)).run();
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reorderBookmarks(input: { id: string; order: number }[]): Bookmark[] {
|
||||||
|
db.transaction((tx) => {
|
||||||
|
for (const entry of input) {
|
||||||
|
tx.update(bookmarks)
|
||||||
|
.set({ order: entry.order, updatedAt: nowIso() })
|
||||||
|
.where(eq(bookmarks.id, entry.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return listBookmarks();
|
||||||
|
}
|
||||||
155
apps/backend/src/db/repositories/categories.ts
Normal file
155
apps/backend/src/db/repositories/categories.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
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, color: row.color };
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
color: input.color ?? null,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return getCategory(id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benennt eine Kategorie um. Da Dienste ihre Kategorie als freier Text
|
||||||
|
* speichern (kein Fremdschlüssel), werden alle Dienste mit dem alten Namen
|
||||||
|
* automatisch auf den neuen Namen mit umgestellt, damit beides synchron bleibt.
|
||||||
|
*/
|
||||||
|
export function updateCategory(id: string, input: CategoryUpdateInput): Category | null {
|
||||||
|
const existing = getCategory(id);
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
const timestamp = nowIso();
|
||||||
|
|
||||||
|
if (input.name !== undefined && input.name !== existing.name) {
|
||||||
|
db.update(services)
|
||||||
|
.set({ category: input.name, updatedAt: timestamp })
|
||||||
|
.where(eq(services.category, existing.name))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(categories)
|
||||||
|
.set({
|
||||||
|
...(input.name !== undefined && { name: input.name }),
|
||||||
|
...(input.color !== undefined && { color: input.color }),
|
||||||
|
updatedAt: timestamp,
|
||||||
|
})
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt eine Kategorie an, falls noch keine mit diesem Namen existiert
|
||||||
|
* (Vergleich ohne Groß-/Kleinschreibung). Wird beim Scannen aufgerufen,
|
||||||
|
* damit jede automatisch erkannte Kategorie auch im Adminbereich auftaucht.
|
||||||
|
*/
|
||||||
|
export function ensureCategory(name: string): Category {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
const existing = listCategories().find(
|
||||||
|
(c) => c.name.toLowerCase() === trimmed.toLowerCase()
|
||||||
|
);
|
||||||
|
if (existing) return existing;
|
||||||
|
return createCategory({ name: trimmed });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gleicht eine Liste von Kategorienamen (z. B. alle distinct-Werte aus
|
||||||
|
* services.category) mit der categories-Tabelle ab und legt fehlende an.
|
||||||
|
* Wird beim Start aufgerufen, damit bereits vorhandene, aber nie über
|
||||||
|
* ensureCategory() angelegte Kategorien (z. B. aus Scans vor diesem Fix)
|
||||||
|
* im Adminbereich sichtbar werden. Gibt die Anzahl neu angelegter zurück.
|
||||||
|
*/
|
||||||
|
export function syncCategoriesFromServiceValues(categoryNames: string[]): number {
|
||||||
|
const existingNames = new Set(listCategories().map((c) => c.name.toLowerCase()));
|
||||||
|
let created = 0;
|
||||||
|
|
||||||
|
for (const rawName of categoryNames) {
|
||||||
|
const trimmed = rawName.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
if (existingNames.has(trimmed.toLowerCase())) continue;
|
||||||
|
|
||||||
|
createCategory({ name: trimmed });
|
||||||
|
existingNames.add(trimmed.toLowerCase());
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
240
apps/backend/src/db/repositories/devices.ts
Normal file
240
apps/backend/src/db/repositories/devices.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
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";
|
||||||
|
import { lookupVendorFromMac } from "../../scanner/macVendors.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,
|
||||||
|
lastPing: row.lastPing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert online-Status + (nur bei online=true) lastPing. Ein
|
||||||
|
* fehlgeschlagener Ping ist keine "Sichtung" - lastPing soll ausschließlich
|
||||||
|
* den Zeitpunkt zeigen, an dem das Gerät zuletzt tatsächlich geantwortet
|
||||||
|
* hat, nicht wann zuletzt VERSUCHT wurde, es zu erreichen. Sonst würde
|
||||||
|
* "zuletzt gesehen" bei einem dauerhaft offline/entfernten Gerät trotzdem
|
||||||
|
* ständig auf "gerade eben" weiterrücken, nur weil der Heartbeat es
|
||||||
|
* regelmäßig (erfolglos) anpingt.
|
||||||
|
*/
|
||||||
|
export function updateDeviceOnlineStatus(id: string, online: boolean): void {
|
||||||
|
db.update(devices)
|
||||||
|
.set(online ? { online, lastPing: nowIso() } : { online })
|
||||||
|
.where(eq(devices.id, id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
const manufacturer =
|
||||||
|
input.manufacturer ?? (input.mac ? lookupVendorFromMac(input.mac) : null);
|
||||||
|
|
||||||
|
db.insert(devices)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
hostname: input.hostname,
|
||||||
|
ip: input.ip,
|
||||||
|
mac: input.mac ?? null,
|
||||||
|
manufacturer,
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Wenn der Nutzer eine MAC einträgt/ändert, aber selbst keinen Hersteller
|
||||||
|
// angibt UND noch keiner gespeichert ist, automatisch versuchen zu
|
||||||
|
// erkennen (siehe scanner/macVendors.ts) - überschreibt nie einen bereits
|
||||||
|
// gesetzten oder gerade explizit eingegebenen Wert.
|
||||||
|
const autoManufacturer =
|
||||||
|
input.manufacturer === undefined && !existing.manufacturer && input.mac
|
||||||
|
? lookupVendorFromMac(input.mac)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
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 }),
|
||||||
|
...(autoManufacturer && { manufacturer: autoManufacturer }),
|
||||||
|
...(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Löscht ALLE Geräte (und per FK-Cascade alle zugehörigen Dienste). Für den Reset-Button im Adminbereich. */
|
||||||
|
export function deleteAllDevices(): number {
|
||||||
|
const result = db.delete(devices).run();
|
||||||
|
return result.changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceScanInput {
|
||||||
|
hostname: string;
|
||||||
|
ip: string;
|
||||||
|
mac?: string | null;
|
||||||
|
manufacturer?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
online?: boolean;
|
||||||
|
source: Device["source"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceChange {
|
||||||
|
field: "hostname" | "ip";
|
||||||
|
current: string;
|
||||||
|
suggested: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceScanUpsertResult {
|
||||||
|
device: Device;
|
||||||
|
/**
|
||||||
|
* Wird nur bei bereits bekannten Geräten befüllt, wenn ein frischer Scan
|
||||||
|
* (z. B. FritzBox meldet eine neue IP oder einen anderen Namen) vom
|
||||||
|
* gespeicherten Wert abweicht. Wird NICHT automatisch übernommen - nur zur
|
||||||
|
* manuellen Bestätigung im Adminbereich zurückgegeben, analog zu
|
||||||
|
* nameChanges bei Diensten (siehe services.ts).
|
||||||
|
*/
|
||||||
|
changes: DeviceChange[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Keine MAC-Übereinstimmung: nur noch über die IP abgleichen, wenn das per
|
||||||
|
// IP gefundene Gerät selbst KEINE eigene MAC-Adresse gespeichert hat. Hat
|
||||||
|
// es bereits eine (andere) MAC, handelt es sich nachweislich um ein
|
||||||
|
// anderes physisches Gerät, das (noch) dieselbe IP gemeldet bekommt - z. B.
|
||||||
|
// weil die FritzBox eine wiederverwendete/gerade neu vergebene IP kurzzeitig
|
||||||
|
// sowohl im alten als auch im neuen Host-Eintrag führt. Ohne diese Prüfung
|
||||||
|
// würde ein per Scan neu entdecktes Gerät fälschlich mit einem bereits
|
||||||
|
// bekannten, anderen Gerät zusammengeführt und als "Namensänderung"
|
||||||
|
// vorgeschlagen (siehe Bugreport: leere Datenbank, trotzdem sofort eine
|
||||||
|
// vorgeschlagene Änderung bei der allerersten FritzBox-Abfrage).
|
||||||
|
const byIp = all.find((d) => d.ip === ip);
|
||||||
|
if (byIp && !byIp.mac) return byIp;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
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).
|
||||||
|
*
|
||||||
|
* Bei einem BESTEHENDEN Gerät werden hostname/ip NICHT mehr automatisch
|
||||||
|
* überschrieben (frühere Version tat das bedingungslos) - stattdessen als
|
||||||
|
* changes zurückgegeben, damit der Nutzer selbst entscheidet, ob er eine neue
|
||||||
|
* IP/einen neuen Namen übernehmen möchte. Nur online/lastScan gelten als
|
||||||
|
* "Live-Status" und werden immer aktualisiert. Achtung: Wird eine geänderte
|
||||||
|
* IP bei einem Gerät OHNE bekannte MAC nie bestätigt, kann ein künftiger Scan
|
||||||
|
* dasselbe physische Gerät unter der neuen IP nicht mehr wiedererkennen und
|
||||||
|
* legt stattdessen einen neuen Eintrag an - mit MAC-Adresse (meist vorhanden)
|
||||||
|
* bleibt die Wiedererkennung unabhängig von der IP stabil.
|
||||||
|
*/
|
||||||
|
export function upsertDeviceFromScan(input: DeviceScanInput): DeviceScanUpsertResult {
|
||||||
|
const existing = findByMacOrIp(input.mac, input.ip);
|
||||||
|
const timestamp = nowIso();
|
||||||
|
const mac = input.mac ?? existing?.mac ?? null;
|
||||||
|
const manufacturer =
|
||||||
|
input.manufacturer ??
|
||||||
|
existing?.manufacturer ??
|
||||||
|
(mac ? lookupVendorFromMac(mac) : null);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
mac,
|
||||||
|
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();
|
||||||
|
|
||||||
|
const changes: DeviceChange[] = [];
|
||||||
|
if (input.hostname && input.hostname !== existing.hostname) {
|
||||||
|
changes.push({ field: "hostname", current: existing.hostname, suggested: input.hostname });
|
||||||
|
}
|
||||||
|
if (input.ip && input.ip !== existing.ip) {
|
||||||
|
changes.push({ field: "ip", current: existing.ip, suggested: input.ip });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { device: getDevice(existing.id)!, changes };
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
db.insert(devices)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
hostname: input.hostname,
|
||||||
|
ip: input.ip,
|
||||||
|
mac,
|
||||||
|
manufacturer,
|
||||||
|
model: input.model ?? null,
|
||||||
|
online: input.online ?? false,
|
||||||
|
source: input.source,
|
||||||
|
lastScan: timestamp,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return { device: getDevice(id)!, changes: [] };
|
||||||
|
}
|
||||||
52
apps/backend/src/db/repositories/logs.ts
Normal file
52
apps/backend/src/db/repositories/logs.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { desc } from "drizzle-orm";
|
||||||
|
import type { ScanLogEntry } from "@launchpad/shared";
|
||||||
|
import { db } from "../client.js";
|
||||||
|
import { scanLogs } from "../schema.js";
|
||||||
|
|
||||||
|
function mapRow(row: typeof scanLogs.$inferSelect): ScanLogEntry {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
type: row.type as ScanLogEntry["type"],
|
||||||
|
targetId: row.targetId,
|
||||||
|
level: row.level as ScanLogEntry["level"],
|
||||||
|
message: row.message,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogScanInput {
|
||||||
|
type: ScanLogEntry["type"];
|
||||||
|
targetId?: string | null;
|
||||||
|
level: ScanLogEntry["level"];
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schreibt einen Eintrag für einen Scan-Versuch (Erfolg oder Fehler). */
|
||||||
|
export function logScan(input: LogScanInput): ScanLogEntry {
|
||||||
|
const id = randomUUID();
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
|
||||||
|
db.insert(scanLogs)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
type: input.type,
|
||||||
|
targetId: input.targetId ?? null,
|
||||||
|
level: input.level,
|
||||||
|
message: input.message,
|
||||||
|
createdAt,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return { id, type: input.type, targetId: input.targetId ?? null, level: input.level, message: input.message, createdAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listLogs(limit = 100): ScanLogEntry[] {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(scanLogs)
|
||||||
|
.orderBy(desc(scanLogs.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
.map(mapRow);
|
||||||
|
}
|
||||||
77
apps/backend/src/db/repositories/readLater.ts
Normal file
77
apps/backend/src/db/repositories/readLater.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
import { db } from "../client.js";
|
||||||
|
import { readLater } from "../schema.js";
|
||||||
|
|
||||||
|
export interface ReadLaterItem {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
displayName: string;
|
||||||
|
favicon: string | null;
|
||||||
|
savedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRow(row: typeof readLater.$inferSelect): ReadLaterItem {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
url: row.url,
|
||||||
|
displayName: row.displayName,
|
||||||
|
favicon: row.favicon,
|
||||||
|
savedAt: row.savedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listReadLater(): ReadLaterItem[] {
|
||||||
|
return db.select().from(readLater).orderBy(desc(readLater.savedAt)).all().map(mapRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReadLaterItem(id: string): ReadLaterItem | null {
|
||||||
|
const row = db.select().from(readLater).where(eq(readLater.id, id)).get();
|
||||||
|
return row ? mapRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createReadLaterItem(input: {
|
||||||
|
url: string;
|
||||||
|
displayName: string;
|
||||||
|
favicon?: string | null;
|
||||||
|
}): ReadLaterItem {
|
||||||
|
const id = randomUUID();
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
db.insert(readLater)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
url: input.url,
|
||||||
|
displayName: input.displayName,
|
||||||
|
favicon: input.favicon ?? null,
|
||||||
|
savedAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return getReadLaterItem(id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateReadLaterItem(
|
||||||
|
id: string,
|
||||||
|
input: { url?: string; displayName?: string }
|
||||||
|
): ReadLaterItem | null {
|
||||||
|
const existing = getReadLaterItem(id);
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
db.update(readLater)
|
||||||
|
.set({
|
||||||
|
...(input.url !== undefined && { url: input.url }),
|
||||||
|
...(input.displayName !== undefined && { displayName: input.displayName }),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(eq(readLater.id, id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return getReadLaterItem(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteReadLaterItem(id: string): boolean {
|
||||||
|
const result = db.delete(readLater).where(eq(readLater.id, id)).run();
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
78
apps/backend/src/db/repositories/recentVisits.ts
Normal file
78
apps/backend/src/db/repositories/recentVisits.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
import type { Bookmark, Service } from "@launchpad/shared";
|
||||||
|
import { db } from "../client.js";
|
||||||
|
import { recentVisits } from "../schema.js";
|
||||||
|
import * as serviceRepo from "./services.js";
|
||||||
|
import * as bookmarkRepo from "./bookmarks.js";
|
||||||
|
import { getSetting } from "./settings.js";
|
||||||
|
|
||||||
|
export type VisitedItem = (Service & { kind: "service" }) | (Bookmark & { kind: "bookmark" });
|
||||||
|
|
||||||
|
export function recordVisit(itemType: "service" | "bookmark", itemId: string): void {
|
||||||
|
db.insert(recentVisits)
|
||||||
|
.values({
|
||||||
|
id: randomUUID(),
|
||||||
|
itemType,
|
||||||
|
itemId,
|
||||||
|
visitedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LIMIT = 5;
|
||||||
|
|
||||||
|
export function getRecentVisitsLimit(): number {
|
||||||
|
const stored = getSetting("recentVisitsLimit");
|
||||||
|
const parsed = stored ? Number(stored) : DEFAULT_LIMIT;
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die zuletzt besuchten Dienste/Lesezeichen, jedes Element nur
|
||||||
|
* einmal (neuester Besuch zählt), aufgelöst zu den echten Objekten. Bereits
|
||||||
|
* gelöschte Ziele werden übersprungen. Limit kommt aus den Einstellungen
|
||||||
|
* (Admin -> Einstellungen), Default 5.
|
||||||
|
*/
|
||||||
|
export function listRecentVisits(limitOverride?: number): VisitedItem[] {
|
||||||
|
const limit = limitOverride ?? getRecentVisitsLimit();
|
||||||
|
|
||||||
|
// Etwas mehr als das Limit an Rohzeilen lesen, falls Duplikate/gelöschte
|
||||||
|
// Ziele darunter sind – reicht in der Praxis (Homelab-Größenordnung) locker.
|
||||||
|
const rows = db
|
||||||
|
.select()
|
||||||
|
.from(recentVisits)
|
||||||
|
.orderBy(desc(recentVisits.visitedAt))
|
||||||
|
.limit(limit * 5 + 20)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: VisitedItem[] = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = `${row.itemType}:${row.itemId}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
|
||||||
|
if (row.itemType === "service") {
|
||||||
|
const service = serviceRepo.getService(row.itemId);
|
||||||
|
if (service) result.push({ ...service, kind: "service" });
|
||||||
|
} else if (row.itemType === "bookmark") {
|
||||||
|
const bookmark = bookmarkRepo.getBookmark(row.itemId);
|
||||||
|
if (bookmark) result.push({ ...bookmark, kind: "bookmark" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.length >= limit) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearRecentVisits(): void {
|
||||||
|
db.delete(recentVisits).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Räumt Verweise auf eine gelöschte Ressource auf (verhindert totes Wachstum). */
|
||||||
|
export function pruneVisitsFor(itemId: string): void {
|
||||||
|
db.delete(recentVisits).where(eq(recentVisits.itemId, itemId)).run();
|
||||||
|
}
|
||||||
76
apps/backend/src/db/repositories/scanDismissals.ts
Normal file
76
apps/backend/src/db/repositories/scanDismissals.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import { db } from "../client.js";
|
||||||
|
import { scanDismissals } from "../schema.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Art des bestätigten/verworfenen Scan-Vorschlags. "dedupeKey" identifiziert
|
||||||
|
* das konkrete Element innerhalb dieser Art:
|
||||||
|
* - "new_service" / "stale_service": Dienst-ID
|
||||||
|
* - "stale_device": Geräte-ID
|
||||||
|
* - "name_change": "<serviceId>:<feld>" (feld = "displayName" | "category")
|
||||||
|
* - "device_change": "<deviceId>:<feld>" (feld = "hostname" | "ip")
|
||||||
|
*/
|
||||||
|
export type DismissalKind =
|
||||||
|
| "new_service"
|
||||||
|
| "stale_service"
|
||||||
|
| "stale_device"
|
||||||
|
| "name_change"
|
||||||
|
| "device_change";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merkt einen Vorschlag als erledigt (bestätigt ODER bewusst verworfen -
|
||||||
|
* beides bedeutet "nicht mehr anzeigen"). "INSERT OR IGNORE" über den
|
||||||
|
* UNIQUE-Index (kind, dedupe_key, siehe db/client.ts) macht das
|
||||||
|
* idempotent - ein zweifach gesendeter Dismiss (z. B. Doppelklick, oder zwei
|
||||||
|
* Browser fast gleichzeitig) legt keinen doppelten Eintrag an.
|
||||||
|
*/
|
||||||
|
export function dismiss(kind: DismissalKind, dedupeKey: string): void {
|
||||||
|
db.insert(scanDismissals)
|
||||||
|
.values({ id: randomUUID(), kind, dedupeKey, dismissedAt: new Date().toISOString() })
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert alle bereits erledigten dedupeKeys für eine Vorschlags-Art - zum
|
||||||
|
* Herausfiltern aus einem frischen Scan-Ergebnis (siehe routes/scan.ts),
|
||||||
|
* bevor es an den Browser geschickt wird. Ein Set für O(1)-Lookups, da diese
|
||||||
|
* Funktion typischerweise direkt vor einem .filter() über potenziell viele
|
||||||
|
* Vorschläge aufgerufen wird.
|
||||||
|
*/
|
||||||
|
export function getDismissedKeys(kind: DismissalKind): Set<string> {
|
||||||
|
const rows = db
|
||||||
|
.select({ dedupeKey: scanDismissals.dedupeKey })
|
||||||
|
.from(scanDismissals)
|
||||||
|
.where(eq(scanDismissals.kind, kind))
|
||||||
|
.all();
|
||||||
|
return new Set(rows.map((r) => r.dedupeKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Räumt alte Dismiss-Einträge für einen Vorschlag auf, sobald er durch einen
|
||||||
|
* NEUEN Scan wieder aktuell werden könnte - z. B. wird ein Dienst, der einmal
|
||||||
|
* als "verschwunden" bestätigt/verworfen wurde, bei einem künftigen Scan
|
||||||
|
* erneut gefunden: dann soll ein danach ERNEUT verschwindender Dienst wieder
|
||||||
|
* normal vorgeschlagen werden, nicht durch einen Jahre alten Dismiss-Eintrag
|
||||||
|
* stumm bleiben. Wird von den Reposities (services.ts/devices.ts) beim
|
||||||
|
* erneuten Auftauchen aufgerufen.
|
||||||
|
*/
|
||||||
|
export function clearDismissal(kind: DismissalKind, dedupeKey: string): void {
|
||||||
|
db.delete(scanDismissals)
|
||||||
|
.where(and(eq(scanDismissals.kind, kind), eq(scanDismissals.dedupeKey, dedupeKey)))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie clearDismissal(), aber für mehrere dedupeKeys derselben Art auf einmal
|
||||||
|
* (z. B. alle Ports eines Geräts, die im aktuellen Scan wieder gefunden
|
||||||
|
* wurden) - vermeidet N einzelne DELETE-Statements.
|
||||||
|
*/
|
||||||
|
export function clearDismissals(kind: DismissalKind, dedupeKeys: string[]): void {
|
||||||
|
if (dedupeKeys.length === 0) return;
|
||||||
|
db.delete(scanDismissals)
|
||||||
|
.where(and(eq(scanDismissals.kind, kind), inArray(scanDismissals.dedupeKey, dedupeKeys)))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
328
apps/backend/src/db/repositories/services.ts
Normal file
328
apps/backend/src/db/repositories/services.ts
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
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,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
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,
|
||||||
|
visible: row.visible,
|
||||||
|
displayNameEditedManually: row.displayNameEditedManually,
|
||||||
|
categoryEditedManually: row.categoryEditedManually,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
visible: input.visible ?? true,
|
||||||
|
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, icon, hostname, url,
|
||||||
|
* https oder visible eines bereits bekannten Dienstes überschreibt.
|
||||||
|
*
|
||||||
|
* Ein PATCH mit displayName/category markiert das jeweilige Feld standardmäßig
|
||||||
|
* als "manuell bearbeitet" (displayNameEditedManually/categoryEditedManually),
|
||||||
|
* damit ein späterer Scan-Vorschlag, der davon abweicht, in der Änderungs-
|
||||||
|
* übersicht klar als "weicht nur wegen deiner manuellen Anpassung ab" markiert
|
||||||
|
* werden kann statt als echte inhaltliche Änderung. Beim Übernehmen eines
|
||||||
|
* Scan-Vorschlags wird die jeweilige *EditedManually-Flag explizit auf false
|
||||||
|
* gesetzt (siehe ScannerPage/DevicesPage "Übernehmen"-Button).
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
displayNameEditedManually: input.displayNameEditedManually ?? true,
|
||||||
|
}),
|
||||||
|
...(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,
|
||||||
|
categoryEditedManually: input.categoryEditedManually ?? true,
|
||||||
|
}),
|
||||||
|
...(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 }),
|
||||||
|
...(input.visible !== undefined && { visible: input.visible }),
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die Reihenfolge mehrerer Dienste in einer Transaktion neu
|
||||||
|
* (z. B. nach Drag & Drop in der Admin-UI).
|
||||||
|
*/
|
||||||
|
export function reorderServices(input: { id: string; order: number }[]): Service[] {
|
||||||
|
db.transaction((tx) => {
|
||||||
|
for (const entry of input) {
|
||||||
|
tx.update(services)
|
||||||
|
.set({ order: entry.order, updatedAt: nowIso() })
|
||||||
|
.where(eq(services.id, entry.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return listServices();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
/** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird (z. B. von einem Plugin bereitgestellt). */
|
||||||
|
suggestedIcon?: string | null;
|
||||||
|
/** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird (z. B. Fehlerseite -> versteckt). */
|
||||||
|
suggestedVisible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanNameChange {
|
||||||
|
field: "displayName" | "category";
|
||||||
|
current: string | null;
|
||||||
|
suggested: string;
|
||||||
|
/**
|
||||||
|
* true, wenn der aktuell gespeicherte Wert vom Scan-Vorschlag nur deshalb
|
||||||
|
* abweicht, weil der Nutzer ihn zuvor manuell angepasst hat (siehe
|
||||||
|
* displayNameEditedManually/categoryEditedManually). Solche Änderungen sind
|
||||||
|
* erwartbar/harmlos und sollten in der UI klar von echten inhaltlichen
|
||||||
|
* Änderungen getrennt dargestellt werden.
|
||||||
|
*/
|
||||||
|
manualOverride: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanUpsertResult {
|
||||||
|
service: Service;
|
||||||
|
created: boolean;
|
||||||
|
/**
|
||||||
|
* Wird nur bei bereits bekannten Diensten befüllt, wenn ein frischer Scan
|
||||||
|
* einen anderen Namen/Kategorie vorschlagen würde als aktuell gespeichert.
|
||||||
|
* Wird NICHT automatisch übernommen (siehe updateService-Dokumentation
|
||||||
|
* oben) - nur zur manuellen Durchsicht/Bestätigung im Adminbereich.
|
||||||
|
*/
|
||||||
|
nameChanges: ScanNameChange[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 einen bereits
|
||||||
|
* bekannten Dienst (abgeglichen über deviceId + port).
|
||||||
|
*
|
||||||
|
* Bei einem BESTEHENDEN Dienst werden nur favicon/description aufgefrischt.
|
||||||
|
* Alles andere – auch hostname/url/https, die früher bei jedem Scan
|
||||||
|
* überschrieben wurden – bleibt unangetastet, damit manuelle Anpassungen
|
||||||
|
* (z. B. IP/Port/Protokoll korrigieren) einen erneuten Scan überleben.
|
||||||
|
* Falls der frische Scan einen anderen Namen/Kategorie vorschlagen würde,
|
||||||
|
* wird das über nameChanges zur manuellen Bestätigung zurückgegeben, statt
|
||||||
|
* entweder stillschweigend ignoriert oder automatisch übernommen zu werden.
|
||||||
|
*
|
||||||
|
* Ein abweichender Vorschlag wird ERST gemeldet, wenn er zwei Scans in Folge
|
||||||
|
* derselbe ist (siehe lastSuggestedDisplayName/lastSuggestedCategory in
|
||||||
|
* schema.ts) - Titel-Erkennung kann pro Scan leicht schwanken (z. B. eine
|
||||||
|
* SPA, deren <title> nur gelegentlich rechtzeitig geladen ist), ein
|
||||||
|
* einmaliger Ausrutscher soll nicht sofort als Änderung gemeldet werden.
|
||||||
|
*
|
||||||
|
* Wurde ein Feld bereits manuell über die Admin-UI gesetzt
|
||||||
|
* (displayNameEditedManually/categoryEditedManually), wird ein abweichender
|
||||||
|
* Scan-Vorschlag NIE als nameChange gemeldet - der Nutzer hat sich bewusst
|
||||||
|
* für seinen eigenen Wert entschieden, ein erneutes Nachfragen bei jedem
|
||||||
|
* Scan wäre nur nervig. Die Admin-UI zeigt stattdessen dauerhaft ein
|
||||||
|
* Hinweis-Icon am Dienst (siehe ServicesPage.tsx).
|
||||||
|
*
|
||||||
|
* suggestedDisplayName/suggestedCategory/suggestedIcon/suggestedVisible
|
||||||
|
* bestimmen den Anfangswert NUR beim erstmaligen Anlegen.
|
||||||
|
*/
|
||||||
|
export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult {
|
||||||
|
const existing = findByDeviceAndPort(input.deviceId, input.port);
|
||||||
|
const timestamp = nowIso();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Rohzeile inkl. der *EditedManually-/lastSuggested*-Felder laden, die
|
||||||
|
// mapRow() bewusst nicht nach außen gibt (nur intern hier relevant).
|
||||||
|
const existingRow = db.select().from(services).where(eq(services.id, existing.id)).get()!;
|
||||||
|
|
||||||
|
db.update(services)
|
||||||
|
.set({
|
||||||
|
favicon: input.favicon ?? existing.favicon,
|
||||||
|
description: input.description ?? existing.description,
|
||||||
|
lastSuggestedDisplayName: input.suggestedDisplayName ?? existingRow.lastSuggestedDisplayName,
|
||||||
|
lastSuggestedCategory: input.suggestedCategory ?? existingRow.lastSuggestedCategory,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
})
|
||||||
|
.where(eq(services.id, existing.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const nameChanges: ScanNameChange[] = [];
|
||||||
|
if (
|
||||||
|
input.suggestedDisplayName &&
|
||||||
|
input.suggestedDisplayName !== existing.displayName &&
|
||||||
|
input.suggestedDisplayName === existingRow.lastSuggestedDisplayName &&
|
||||||
|
!existingRow.displayNameEditedManually
|
||||||
|
) {
|
||||||
|
nameChanges.push({
|
||||||
|
field: "displayName",
|
||||||
|
current: existing.displayName,
|
||||||
|
suggested: input.suggestedDisplayName,
|
||||||
|
manualOverride: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.suggestedCategory !== undefined &&
|
||||||
|
(input.suggestedCategory ?? null) !== existing.category &&
|
||||||
|
input.suggestedCategory &&
|
||||||
|
input.suggestedCategory === existingRow.lastSuggestedCategory &&
|
||||||
|
!existingRow.categoryEditedManually
|
||||||
|
) {
|
||||||
|
nameChanges.push({
|
||||||
|
field: "category",
|
||||||
|
current: existing.category,
|
||||||
|
suggested: input.suggestedCategory,
|
||||||
|
manualOverride: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { service: getService(existing.id)!, created: false, nameChanges };
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
try {
|
||||||
|
db.insert(services)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
deviceId: input.deviceId,
|
||||||
|
displayName: input.suggestedDisplayName,
|
||||||
|
category: input.suggestedCategory ?? null,
|
||||||
|
favorite: false,
|
||||||
|
order: 0,
|
||||||
|
alias: "[]",
|
||||||
|
icon: input.suggestedIcon ?? null,
|
||||||
|
hostname: input.hostname,
|
||||||
|
url: input.url,
|
||||||
|
https: input.https,
|
||||||
|
visible: input.suggestedVisible ?? true,
|
||||||
|
port: input.port,
|
||||||
|
favicon: input.favicon ?? null,
|
||||||
|
description: input.description ?? null,
|
||||||
|
lastSuggestedDisplayName: input.suggestedDisplayName,
|
||||||
|
lastSuggestedCategory: input.suggestedCategory ?? null,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
// UNIQUE-Constraint (device_id, port) hat zugeschlagen - zwei
|
||||||
|
// überlappende Scans desselben Geräts haben beide "existiert noch
|
||||||
|
// nicht" gesehen und wollten beide anlegen. Statt einen Fehler nach
|
||||||
|
// oben zu werfen: der jeweils andere Versuch hat gewonnen, also einfach
|
||||||
|
// DESSEN Zeile als Ergebnis zurückgeben (kein Duplikat, kein Datenverlust).
|
||||||
|
const winner = findByDeviceAndPort(input.deviceId, input.port);
|
||||||
|
if (winner) {
|
||||||
|
return { service: winner, created: false, nameChanges: [] };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { service: getService(id)!, created: true, nameChanges: [] };
|
||||||
|
}
|
||||||
22
apps/backend/src/db/repositories/settings.ts
Normal file
22
apps/backend/src/db/repositories/settings.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "../client.js";
|
||||||
|
import { appSettings } from "../schema.js";
|
||||||
|
|
||||||
|
export function getSetting(key: string): string | null {
|
||||||
|
const row = db.select().from(appSettings).where(eq(appSettings.key, key)).get();
|
||||||
|
return row?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSetting(key: string, value: string): void {
|
||||||
|
const existing = getSetting(key);
|
||||||
|
if (existing === null) {
|
||||||
|
db.insert(appSettings).values({ key, value }).run();
|
||||||
|
} else {
|
||||||
|
db.update(appSettings).set({ value }).where(eq(appSettings.key, key)).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSettings(): Record<string, string> {
|
||||||
|
const rows = db.select().from(appSettings).all();
|
||||||
|
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
||||||
|
}
|
||||||
193
apps/backend/src/db/schema.ts
Normal file
193
apps/backend/src/db/schema.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
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
|
||||||
|
// Zeitpunkt des letzten Ping-Live-Status-Checks (siehe liveStatus.ts) -
|
||||||
|
// bewusst getrennt von lastScan, das an echte Scan-Funde gebunden ist und
|
||||||
|
// die 7-Tage-"nicht mehr gemeldet"-Schwelle beim FritzBox-Scan speist. Ein
|
||||||
|
// Ping ändert daran nichts, ist nur ein Erreichbarkeits-Heartbeat.
|
||||||
|
lastPing: text("last_ping"), // 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,
|
||||||
|
* hostname, url, https, visible) dürfen von einem erneuten Scan eines bereits
|
||||||
|
* bekannten Dienstes NIEMALS überschrieben werden – siehe README/Spezifikation.
|
||||||
|
* Nur favicon/description werden bei jedem Scan aufgefrischt.
|
||||||
|
*/
|
||||||
|
export const services = sqliteTable("services", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
deviceId: text("device_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => devices.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
// vom Benutzer gepflegt bzw. nur beim erstmaligen Anlegen durch einen Scan gesetzt
|
||||||
|
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"),
|
||||||
|
hostname: text("hostname").notNull(),
|
||||||
|
url: text("url").notNull(),
|
||||||
|
https: integer("https", { mode: "boolean" }).notNull().default(false),
|
||||||
|
// Ausgeblendete Dienste erscheinen nicht in der Suche, bleiben aber im Adminbereich.
|
||||||
|
visible: integer("visible", { mode: "boolean" }).notNull().default(true),
|
||||||
|
// Wird true, sobald displayName/category einmal manuell über die Admin-UI
|
||||||
|
// gesetzt wurden (siehe updateService). Dient dazu, bei einem erneuten Scan
|
||||||
|
// zu erkennen: weicht der Wert vom Scan-Vorschlag ab, WEIL der Nutzer ihn
|
||||||
|
// manuell angepasst hat (harmlos, nur zur Info) oder weil sich der
|
||||||
|
// tatsächlich erkannte Wert geändert hat (echte Änderung)?
|
||||||
|
displayNameEditedManually: integer("display_name_edited_manually", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
categoryEditedManually: integer("category_edited_manually", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
// Merkt sich den zuletzt vom Scanner berechneten Vorschlag (unabhängig
|
||||||
|
// davon, ob er zu einer Änderungsmeldung führte). Dient dazu, einmalige
|
||||||
|
// "Ausrutscher" abzufedern (z. B. eine SPA, deren <title> der Scanner nur
|
||||||
|
// gelegentlich rechtzeitig sieht): ein neuer, abweichender Vorschlag wird
|
||||||
|
// erst als Änderung gemeldet, wenn er beim ZWEITEN Mal in Folge wieder
|
||||||
|
// auftaucht - siehe upsertServiceFromScan.
|
||||||
|
lastSuggestedDisplayName: text("last_suggested_display_name"),
|
||||||
|
lastSuggestedCategory: text("last_suggested_category"),
|
||||||
|
|
||||||
|
// Bei jedem Scan aktualisierbar
|
||||||
|
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),
|
||||||
|
color: text("color"),
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manuell angelegtes Lesezeichen – im Unterschied zu Diensten nicht an ein
|
||||||
|
* gescanntes Gerät gebunden, eigenständige Tabelle. Erscheint zusammen mit
|
||||||
|
* Diensten in der Suche (siehe packages/shared rankServices), wird aber
|
||||||
|
* separat verwaltet (Admin -> Lesezeichen).
|
||||||
|
*/
|
||||||
|
export const bookmarks = sqliteTable("bookmarks", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
url: text("url").notNull(),
|
||||||
|
displayName: text("display_name").notNull(),
|
||||||
|
hostname: text("hostname").notNull(), // aus der URL abgeleitet
|
||||||
|
description: text("description"),
|
||||||
|
category: text("category"),
|
||||||
|
icon: text("icon"),
|
||||||
|
favicon: text("favicon"),
|
||||||
|
favorite: integer("favorite", { mode: "boolean" }).notNull().default(false),
|
||||||
|
alias: text("alias").notNull().default("[]"),
|
||||||
|
order: real("order").notNull().default(0),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
updatedAt: text("updated_at").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zuletzt aus der Suche geöffnete Dienste/Lesezeichen, für die
|
||||||
|
* "Zuletzt besucht"-Leiste auf der Startseite.
|
||||||
|
*/
|
||||||
|
export const recentVisits = sqliteTable("recent_visits", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
itemType: text("item_type").notNull(), // "service" | "bookmark"
|
||||||
|
itemId: text("item_id").notNull(),
|
||||||
|
visitedAt: text("visited_at").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Freie Schlüssel-Wert-Einstellungen, z. B. Anzahl "Zuletzt besucht". */
|
||||||
|
export const appSettings = sqliteTable("app_settings", {
|
||||||
|
key: text("key").primaryKey(),
|
||||||
|
value: text("value").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Später lesen": schnell von der Startseite abgelegte Links, unabhängig
|
||||||
|
* von Lesezeichen/Diensten. Können später zu einem Lesezeichen befördert
|
||||||
|
* oder gelöscht werden.
|
||||||
|
*/
|
||||||
|
export const readLater = sqliteTable("read_later", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
url: text("url").notNull(),
|
||||||
|
displayName: text("display_name").notNull(),
|
||||||
|
favicon: text("favicon"),
|
||||||
|
savedAt: text("saved_at").notNull(),
|
||||||
|
updatedAt: text("updated_at").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Von einem eigenen, separaten Scanner ("API-Scanner", siehe
|
||||||
|
* scanner/apiDetector.ts) gefundene API-Endpunkte bereits bekannter Dienste.
|
||||||
|
* Kein automatischer Teil des normalen Geräte-/Dienste-Scans - läuft nur auf
|
||||||
|
* ausdrücklichen Knopfdruck, genau wie die anderen Scanner.
|
||||||
|
*/
|
||||||
|
export const detectedApis = sqliteTable("detected_apis", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
serviceId: text("service_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => services.id, { onDelete: "cascade" }),
|
||||||
|
path: text("path").notNull(),
|
||||||
|
type: text("type").notNull(),
|
||||||
|
status: integer("status").notNull(),
|
||||||
|
detectedAt: text("detected_at").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merkt sich, welche Scan-Vorschläge (neuer Dienst, verschwundener Dienst,
|
||||||
|
* verschwundenes Gerät, Namens-/Kategorie-Änderung) bereits bestätigt ODER
|
||||||
|
* bewusst verworfen ("Behalten") wurden - SERVERSEITIG statt nur im
|
||||||
|
* sessionStorage des jeweiligen Browsers (siehe Bugreport: Bestätigen auf
|
||||||
|
* einem Gerät/Browser hatte auf einem anderen keine Wirkung, dort musste
|
||||||
|
* derselbe Vorschlag erneut bestätigt werden). "kind" unterscheidet die Art
|
||||||
|
* des Vorschlags ("new_service", "stale_service", "stale_device",
|
||||||
|
* "name_change"), "dedupeKey" identifiziert das konkrete Element (z. B. die
|
||||||
|
* Dienst-/Geräte-ID, bei Namensänderungen "<id>:<feld>"). Scan-Ergebnisse
|
||||||
|
* werden beim Ausliefern gegen diese Tabelle gefiltert (siehe routes/scan.ts)
|
||||||
|
* - ein einmal bestätigter/verworfener Vorschlag verschwindet dadurch für
|
||||||
|
* ALLE Browser/Geräte gleichzeitig, nicht nur lokal.
|
||||||
|
*/
|
||||||
|
export const scanDismissals = sqliteTable("scan_dismissals", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
kind: text("kind").notNull(),
|
||||||
|
dedupeKey: text("dedupe_key").notNull(),
|
||||||
|
dismissedAt: text("dismissed_at").notNull(),
|
||||||
|
});
|
||||||
11
apps/backend/src/env.ts
Normal file
11
apps/backend/src/env.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { config } from "dotenv";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
|
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// apps/backend/src/env.ts (bzw. apps/backend/dist/env.js) -> Repo-Root liegt
|
||||||
|
// drei Verzeichnisebenen höher. Existiert dort keine .env (z. B. im
|
||||||
|
// Docker-Image), passiert einfach nichts – die Werte kommen dann bereits aus
|
||||||
|
// der Container-Umgebung (docker-compose env_file/environment).
|
||||||
|
config({ path: resolve(currentDir, "../../../.env") });
|
||||||
98
apps/backend/src/index.ts
Normal file
98
apps/backend/src/index.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
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";
|
||||||
|
import { pluginRoutes } from "./routes/plugins.js";
|
||||||
|
import { resetRoutes } from "./routes/reset.js";
|
||||||
|
import { transferRoutes } from "./routes/transfer.js";
|
||||||
|
import { bookmarkRoutes } from "./routes/bookmarks.js";
|
||||||
|
import { recentVisitsRoutes } from "./routes/recentVisits.js";
|
||||||
|
import { settingsRoutes } from "./routes/settings.js";
|
||||||
|
import { readLaterRoutes } from "./routes/readLater.js";
|
||||||
|
import { faviconProxyRoutes } from "./routes/faviconProxy.js";
|
||||||
|
import { iconsRoutes } from "./routes/icons.js";
|
||||||
|
import { apiRoutes } from "./routes/apis.js";
|
||||||
|
import { loadPlugins } from "./plugins/loader.js";
|
||||||
|
import { startLiveStatusHeartbeat } from "./liveStatus.js";
|
||||||
|
import * as serviceRepo from "./db/repositories/services.js";
|
||||||
|
import * as bookmarkRepo from "./db/repositories/bookmarks.js";
|
||||||
|
import * as categoryRepo from "./db/repositories/categories.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();
|
||||||
|
|
||||||
|
// Kategorien, die bereits auf Diensten stehen (z. B. aus Scans vor diesem
|
||||||
|
// Fix, oder von Plugins), aber noch nicht in der categories-Tabelle sind,
|
||||||
|
// hier nachtragen. Damit zeigt Admin -> Kategorien immer das, was auch in
|
||||||
|
// der Suche als Kategorie auftaucht.
|
||||||
|
const existingCategoryNames = Array.from(
|
||||||
|
new Set(
|
||||||
|
[
|
||||||
|
...serviceRepo.listServices().map((s) => s.category),
|
||||||
|
...bookmarkRepo.listBookmarks().map((b) => b.category),
|
||||||
|
].filter((c): c is string => !!c)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const syncedCount = categoryRepo.syncCategoriesFromServiceValues(existingCategoryNames);
|
||||||
|
if (syncedCount > 0) {
|
||||||
|
app.log.info(`${syncedCount} Kategorie(n) aus bestehenden Diensten nachgetragen`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugins = await loadPlugins();
|
||||||
|
app.log.info(`${plugins.length} Plugin(s) geladen`);
|
||||||
|
|
||||||
|
await app.register(healthRoutes);
|
||||||
|
await app.register(deviceRoutes);
|
||||||
|
await app.register(serviceRoutes);
|
||||||
|
await app.register(categoryRoutes);
|
||||||
|
await app.register(scanRoutes);
|
||||||
|
await app.register(logRoutes);
|
||||||
|
await app.register(pluginRoutes);
|
||||||
|
await app.register(resetRoutes);
|
||||||
|
await app.register(transferRoutes);
|
||||||
|
await app.register(bookmarkRoutes);
|
||||||
|
await app.register(recentVisitsRoutes);
|
||||||
|
await app.register(settingsRoutes);
|
||||||
|
await app.register(readLaterRoutes);
|
||||||
|
await app.register(faviconProxyRoutes);
|
||||||
|
await app.register(iconsRoutes);
|
||||||
|
await app.register(apiRoutes);
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
startLiveStatusHeartbeat();
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
52
apps/backend/src/liveStatus.ts
Normal file
52
apps/backend/src/liveStatus.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import * as deviceRepo from "./db/repositories/devices.js";
|
||||||
|
import * as settingsRepo from "./db/repositories/settings.js";
|
||||||
|
import { pingHost } from "./scanner/ping.js";
|
||||||
|
|
||||||
|
const CHECK_INTERVAL_MS = 60 * 1000;
|
||||||
|
let lastRunAt = 0;
|
||||||
|
let running = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optionaler Live-Status-Heartbeat: pingt (ICMP, siehe scanner/ping.ts) alle
|
||||||
|
* bekannten Geräte in konfigurierbarem Abstand an und aktualisiert NUR deren
|
||||||
|
* online-Feld + lastPing-Zeitstempel.
|
||||||
|
*
|
||||||
|
* Bewusst STRIKT getrennt vom "Scans laufen ausschließlich manuell"-Prinzip
|
||||||
|
* (siehe Scanner-Seite): hier werden NIE Dienste neu angelegt, NIE Namen/
|
||||||
|
* Kategorien vorgeschlagen, NIE etwas zur Bestätigung vorgelegt - einzig der
|
||||||
|
* Online-Punkt neben einem Gerät wird aktualisiert. Standardmäßig
|
||||||
|
* AUSGESCHALTET (liveStatusEnabled=false), muss in den Einstellungen bewusst
|
||||||
|
* aktiviert werden.
|
||||||
|
*
|
||||||
|
* Statt eines echten setInterval mit dynamischer Laufzeit wird jede Minute
|
||||||
|
* geprüft, ob genug Zeit seit dem letzten Lauf vergangen ist - so greift eine
|
||||||
|
* Änderung des Intervalls in den Einstellungen beim nächsten Tick sofort,
|
||||||
|
* ohne den Timer neu aufsetzen zu müssen.
|
||||||
|
*/
|
||||||
|
export function startLiveStatusHeartbeat(): void {
|
||||||
|
setInterval(async () => {
|
||||||
|
if (running) return; // vorheriger Lauf noch nicht fertig - überspringen statt zu stapeln
|
||||||
|
|
||||||
|
const settings = settingsRepo.listSettings();
|
||||||
|
if (settings.liveStatusEnabled !== "true") return;
|
||||||
|
|
||||||
|
const intervalMinutes = Number(settings.liveStatusIntervalMinutes ?? 5);
|
||||||
|
const intervalMs = Math.max(1, intervalMinutes) * 60 * 1000;
|
||||||
|
if (Date.now() - lastRunAt < intervalMs) return;
|
||||||
|
|
||||||
|
running = true;
|
||||||
|
lastRunAt = Date.now();
|
||||||
|
try {
|
||||||
|
const devices = deviceRepo.listDevices();
|
||||||
|
// Nacheinander statt Promise.all, um das Netzwerk (und ggf. langsame
|
||||||
|
// Router/IoT-Geräte) nicht mit vielen gleichzeitigen Pings zu fluten.
|
||||||
|
for (const device of devices) {
|
||||||
|
const online = await pingHost(device.ip);
|
||||||
|
deviceRepo.updateDeviceOnlineStatus(device.id, online);
|
||||||
|
}
|
||||||
|
settingsRepo.setSetting("liveStatusLastRunAt", new Date().toISOString());
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
}, CHECK_INTERVAL_MS);
|
||||||
|
}
|
||||||
71
apps/backend/src/plugins/loader.ts
Normal file
71
apps/backend/src/plugins/loader.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import { registerSoftwareSignature } from "../scanner/softwareDetection.js";
|
||||||
|
import type { LaunchPadPlugin } from "./types.js";
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// apps/backend/src/plugins/loader.ts (bzw. dist/plugins/loader.js) -> das
|
||||||
|
// Plugin-Verzeichnis liegt direkt neben src/dist. Im Docker-Image entspricht
|
||||||
|
// das /app/plugins (siehe docker-compose.yml Bind-Mount), lokal
|
||||||
|
// apps/backend/plugins – Plugins lassen sich damit ohne Rebuild hinzufügen.
|
||||||
|
const PLUGINS_DIR = path.resolve(currentDir, "../../plugins");
|
||||||
|
|
||||||
|
export interface LoadedPlugin {
|
||||||
|
plugin: LaunchPadPlugin;
|
||||||
|
capabilities: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadedPlugins: LoadedPlugin[] = [];
|
||||||
|
|
||||||
|
export async function loadPlugins(): Promise<LoadedPlugin[]> {
|
||||||
|
loadedPlugins.length = 0;
|
||||||
|
|
||||||
|
if (!existsSync(PLUGINS_DIR)) {
|
||||||
|
return loadedPlugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = readdirSync(PLUGINS_DIR).filter((entry) => {
|
||||||
|
const full = path.join(PLUGINS_DIR, entry);
|
||||||
|
return statSync(full).isDirectory();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPoint = path.join(PLUGINS_DIR, entry, "index.js");
|
||||||
|
if (!existsSync(entryPoint)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imported = await import(pathToFileURL(entryPoint).href);
|
||||||
|
const plugin: LaunchPadPlugin | undefined = imported.default ?? imported.plugin;
|
||||||
|
|
||||||
|
if (!plugin || !plugin.name || !plugin.version) {
|
||||||
|
console.warn(`Plugin-Ordner "${entry}" liefert kein gültiges Plugin-Objekt, wird übersprungen.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plugin.setup) {
|
||||||
|
await plugin.setup({ registerSoftwareSignature });
|
||||||
|
}
|
||||||
|
|
||||||
|
const capabilities: string[] = [];
|
||||||
|
if (plugin.setup) capabilities.push("scanner");
|
||||||
|
if (plugin.importDevices) capabilities.push("device-import");
|
||||||
|
|
||||||
|
loadedPlugins.push({ plugin, capabilities });
|
||||||
|
console.log(`Plugin geladen: ${plugin.name}@${plugin.version} [${capabilities.join(", ") || "keine Capabilities"}]`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Plugin "${entry}" konnte nicht geladen werden:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadedPlugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLoadedPlugins(): LoadedPlugin[] {
|
||||||
|
return loadedPlugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlugin(name: string): LoadedPlugin | undefined {
|
||||||
|
return loadedPlugins.find((p) => p.plugin.name === name);
|
||||||
|
}
|
||||||
22
apps/backend/src/plugins/types.ts
Normal file
22
apps/backend/src/plugins/types.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { DeviceCreateInput } from "@launchpad/shared";
|
||||||
|
import type { SoftwareSignature } from "../scanner/softwareDetection.js";
|
||||||
|
|
||||||
|
export interface PluginContext {
|
||||||
|
/** Erweitert die Softwareerkennung des Scanners um eine eigene Signatur. */
|
||||||
|
registerSoftwareSignature: (signature: SoftwareSignature) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertrag, den jedes Plugin erfüllt. Ein Plugin ist ein Ordner unter
|
||||||
|
* apps/backend/plugins/<name>/ mit einer index.js, deren Default-Export
|
||||||
|
* dieses Objekt ist. Siehe apps/backend/plugins/README.md.
|
||||||
|
*/
|
||||||
|
export interface LaunchPadPlugin {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description?: string;
|
||||||
|
/** Wird einmal beim Start aufgerufen, z. B. um Scanner-Signaturen zu registrieren. */
|
||||||
|
setup?: (ctx: PluginContext) => void | Promise<void>;
|
||||||
|
/** Optional: eigene Geräte-Importquelle (z. B. Inventarliste, externe API). */
|
||||||
|
importDevices?: () => Promise<DeviceCreateInput[]>;
|
||||||
|
}
|
||||||
94
apps/backend/src/routes/apis.ts
Normal file
94
apps/backend/src/routes/apis.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import * as serviceRepo from "../db/repositories/services.js";
|
||||||
|
import * as apiRepo from "../db/repositories/apis.js";
|
||||||
|
import * as logRepo from "../db/repositories/logs.js";
|
||||||
|
import { detectApis } from "../scanner/apiDetector.js";
|
||||||
|
import { startJob, updateJobProgress, finishJob, failJob, getJobStatus, isJobRunning } from "../scanJobs.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API-Scanner: eigener, von den Geräte-/FritzBox-Scannern komplett
|
||||||
|
* unabhängiger manueller Scan (siehe Scanner-Seite) - durchsucht bereits
|
||||||
|
* bekannte Dienste nach üblichen API-Pfaden (siehe scanner/apiDetector.ts)
|
||||||
|
* und speichert die Funde. Läuft NIE automatisch, nur auf Knopfdruck.
|
||||||
|
*
|
||||||
|
* Läuft als Hintergrund-Job im Backend (siehe scanJobs.ts), nicht mehr
|
||||||
|
* innerhalb einer einzigen HTTP-Anfrage: bei vielen Diensten (jeweils ~40
|
||||||
|
* geprüfte Pfade) kann das insgesamt länger dauern, als ein
|
||||||
|
* Reverse-Proxy/Browser auf eine Antwort wartet (führte zu HTTP 504). Der
|
||||||
|
* POST-Aufruf startet den Job nur und antwortet sofort, der Fortschritt wird
|
||||||
|
* über GET .../status abgefragt - das macht den laufenden Scan zudem
|
||||||
|
* geräteübergreifend sichtbar (der Status lebt im Backend, nicht im Browser).
|
||||||
|
*/
|
||||||
|
export async function apiRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/api/detected-apis", async () => {
|
||||||
|
return apiRepo.listDetectedApis();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/scan/apis/status", async () => {
|
||||||
|
return getJobStatus("apis");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/scan/apis", async (_request, reply) => {
|
||||||
|
if (isJobRunning("apis")) {
|
||||||
|
return reply.code(409).send({ error: "Es läuft bereits ein API-Scan." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const services = serviceRepo.listServices();
|
||||||
|
startJob("apis", services.length);
|
||||||
|
|
||||||
|
// Bewusst NICHT awaited - der Job läuft im Hintergrund weiter, die
|
||||||
|
// HTTP-Antwort kommt sofort zurück (siehe Doku oben).
|
||||||
|
void (async () => {
|
||||||
|
let servicesWithApi = 0;
|
||||||
|
let totalFound = 0;
|
||||||
|
const newFindings: { serviceId: string; serviceName: string; apis: apiRepo.DetectedApiEntry[] }[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < services.length; i++) {
|
||||||
|
const service = services[i];
|
||||||
|
updateJobProgress("apis", i, service.displayName);
|
||||||
|
|
||||||
|
const found = await detectApis(service.url);
|
||||||
|
if (found.length > 0) {
|
||||||
|
const { added } = apiRepo.replaceApisForService(service.id, found);
|
||||||
|
servicesWithApi++;
|
||||||
|
totalFound += found.length;
|
||||||
|
if (added.length > 0) {
|
||||||
|
newFindings.push({ serviceId: service.id, serviceName: service.displayName, apis: added });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
apiRepo.deleteApisForService(service.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logRepo.logScan({
|
||||||
|
type: "api",
|
||||||
|
targetId: null,
|
||||||
|
level: "info",
|
||||||
|
message: `API-Scan: ${services.length} Dienst(e) geprüft, bei ${servicesWithApi} Dienst(en) ${totalFound} API-Endpunkt(e) gefunden.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
finishJob("apis", { checked: services.length, servicesWithApi, totalFound, newFindings });
|
||||||
|
} catch (err) {
|
||||||
|
failJob("apis", err instanceof Error ? err.message : "Unbekannter Fehler");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return reply.code(202).send({ started: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/scan/apis/:serviceId", async (request, reply) => {
|
||||||
|
const { serviceId } = request.params as { serviceId: string };
|
||||||
|
const service = serviceRepo.getService(serviceId);
|
||||||
|
if (!service) {
|
||||||
|
return reply.code(404).send({ error: "Dienst nicht gefunden" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = await detectApis(service.url);
|
||||||
|
const { saved, added } =
|
||||||
|
found.length > 0 ? apiRepo.replaceApisForService(service.id, found) : { saved: [], added: [] };
|
||||||
|
if (found.length === 0) apiRepo.deleteApisForService(service.id);
|
||||||
|
|
||||||
|
return { serviceId, apis: saved, added };
|
||||||
|
});
|
||||||
|
}
|
||||||
93
apps/backend/src/routes/bookmarks.ts
Normal file
93
apps/backend/src/routes/bookmarks.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { BookmarkCreateSchema, BookmarkReorderSchema, BookmarkUpdateSchema } from "@launchpad/shared";
|
||||||
|
import * as bookmarkRepo from "../db/repositories/bookmarks.js";
|
||||||
|
import * as categoryRepo from "../db/repositories/categories.js";
|
||||||
|
import { probeHttp } from "../scanner/http.js";
|
||||||
|
|
||||||
|
export async function bookmarkRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/api/bookmarks", async () => {
|
||||||
|
return bookmarkRepo.listBookmarks();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/bookmarks/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const bookmark = bookmarkRepo.getBookmark(id);
|
||||||
|
if (!bookmark) {
|
||||||
|
return reply.code(404).send({ error: "Lesezeichen nicht gefunden" });
|
||||||
|
}
|
||||||
|
return bookmark;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/bookmarks", async (request, reply) => {
|
||||||
|
const parsed = BookmarkCreateSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
|
||||||
|
let displayName = parsed.data.displayName;
|
||||||
|
let favicon: string | null = null;
|
||||||
|
let description = parsed.data.description;
|
||||||
|
|
||||||
|
// Titel/Favicon/Beschreibung automatisch ziehen, falls nicht angegeben
|
||||||
|
// (derselbe Mechanismus wie beim Netzwerk-Scanner, siehe
|
||||||
|
// apps/backend/src/scanner/http.ts).
|
||||||
|
try {
|
||||||
|
const probe = await probeHttp(parsed.data.url, 5000);
|
||||||
|
if (!displayName) {
|
||||||
|
displayName = probe.title ?? bookmarkRepo.extractHostname(parsed.data.url);
|
||||||
|
}
|
||||||
|
if (!description) {
|
||||||
|
description = probe.description;
|
||||||
|
}
|
||||||
|
favicon = probe.faviconUrl ?? null;
|
||||||
|
} catch {
|
||||||
|
if (!displayName) {
|
||||||
|
displayName = bookmarkRepo.extractHostname(parsed.data.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.category) {
|
||||||
|
categoryRepo.ensureCategory(parsed.data.category);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookmark = bookmarkRepo.createBookmark(
|
||||||
|
{ ...parsed.data, description },
|
||||||
|
{ displayName, favicon }
|
||||||
|
);
|
||||||
|
return reply.code(201).send(bookmark);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Muss vor der /:id-Route stehen, damit "reorder" nicht als ID interpretiert wird.
|
||||||
|
app.patch("/api/bookmarks/reorder", async (request, reply) => {
|
||||||
|
const parsed = BookmarkReorderSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
return bookmarkRepo.reorderBookmarks(parsed.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/bookmarks/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const parsed = BookmarkUpdateSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
if (parsed.data.category) {
|
||||||
|
categoryRepo.ensureCategory(parsed.data.category);
|
||||||
|
}
|
||||||
|
const bookmark = bookmarkRepo.updateBookmark(id, parsed.data);
|
||||||
|
if (!bookmark) {
|
||||||
|
return reply.code(404).send({ error: "Lesezeichen nicht gefunden" });
|
||||||
|
}
|
||||||
|
return bookmark;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/bookmarks/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const deleted = bookmarkRepo.deleteBookmark(id);
|
||||||
|
if (!deleted) {
|
||||||
|
return reply.code(404).send({ error: "Lesezeichen nicht gefunden" });
|
||||||
|
}
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
53
apps/backend/src/routes/categories.ts
Normal file
53
apps/backend/src/routes/categories.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import {
|
||||||
|
CategoryCreateSchema,
|
||||||
|
CategoryReorderSchema,
|
||||||
|
CategoryUpdateSchema,
|
||||||
|
} from "@launchpad/shared";
|
||||||
|
import * as categoryRepo from "../db/repositories/categories.js";
|
||||||
|
|
||||||
|
export async function categoryRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/api/categories", async () => {
|
||||||
|
return categoryRepo.listCategories();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/categories", async (request, reply) => {
|
||||||
|
const parsed = CategoryCreateSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
const category = categoryRepo.createCategory(parsed.data);
|
||||||
|
return reply.code(201).send(category);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Muss vor der /:id-Route registriert sein, damit "reorder" nicht als ID interpretiert wird.
|
||||||
|
app.patch("/api/categories/reorder", async (request, reply) => {
|
||||||
|
const parsed = CategoryReorderSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
return categoryRepo.reorderCategories(parsed.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/categories/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const parsed = CategoryUpdateSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
const category = categoryRepo.updateCategory(id, parsed.data);
|
||||||
|
if (!category) {
|
||||||
|
return reply.code(404).send({ error: "Kategorie nicht gefunden" });
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/categories/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const deleted = categoryRepo.deleteCategory(id);
|
||||||
|
if (!deleted) {
|
||||||
|
return reply.code(404).send({ error: "Kategorie nicht gefunden" });
|
||||||
|
}
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
53
apps/backend/src/routes/devices.ts
Normal file
53
apps/backend/src/routes/devices.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { DeviceCreateSchema, DeviceUpdateSchema } from "@launchpad/shared";
|
||||||
|
import * as deviceRepo from "../db/repositories/devices.js";
|
||||||
|
import * as serviceRepo from "../db/repositories/services.js";
|
||||||
|
|
||||||
|
export async function deviceRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/api/devices", async () => {
|
||||||
|
return deviceRepo.listDevices().map((device) => ({
|
||||||
|
...device,
|
||||||
|
services: serviceRepo.listServicesByDevice(device.id),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/devices/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const device = deviceRepo.getDevice(id);
|
||||||
|
if (!device) {
|
||||||
|
return reply.code(404).send({ error: "Gerät nicht gefunden" });
|
||||||
|
}
|
||||||
|
return { ...device, services: serviceRepo.listServicesByDevice(id) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/devices", async (request, reply) => {
|
||||||
|
const parsed = DeviceCreateSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
const device = deviceRepo.createDevice(parsed.data);
|
||||||
|
return reply.code(201).send(device);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/devices/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const parsed = DeviceUpdateSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
const device = deviceRepo.updateDevice(id, parsed.data);
|
||||||
|
if (!device) {
|
||||||
|
return reply.code(404).send({ error: "Gerät nicht gefunden" });
|
||||||
|
}
|
||||||
|
return device;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/devices/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const deleted = deviceRepo.deleteDevice(id);
|
||||||
|
if (!deleted) {
|
||||||
|
return reply.code(404).send({ error: "Gerät nicht gefunden" });
|
||||||
|
}
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
90
apps/backend/src/routes/faviconProxy.ts
Normal file
90
apps/backend/src/routes/faviconProxy.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import http from "node:http";
|
||||||
|
import https from "node:https";
|
||||||
|
|
||||||
|
const MAX_BYTES = 2 * 1024 * 1024; // 2 MB reicht für jedes realistische Favicon
|
||||||
|
const TIMEOUT_MS = 4000;
|
||||||
|
|
||||||
|
function fetchImage(
|
||||||
|
targetUrl: string
|
||||||
|
): Promise<{ statusCode: number; contentType: string; body: Buffer }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(targetUrl);
|
||||||
|
} catch {
|
||||||
|
reject(new Error("Ungültige URL"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
|
reject(new Error("Nur http/https erlaubt"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = parsed.protocol === "https:" ? https : http;
|
||||||
|
const req = client.get(
|
||||||
|
parsed,
|
||||||
|
{
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
// Homelab-Geräte haben oft selbstsignierte Zertifikate.
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const statusCode = res.statusCode ?? 0;
|
||||||
|
if (statusCode >= 300 && statusCode < 400 && res.headers.location) {
|
||||||
|
res.resume();
|
||||||
|
fetchImage(new URL(res.headers.location, targetUrl).toString())
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let size = 0;
|
||||||
|
res.on("data", (chunk: Buffer) => {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > MAX_BYTES) {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error("Bild zu groß"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
res.on("end", () => {
|
||||||
|
resolve({
|
||||||
|
statusCode,
|
||||||
|
contentType: res.headers["content-type"] ?? "image/x-icon",
|
||||||
|
body: Buffer.concat(chunks),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
res.on("error", reject);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
req.on("timeout", () => req.destroy(new Error("Zeitüberschreitung")));
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function faviconProxyRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/api/favicon-proxy", async (request, reply) => {
|
||||||
|
const { url } = request.query as { url?: string };
|
||||||
|
if (!url) {
|
||||||
|
return reply.code(400).send({ error: "url erforderlich" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const image = await fetchImage(url);
|
||||||
|
if (image.statusCode >= 400 || image.body.length === 0) {
|
||||||
|
return reply.code(404).send();
|
||||||
|
}
|
||||||
|
reply.header("Cache-Control", "public, max-age=86400");
|
||||||
|
reply.type(image.contentType);
|
||||||
|
return reply.send(image.body);
|
||||||
|
} catch {
|
||||||
|
// Icon nicht ladbar (Gerät offline, kaputte URL, ...) - 404 statt 500,
|
||||||
|
// damit das Frontend sauber auf den Buchstaben-Fallback zurückfällt.
|
||||||
|
return reply.code(404).send();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
15
apps/backend/src/routes/health.ts
Normal file
15
apps/backend/src/routes/health.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { HealthStatus } from "@launchpad/shared";
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
export async function healthRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/api/health", async (): Promise<HealthStatus> => {
|
||||||
|
return {
|
||||||
|
status: "ok",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptimeSeconds: Math.round((Date.now() - startedAt) / 1000),
|
||||||
|
version: "0.1.0",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
10
apps/backend/src/routes/icons.ts
Normal file
10
apps/backend/src/routes/icons.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { searchIconDb } from "../scanner/iconDb.js";
|
||||||
|
|
||||||
|
export async function iconsRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/api/icons/search", async (request) => {
|
||||||
|
const { q } = request.query as { q?: string };
|
||||||
|
const results = await searchIconDb(q ?? "");
|
||||||
|
return { results };
|
||||||
|
});
|
||||||
|
}
|
||||||
10
apps/backend/src/routes/logs.ts
Normal file
10
apps/backend/src/routes/logs.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import * as logRepo from "../db/repositories/logs.js";
|
||||||
|
|
||||||
|
export async function logRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/api/logs", async (request) => {
|
||||||
|
const query = request.query as { limit?: string };
|
||||||
|
const limit = query.limit ? Math.min(Number(query.limit), 500) : 100;
|
||||||
|
return logRepo.listLogs(limit);
|
||||||
|
});
|
||||||
|
}
|
||||||
65
apps/backend/src/routes/plugins.ts
Normal file
65
apps/backend/src/routes/plugins.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import * as deviceRepo from "../db/repositories/devices.js";
|
||||||
|
import * as logRepo from "../db/repositories/logs.js";
|
||||||
|
import { getLoadedPlugins, getPlugin } from "../plugins/loader.js";
|
||||||
|
|
||||||
|
export async function pluginRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/api/plugins", async () => {
|
||||||
|
return getLoadedPlugins().map(({ plugin, capabilities }) => ({
|
||||||
|
name: plugin.name,
|
||||||
|
version: plugin.version,
|
||||||
|
description: plugin.description,
|
||||||
|
capabilities,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manueller Geräte-Import über ein Plugin – wie Scans nie automatisch,
|
||||||
|
// nur per Knopfdruck (Admin -> Plugins).
|
||||||
|
app.post("/api/plugins/:name/import", async (request, reply) => {
|
||||||
|
const { name } = request.params as { name: string };
|
||||||
|
const loaded = getPlugin(name);
|
||||||
|
|
||||||
|
if (!loaded) {
|
||||||
|
return reply.code(404).send({ error: "Plugin nicht gefunden" });
|
||||||
|
}
|
||||||
|
if (!loaded.plugin.importDevices) {
|
||||||
|
return reply.code(400).send({ error: "Dieses Plugin unterstützt keinen Geräte-Import" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const devices = await loaded.plugin.importDevices();
|
||||||
|
const results = devices.map((d) =>
|
||||||
|
deviceRepo.upsertDeviceFromScan({
|
||||||
|
hostname: d.hostname,
|
||||||
|
ip: d.ip,
|
||||||
|
mac: d.mac,
|
||||||
|
manufacturer: d.manufacturer,
|
||||||
|
model: d.model,
|
||||||
|
online: d.online ?? false,
|
||||||
|
source: "plugin",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const imported = results.map((r) => r.device);
|
||||||
|
const deviceChanges = results.flatMap((r) =>
|
||||||
|
r.changes.map((c) => ({ deviceId: r.device.id, ...c }))
|
||||||
|
);
|
||||||
|
|
||||||
|
logRepo.logScan({
|
||||||
|
type: "device",
|
||||||
|
level: "info",
|
||||||
|
message: `Plugin "${name}": ${imported.length} Gerät(e) importiert${deviceChanges.length > 0 ? `, ${deviceChanges.length} Änderung(en) vorgeschlagen` : ""}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { imported: imported.length, devices: imported, deviceChanges };
|
||||||
|
} catch (err) {
|
||||||
|
const detail = err instanceof Error ? err.message : String(err);
|
||||||
|
logRepo.logScan({
|
||||||
|
type: "device",
|
||||||
|
level: "error",
|
||||||
|
message: `Plugin "${name}": Import fehlgeschlagen – ${detail}`,
|
||||||
|
});
|
||||||
|
request.log.error(err);
|
||||||
|
return reply.code(500).send({ error: "Import fehlgeschlagen", detail });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
71
apps/backend/src/routes/readLater.ts
Normal file
71
apps/backend/src/routes/readLater.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import * as readLaterRepo from "../db/repositories/readLater.js";
|
||||||
|
import * as bookmarkRepo from "../db/repositories/bookmarks.js";
|
||||||
|
import { probeHttp } from "../scanner/http.js";
|
||||||
|
|
||||||
|
export async function readLaterRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/api/read-later", async () => {
|
||||||
|
return readLaterRepo.listReadLater();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/read-later", async (request, reply) => {
|
||||||
|
const body = request.body as { url?: string } | undefined;
|
||||||
|
if (!body?.url) {
|
||||||
|
return reply.code(400).send({ error: "url erforderlich" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let name: string;
|
||||||
|
let favicon: string | null = null;
|
||||||
|
try {
|
||||||
|
const probe = await probeHttp(body.url, 5000);
|
||||||
|
name = probe.title ?? new URL(body.url).hostname;
|
||||||
|
favicon = probe.faviconUrl ?? null;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
name = new URL(body.url).hostname;
|
||||||
|
} catch {
|
||||||
|
name = body.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = readLaterRepo.createReadLaterItem({ url: body.url, displayName: name, favicon });
|
||||||
|
return reply.code(201).send(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/read-later/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const body = request.body as { url?: string; displayName?: string } | undefined;
|
||||||
|
const item = readLaterRepo.updateReadLaterItem(id, body ?? {});
|
||||||
|
if (!item) {
|
||||||
|
return reply.code(404).send({ error: "Eintrag nicht gefunden" });
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/read-later/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const deleted = readLaterRepo.deleteReadLaterItem(id);
|
||||||
|
if (!deleted) {
|
||||||
|
return reply.code(404).send({ error: "Eintrag nicht gefunden" });
|
||||||
|
}
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verschiebt einen "Später lesen"-Eintrag in die Lesezeichen (als Favorit)
|
||||||
|
// und entfernt ihn aus der Liste.
|
||||||
|
app.post("/api/read-later/:id/promote", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const item = readLaterRepo.getReadLaterItem(id);
|
||||||
|
if (!item) {
|
||||||
|
return reply.code(404).send({ error: "Eintrag nicht gefunden" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookmark = bookmarkRepo.createBookmark(
|
||||||
|
{ url: item.url, favorite: true },
|
||||||
|
{ displayName: item.displayName, favicon: item.favicon }
|
||||||
|
);
|
||||||
|
readLaterRepo.deleteReadLaterItem(id);
|
||||||
|
|
||||||
|
return reply.code(201).send(bookmark);
|
||||||
|
});
|
||||||
|
}
|
||||||
27
apps/backend/src/routes/recentVisits.ts
Normal file
27
apps/backend/src/routes/recentVisits.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import * as recentVisitsRepo from "../db/repositories/recentVisits.js";
|
||||||
|
|
||||||
|
export async function recentVisitsRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/api/recent-visits", async (request) => {
|
||||||
|
const query = request.query as { limit?: string };
|
||||||
|
const limit = query.limit ? Number(query.limit) : undefined;
|
||||||
|
return recentVisitsRepo.listRecentVisits(limit);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/recent-visits", async (request, reply) => {
|
||||||
|
const body = request.body as { itemType?: string; itemId?: string } | undefined;
|
||||||
|
if (body?.itemType !== "service" && body?.itemType !== "bookmark") {
|
||||||
|
return reply.code(400).send({ error: "itemType muss 'service' oder 'bookmark' sein" });
|
||||||
|
}
|
||||||
|
if (!body.itemId) {
|
||||||
|
return reply.code(400).send({ error: "itemId erforderlich" });
|
||||||
|
}
|
||||||
|
recentVisitsRepo.recordVisit(body.itemType, body.itemId);
|
||||||
|
return reply.code(201).send({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/recent-visits", async () => {
|
||||||
|
recentVisitsRepo.clearRecentVisits();
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
34
apps/backend/src/routes/reset.ts
Normal file
34
apps/backend/src/routes/reset.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import * as deviceRepo from "../db/repositories/devices.js";
|
||||||
|
import * as logRepo from "../db/repositories/logs.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht ALLE Geräte und (per FK-Cascade) alle zugehörigen Dienste.
|
||||||
|
* Kategorien und Logs bleiben unangetastet.
|
||||||
|
*
|
||||||
|
* Erfordert { confirm: true } im Body – zusätzlich zur Bestätigung im
|
||||||
|
* Frontend eine zweite Sicherung gegen versehentliche Aufrufe (z. B. per
|
||||||
|
* Skript oder Browser-Erweiterung).
|
||||||
|
*/
|
||||||
|
export async function resetRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.post("/api/reset", async (request, reply) => {
|
||||||
|
const body = request.body as { confirm?: boolean } | undefined;
|
||||||
|
|
||||||
|
if (body?.confirm !== true) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: "Bestätigung erforderlich. Bitte { \"confirm\": true } im Body senden.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceCount = deviceRepo.listDevices().length;
|
||||||
|
const deletedCount = deviceRepo.deleteAllDevices();
|
||||||
|
|
||||||
|
logRepo.logScan({
|
||||||
|
type: "device",
|
||||||
|
level: "info",
|
||||||
|
message: `Reset: ${deletedCount} Gerät(e) und alle zugehörigen Dienste gelöscht`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { deletedDevices: deletedCount, previousDeviceCount: deviceCount };
|
||||||
|
});
|
||||||
|
}
|
||||||
939
apps/backend/src/routes/scan.ts
Normal file
939
apps/backend/src/routes/scan.ts
Normal file
@@ -0,0 +1,939 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import * as deviceRepo from "../db/repositories/devices.js";
|
||||||
|
import * as serviceRepo from "../db/repositories/services.js";
|
||||||
|
import * as categoryRepo from "../db/repositories/categories.js";
|
||||||
|
import * as logRepo from "../db/repositories/logs.js";
|
||||||
|
import * as settingsRepo from "../db/repositories/settings.js";
|
||||||
|
import * as dismissalRepo from "../db/repositories/scanDismissals.js";
|
||||||
|
import { scanDeviceServices, type PreFetchedScanData } from "../scanner/networkScanner.js";
|
||||||
|
import { fullPortRange } from "../scanner/ports.js";
|
||||||
|
import { scanHostsWithMasscan, getMasscanLastError } from "../scanner/masscan.js";
|
||||||
|
import { probeManyWithHttpx, getHttpxLastError, type HttpxResult } from "../scanner/httpx.js";
|
||||||
|
import { fetchFritzBoxHosts, type FritzBoxHost } from "../scanner/fritzbox.js";
|
||||||
|
import {
|
||||||
|
startJob,
|
||||||
|
updateJobProgress,
|
||||||
|
finishJob,
|
||||||
|
failJob,
|
||||||
|
getJobStatus,
|
||||||
|
isJobRunning,
|
||||||
|
isCancelRequested,
|
||||||
|
requestCancel,
|
||||||
|
tryLockDevice,
|
||||||
|
unlockDevice,
|
||||||
|
getScanningDeviceIds,
|
||||||
|
} from "../scanJobs.js";
|
||||||
|
import type { Device } from "@launchpad/shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatiert den masscan/httpx-Status für die Scan-Log-Zeile. Zeigt bei
|
||||||
|
* masscan "nicht relevant" statt "nicht genutzt", wenn es bei diesem Scan
|
||||||
|
* gar nicht versucht wurde (masscan läuft nur beim vollständigen Portscan,
|
||||||
|
* siehe networkScanner.ts) - und hängt bei echten Fehlschlägen die konkrete
|
||||||
|
* Fehlermeldung an, statt nur "nicht genutzt/verfügbar" ohne Erklärung.
|
||||||
|
*/
|
||||||
|
function formatToolsUsed(toolsUsed: {
|
||||||
|
masscan: boolean;
|
||||||
|
masscanAttempted: boolean;
|
||||||
|
masscanError: string | null;
|
||||||
|
httpx: boolean;
|
||||||
|
httpxError: string | null;
|
||||||
|
}): string {
|
||||||
|
const masscanPart = !toolsUsed.masscanAttempted
|
||||||
|
? "masscan: nicht relevant (kein Volltest)"
|
||||||
|
: toolsUsed.masscan
|
||||||
|
? "masscan: ✓ genutzt"
|
||||||
|
: `masscan: ✗ fehlgeschlagen${toolsUsed.masscanError ? ` (${toolsUsed.masscanError})` : " (Grund unbekannt)"}`;
|
||||||
|
|
||||||
|
const httpxPart = toolsUsed.httpx
|
||||||
|
? "httpx: ✓ genutzt"
|
||||||
|
: `httpx: ✗ fehlgeschlagen${toolsUsed.httpxError ? ` (${toolsUsed.httpxError})` : " (Grund unbekannt)"}`;
|
||||||
|
|
||||||
|
return `${masscanPart}, ${httpxPart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löst FRITZBOX_HOST (Umgebungsvariable, siehe /api/scan/fritzbox weiter
|
||||||
|
* unten) zur IP-Adresse des Routers selbst auf - wird genutzt, um genau
|
||||||
|
* dieses eine Gerät vom vollständigen masscan-Portscan im Sammel-Scan
|
||||||
|
* auszunehmen (siehe Kommentar dort). Ist FRITZBOX_HOST bereits eine
|
||||||
|
* IP-Adresse, wird sie direkt übernommen, sonst per DNS aufgelöst. Liefert
|
||||||
|
* null, wenn nicht konfiguriert oder nicht auflösbar - dann greift einfach
|
||||||
|
* keine Ausnahme, wie vor round58.
|
||||||
|
*/
|
||||||
|
async function resolveRouterIp(): Promise<string | null> {
|
||||||
|
const host = process.env.FRITZBOX_HOST;
|
||||||
|
if (!host) return null;
|
||||||
|
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return host;
|
||||||
|
try {
|
||||||
|
const { lookup } = await import("node:dns/promises");
|
||||||
|
const { address } = await lookup(host);
|
||||||
|
return address;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scannt EIN Gerät (Ports, Titel/Favicon, Softwareerkennung) und schreibt die
|
||||||
|
* Ergebnisse in die Datenbank. Ausgelagert aus der Route, damit sowohl der
|
||||||
|
* Einzelgerät-Endpunkt als auch der Sammel-Scan (siehe /api/scan/devices/all
|
||||||
|
* weiter unten) dieselbe Logik nutzen, ohne sie zu duplizieren.
|
||||||
|
*/
|
||||||
|
async function performDeviceScan(device: Device, fullScan = false, preFetched?: PreFetchedScanData) {
|
||||||
|
// Bestehende Dienste dieses Geräts VOR dem Scan merken, um danach zu
|
||||||
|
// erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale").
|
||||||
|
const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id);
|
||||||
|
|
||||||
|
const { services: discovered, suggestedHostname, toolsUsed } = await scanDeviceServices(
|
||||||
|
device,
|
||||||
|
fullScan ? fullPortRange() : undefined,
|
||||||
|
servicesBeforeScan.map((s) => s.port),
|
||||||
|
preFetched
|
||||||
|
);
|
||||||
|
|
||||||
|
// Jede erkannte Kategorie auch in der categories-Tabelle anlegen, damit
|
||||||
|
// sie unter Admin -> Kategorien auftaucht und dort umbenannt/sortiert
|
||||||
|
// werden kann (services.category ist reiner Freitext, kein Fremdschlüssel).
|
||||||
|
for (const category of new Set(discovered.map((d) => d.category).filter((c): c is string => !!c))) {
|
||||||
|
categoryRepo.ensureCategory(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
suggestedIcon: found.icon,
|
||||||
|
suggestedVisible: found.visible,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
const ports = discovered.map((d) => d.port).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
// Dienste, die es vorher gab, aber diesmal nicht mehr gefunden wurden
|
||||||
|
// (Port nicht mehr offen) – werden NICHT automatisch gelöscht, sondern
|
||||||
|
// erst nach dem konfigurierten Schwellenwert (gleiche Einstellung wie
|
||||||
|
// bei nicht mehr gemeldeten Geräten, siehe Einstellungen) zur
|
||||||
|
// manuellen Durchsicht zurückgegeben. Ein einzelner verpasster Scan
|
||||||
|
// (z. B. Gerät kurz neu gestartet) soll nicht sofort als "verschwunden"
|
||||||
|
// gemeldet werden.
|
||||||
|
const foundPorts = new Set(discovered.map((d) => d.port));
|
||||||
|
const staleThresholdDays = Number(settingsRepo.listSettings().staleDeviceThresholdDays ?? 7);
|
||||||
|
const staleThresholdMs = staleThresholdDays * 24 * 60 * 60 * 1000;
|
||||||
|
const now = Date.now();
|
||||||
|
const staleServiceCandidates = servicesBeforeScan.filter((s) => {
|
||||||
|
if (foundPorts.has(s.port)) return false;
|
||||||
|
const lastSeen = s.updatedAt ? new Date(s.updatedAt).getTime() : 0;
|
||||||
|
return now - lastSeen >= staleThresholdMs;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ein Dienst, der in DIESEM Scan wieder gefunden wurde, ist nicht mehr
|
||||||
|
// "verschwunden" - eine frühere Bestätigung/Ablehnung ("ist ok, weg damit"
|
||||||
|
// bzw. "nein, das war ein Fehlalarm") bezog sich auf DIESE Abwesenheits-
|
||||||
|
// Episode und soll eine KÜNFTIGE (neue) Abwesenheit nicht stumm
|
||||||
|
// unterdrücken. Alte Dismiss-Einträge für wiedergefundene Dienste daher
|
||||||
|
// hier auflösen.
|
||||||
|
dismissalRepo.clearDismissals(
|
||||||
|
"stale_service",
|
||||||
|
servicesBeforeScan.filter((s) => foundPorts.has(s.port)).map((s) => s.id)
|
||||||
|
);
|
||||||
|
const dismissedStaleServiceIds = dismissalRepo.getDismissedKeys("stale_service");
|
||||||
|
const staleServices = staleServiceCandidates.filter((s) => !dismissedStaleServiceIds.has(s.id));
|
||||||
|
|
||||||
|
// Vorschläge für abweichende Namen/Kategorien bereits bekannter Dienste
|
||||||
|
// einsammeln (siehe upsertServiceFromScan) - werden NICHT automatisch
|
||||||
|
// übernommen, nur zur Bestätigung zurückgegeben. dedupeKey enthält den
|
||||||
|
// vorgeschlagenen WERT mit, damit eine einmal verworfene Änderung nicht
|
||||||
|
// auch eine völlig ANDERE künftige Änderung an demselben Feld unterdrückt.
|
||||||
|
const dismissedNameChangeKeys = dismissalRepo.getDismissedKeys("name_change");
|
||||||
|
const nameChanges = results
|
||||||
|
.flatMap((r) =>
|
||||||
|
r.nameChanges.map((change) => ({
|
||||||
|
serviceId: r.service.id,
|
||||||
|
serviceHostname: r.service.hostname,
|
||||||
|
servicePort: r.service.port,
|
||||||
|
...change,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.filter((c) => !dismissedNameChangeKeys.has(`${c.serviceId}:${c.field}:${c.suggested}`));
|
||||||
|
|
||||||
|
// Gerätename-Vorschlag per Reverse-DNS, falls er sich vom aktuell
|
||||||
|
// gespeicherten Hostnamen unterscheidet - ebenfalls nur zur Bestätigung.
|
||||||
|
const deviceNameSuggestion =
|
||||||
|
suggestedHostname && suggestedHostname !== device.hostname ? suggestedHostname : null;
|
||||||
|
|
||||||
|
logRepo.logScan({
|
||||||
|
type: "device",
|
||||||
|
targetId: device.id,
|
||||||
|
level: "info",
|
||||||
|
message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden (Ports: ${ports.join(", ") || "keine"}), ${created} neu, ${updated} aktualisiert${staleServices.length > 0 ? `, ${staleServices.length} nicht mehr gefunden` : ""}${nameChanges.length > 0 ? `, ${nameChanges.length} Namensänderung(en) vorgeschlagen` : ""} [${formatToolsUsed(toolsUsed)}]`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dismissedNewServiceIds = dismissalRepo.getDismissedKeys("new_service");
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceId: device.id,
|
||||||
|
scannedPorts: discovered.length,
|
||||||
|
ports,
|
||||||
|
created,
|
||||||
|
updated,
|
||||||
|
services: results.map((r) => r.service),
|
||||||
|
newServices: results
|
||||||
|
.filter((r) => r.created)
|
||||||
|
.map((r) => r.service)
|
||||||
|
.filter((s) => !dismissedNewServiceIds.has(s.id)),
|
||||||
|
staleServices,
|
||||||
|
nameChanges,
|
||||||
|
deviceNameSuggestion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Filtert die Vorschlagslisten in einem (beim Scan-Abschluss eingefrorenen)
|
||||||
|
* "lastResult" NOCHMAL gegen den AKTUELLEN Dismiss-Stand, bei jedem
|
||||||
|
* Status-Abruf neu. Nötig, weil ein Bestätigen/Verwerfen NACH Scan-Ende
|
||||||
|
* passieren kann (der Normalfall) - ohne diese Live-Filterung würde ein
|
||||||
|
* anderer Browser/Gerät, der denselben eingefrorenen Snapshot abfragt,
|
||||||
|
* weiterhin den alten (unbestätigten) Stand sehen, obwohl der Vorschlag
|
||||||
|
* anderswo längst bestätigt wurde (siehe Bugreport).
|
||||||
|
*/
|
||||||
|
function reapplyDismissals(status: ReturnType<typeof getJobStatus>): ReturnType<typeof getJobStatus> {
|
||||||
|
if (!status.lastResult) return status;
|
||||||
|
const r = status.lastResult as Record<string, unknown>;
|
||||||
|
|
||||||
|
const filterByIdKind = (list: unknown, kind: dismissalRepo.DismissalKind) => {
|
||||||
|
if (!Array.isArray(list)) return list;
|
||||||
|
const dismissed = dismissalRepo.getDismissedKeys(kind);
|
||||||
|
return list.filter((item) => !dismissed.has((item as { id: string }).id));
|
||||||
|
};
|
||||||
|
const filterNameChangeLike = (
|
||||||
|
list: unknown,
|
||||||
|
kind: dismissalRepo.DismissalKind,
|
||||||
|
idField: "serviceId" | "deviceId"
|
||||||
|
) => {
|
||||||
|
if (!Array.isArray(list)) return list;
|
||||||
|
const dismissed = dismissalRepo.getDismissedKeys(kind);
|
||||||
|
return list.filter((item) => {
|
||||||
|
const c = item as Record<string, unknown>;
|
||||||
|
return !dismissed.has(`${c[idField]}:${c.field}:${c.suggested}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const patched: Record<string, unknown> = { ...r };
|
||||||
|
if ("newServices" in r) patched.newServices = filterByIdKind(r.newServices, "new_service");
|
||||||
|
if ("staleServices" in r) patched.staleServices = filterByIdKind(r.staleServices, "stale_service");
|
||||||
|
if ("nameChanges" in r) patched.nameChanges = filterNameChangeLike(r.nameChanges, "name_change", "serviceId");
|
||||||
|
if ("staleDevices" in r) patched.staleDevices = filterByIdKind(r.staleDevices, "stale_device");
|
||||||
|
if ("deviceChanges" in r) patched.deviceChanges = filterNameChangeLike(r.deviceChanges, "device_change", "deviceId");
|
||||||
|
|
||||||
|
return { ...status, lastResult: patched };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
// Merkt einen Scan-Vorschlag (neuer Dienst, verschwundener Dienst/Gerät,
|
||||||
|
// Namens-/Kategorie-Änderung) SERVERSEITIG als erledigt - egal ob durch
|
||||||
|
// "Übernehmen" oder "Behalten" ausgelöst, beides heißt hier "nicht mehr
|
||||||
|
// anzeigen". Wird vom Frontend bei jeder Bestätigen/Verwerfen-Aktion
|
||||||
|
// aufgerufen (siehe ScannerPage.tsx) - vorher lief das rein lokal über
|
||||||
|
// sessionStorage, wodurch ein Bestätigen auf einem Gerät/Browser auf einem
|
||||||
|
// anderen wirkungslos blieb (siehe Bugreport).
|
||||||
|
app.post("/api/scan/dismiss", async (request, reply) => {
|
||||||
|
const { kind, key } = request.body as { kind?: string; key?: string };
|
||||||
|
const validKinds = ["new_service", "stale_service", "stale_device", "name_change", "device_change"];
|
||||||
|
if (!kind || !validKinds.includes(kind) || !key) {
|
||||||
|
return reply.code(400).send({ error: "kind (eine der bekannten Arten) und key sind erforderlich." });
|
||||||
|
}
|
||||||
|
dismissalRepo.dismiss(kind as dismissalRepo.DismissalKind, key);
|
||||||
|
return { dismissed: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Geräteübergreifende Sichtbarkeit für manuelle Einzelgeräte-Scans (siehe
|
||||||
|
// getScanningDeviceIds() in scanJobs.ts) - wird von der Seitenleiste
|
||||||
|
// gepollt, damit "Ein Scan läuft gerade" auch für einen per Button auf der
|
||||||
|
// Geräteseite gestarteten Scan angezeigt wird, nicht nur für den
|
||||||
|
// Sammel-Scan.
|
||||||
|
app.get("/api/scan/devices/active", async () => {
|
||||||
|
return { deviceIds: getScanningDeviceIds() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/scan/devices/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const { full } = request.query as { full?: string };
|
||||||
|
const device = deviceRepo.getDevice(id);
|
||||||
|
if (!device) {
|
||||||
|
return reply.code(404).send({ error: "Gerät nicht gefunden" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tryLockDevice(device.id)) {
|
||||||
|
return reply.code(409).send({ error: "Für dieses Gerät läuft bereits ein Scan." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await performDeviceScan(device, full === "true");
|
||||||
|
} 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 });
|
||||||
|
} finally {
|
||||||
|
unlockDevice(device.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sammel-Scan über ALLE Geräte - läuft als Hintergrund-Job im Backend
|
||||||
|
// (siehe scanJobs.ts), nicht mehr als eine lange, vom Frontend
|
||||||
|
// client-seitig orchestrierte Schleife aus Einzelaufrufen. Zwei Vorteile:
|
||||||
|
// 1) Der Fortschritt ist über GET .../status geräteübergreifend sichtbar -
|
||||||
|
// öffnet jemand den Adminbereich auf einem ANDEREN Gerät/Browser während
|
||||||
|
// ein Scan läuft, sieht er das trotzdem (der Zustand lebt im Backend,
|
||||||
|
// nicht im Browser-sessionStorage).
|
||||||
|
// 2) Ein einzelnes sehr langsames Gerät blockiert nicht die gesamte
|
||||||
|
// HTTP-Anfrage bis zum Timeout.
|
||||||
|
app.get("/api/scan/devices/all/status", async () => {
|
||||||
|
return reapplyDismissals(getJobStatus("devices"));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/scan/devices/all/cancel", async () => {
|
||||||
|
requestCancel("devices");
|
||||||
|
return { cancelling: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/scan/devices/all", async (_request, reply) => {
|
||||||
|
if (isJobRunning("devices")) {
|
||||||
|
return reply.code(409).send({ error: "Es läuft bereits ein Geräte-Scan." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = deviceRepo.listDevices();
|
||||||
|
startJob("devices", devices.length);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
type DeviceScanResult = Awaited<ReturnType<typeof performDeviceScan>>;
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
let scannedCount = 0;
|
||||||
|
const allStale: DeviceScanResult["staleServices"] = [];
|
||||||
|
const allNameChanges: DeviceScanResult["nameChanges"] = [];
|
||||||
|
const allNew: DeviceScanResult["newServices"] = [];
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// NEU (round50): Bis round49 startete JEDES Gerät seinen eigenen
|
||||||
|
// masscan- bzw. httpx-Prozess - bei bis zu 10 gleichzeitig
|
||||||
|
// gescannten Geräten (siehe CONCURRENT_DEVICES unten) liefen dadurch
|
||||||
|
// bis zu 10 masscan- und 10 httpx-Prozesse parallel, die sich
|
||||||
|
// gegenseitig die Netzwerkschnittstelle streitig machten und in den
|
||||||
|
// Timeout liefen (siehe Bugreport: "Flaschenhals").
|
||||||
|
//
|
||||||
|
// Jetzt läuft masscan EINMAL für ALLE Geräte dieses Scan-Laufs
|
||||||
|
// gemeinsam (masscan unterstützt mehrere Ziel-IPs nativ in einem
|
||||||
|
// Prozess) und httpx EINMAL für die Vereinigung aller dabei
|
||||||
|
// gefundenen offenen Ports über alle Geräte hinweg. Das Ergebnis
|
||||||
|
// wird unten pro Gerät als "preFetched" an performDeviceScan
|
||||||
|
// durchgereicht - der eigentliche Geräte-Scan macht dann selbst
|
||||||
|
// keine masscan-/httpx-Aufrufe mehr, nur noch die (leichtgewichtigen,
|
||||||
|
// reinen HTTP-)Detailabfragen pro Port (Titel, Favicon,
|
||||||
|
// Softwareerkennung).
|
||||||
|
// NEU (round58): Die FritzBox (der Router selbst, NICHT ein Repeater
|
||||||
|
// o. ä.) hat eine eingebaute Angriffs-/Portscan-Erkennung
|
||||||
|
// (Sicherheitseinstellungen -> Angriffserkennung). Ein voller
|
||||||
|
// masscan-Lauf über 1-65535 Ports gegen die eigene Router-IP sieht
|
||||||
|
// dafür genau wie ein klassischer Portscan-Angriff aus - die
|
||||||
|
// FritzBox blockt/ignoriert die Scan-Quelle daraufhin offenbar
|
||||||
|
// kurzzeitig, sodass selbst der direkt danach laufende normale
|
||||||
|
// 80/443-Retry (siehe scanDeviceServices) noch nichts findet (siehe
|
||||||
|
// Bugreport: "0 Dienst(e) gefunden (Ports: keine)" bei der FritzBox
|
||||||
|
// selbst, während ein FRITZ!Repeater im selben Lauf ganz normal alle
|
||||||
|
// Dienste liefert - Repeater haben typischerweise nicht dieselbe
|
||||||
|
// Absicherung wie der Router, der ja die Sicherheits-Gateway-Rolle
|
||||||
|
// fürs ganze Netz hat). Die Router-IP wird daher gezielt aus dem
|
||||||
|
// masscan-Batch ausgenommen - für sie greift stattdessen weiter
|
||||||
|
// unten ganz normal die schonende "übliche Portliste"-Prüfung
|
||||||
|
// (kein vollständiger Portscan, kein masscan), ihre echten Dienste
|
||||||
|
// kennen wir ohnehin schon zuverlässig über den eigenen
|
||||||
|
// TR-064-Scanner (siehe scanner/fritzbox.ts).
|
||||||
|
const routerIp = await resolveRouterIp();
|
||||||
|
|
||||||
|
const deviceIps = devices.map((d) => d.ip).filter((ip) => ip !== routerIp);
|
||||||
|
if (routerIp && devices.some((d) => d.ip === routerIp)) {
|
||||||
|
logRepo.logScan({
|
||||||
|
type: "device",
|
||||||
|
targetId: null,
|
||||||
|
level: "info",
|
||||||
|
message: `Sammel-Scan: Router-IP ${routerIp} vom vollständigen Portscan ausgenommen (FritzOS-Angriffserkennung reagiert empfindlich auf volle Portscans gegen sich selbst) - wird stattdessen mit der üblichen Portliste geprüft.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sichtbarer Fortschritt VOR dem eigentlichen Geräte-Scan: ohne das
|
||||||
|
// stand die Anzeige die ganze Vorabscan-Zeit über bei "1 von 120" und
|
||||||
|
// wirkte wie hängengeblieben, obwohl im Hintergrund der gemeinsame
|
||||||
|
// masscan-/httpx-Lauf arbeitete (siehe Bugreport).
|
||||||
|
updateJobProgress("devices", 0, `masscan läuft… (${deviceIps.length} Geräte, vollständiger Portscan)`);
|
||||||
|
const masscanStartedAt = Date.now();
|
||||||
|
|
||||||
|
// masscan meldet laufend Fortschritt (Prozent, Restzeit, bisherige
|
||||||
|
// Treffer) - wird hier live in die Fortschrittsanzeige gespiegelt,
|
||||||
|
// statt nur den statischen "masscan läuft…"-Text stehen zu lassen
|
||||||
|
// (siehe Bugreport: "ich sehe nicht, ob noch alles richtig läuft").
|
||||||
|
// Eine feste IP lässt sich dabei bewusst NICHT anzeigen - masscan
|
||||||
|
// arbeitet alle Ziel-Hosts gemeinsam/asynchron ab, es gibt keine
|
||||||
|
// sinnvolle "aktuell bearbeitete IP" wie bei einem sequenziellen
|
||||||
|
// Scanner.
|
||||||
|
let lastMasscanProgressUpdate = 0;
|
||||||
|
const masscanBatch = await scanHostsWithMasscan(deviceIps, "1-65535", undefined, (progress) => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastMasscanProgressUpdate < 500) return; // nicht öfter als 2x/s aktualisieren
|
||||||
|
lastMasscanProgressUpdate = now;
|
||||||
|
const etaText =
|
||||||
|
progress.etaSeconds !== null
|
||||||
|
? progress.etaSeconds > 0
|
||||||
|
? `, ~${progress.etaSeconds}s verbleibend`
|
||||||
|
: ", wartet auf letzte Antworten"
|
||||||
|
: "";
|
||||||
|
updateJobProgress(
|
||||||
|
"devices",
|
||||||
|
0,
|
||||||
|
`masscan läuft… (${deviceIps.length} Geräte, vollständiger Portscan) – ${progress.percent.toFixed(0)}% erledigt${etaText}, ${progress.foundSoFar} offene(r) Port(s) bisher`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const masscanDurationMs = Date.now() - masscanStartedAt;
|
||||||
|
const masscanBatchError = masscanBatch === null ? getMasscanLastError() : null;
|
||||||
|
const masscanPortsFound = masscanBatch
|
||||||
|
? Array.from(masscanBatch.values()).reduce((sum, ports) => sum + ports.length, 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Start- und Endzeit fest im Scan-Log protokollieren (nicht nur in
|
||||||
|
// der Konsole) - so ist die tatsächliche Dauer des Vorabscans auch im
|
||||||
|
// Nachhinein unter Admin -> Logs nachvollziehbar, nicht nur während
|
||||||
|
// eines laufenden Scans über die Fortschrittsanzeige.
|
||||||
|
logRepo.logScan({
|
||||||
|
type: "device",
|
||||||
|
targetId: null,
|
||||||
|
level: masscanBatch ? "info" : "error",
|
||||||
|
message: masscanBatch
|
||||||
|
? `Sammel-Scan: masscan-Vorabscan über ${deviceIps.length} Geräte abgeschlossen in ${(masscanDurationMs / 1000).toFixed(1)}s, ${masscanPortsFound} offene(r) Port(s) gefunden (Start: ${new Date(masscanStartedAt).toLocaleTimeString("de-DE")}, Ende: ${new Date().toLocaleTimeString("de-DE")})`
|
||||||
|
: `Sammel-Scan: masscan-Vorabscan über ${deviceIps.length} Geräte fehlgeschlagen nach ${(masscanDurationMs / 1000).toFixed(1)}s – ${masscanBatchError} (jedes Gerät fällt einzeln auf den TCP-Fallback zurück)`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// httpx nur für die Geräte vorab bündeln, für die masscan Ergebnisse
|
||||||
|
// geliefert hat - ist masscan für den GESAMTEN Batch fehlgeschlagen
|
||||||
|
// (masscanBatch === null), kennen wir die offenen Ports noch nicht
|
||||||
|
// und jedes Gerät ermittelt sie unten wie gewohnt selbst per
|
||||||
|
// TCP-Fallback (scanPortsInBatches) samt eigenem httpx-Aufruf -
|
||||||
|
// langsamer als der Idealfall, aber weiterhin korrekt.
|
||||||
|
let httpxBatch: Map<string, HttpxResult> | null = null;
|
||||||
|
let httpxBatchError: string | null = null;
|
||||||
|
if (masscanBatch) {
|
||||||
|
const httpxTargets: Array<{ ip: string; port: number }> = [];
|
||||||
|
for (const device of devices) {
|
||||||
|
const ports = masscanBatch.get(device.ip) ?? [];
|
||||||
|
for (const port of ports) httpxTargets.push({ ip: device.ip, port });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateJobProgress("devices", 0, `httpx läuft… (${httpxTargets.length} Port(s) über alle Geräte)`);
|
||||||
|
const httpxStartedAt = Date.now();
|
||||||
|
|
||||||
|
// httpx meldet jeden Treffer sofort, sobald er bestätigt ist -
|
||||||
|
// wird hier live in die Fortschrittsanzeige gespiegelt (Anzahl
|
||||||
|
// bereits als HTTP/HTTPS bestätigter Ports von den insgesamt zu
|
||||||
|
// prüfenden Zielen). Eine Zwischenanzeige "noch nicht geprüfte"
|
||||||
|
// Ziele ist dabei nicht sinnvoll möglich - httpx meldet erfolglose
|
||||||
|
// Versuche gar nicht erst zurück (siehe httpx.ts).
|
||||||
|
let lastHttpxProgressUpdate = 0;
|
||||||
|
httpxBatch = await probeManyWithHttpx(httpxTargets, (confirmedSoFar) => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastHttpxProgressUpdate < 500) return;
|
||||||
|
lastHttpxProgressUpdate = now;
|
||||||
|
updateJobProgress(
|
||||||
|
"devices",
|
||||||
|
0,
|
||||||
|
`httpx läuft… (${httpxTargets.length} Port(s) über alle Geräte) – ${confirmedSoFar} bereits als HTTP/HTTPS bestätigt`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const httpxDurationMs = Date.now() - httpxStartedAt;
|
||||||
|
if (httpxBatch === null) httpxBatchError = getHttpxLastError();
|
||||||
|
|
||||||
|
logRepo.logScan({
|
||||||
|
type: "device",
|
||||||
|
targetId: null,
|
||||||
|
level: httpxBatch ? "info" : "error",
|
||||||
|
message: httpxBatch
|
||||||
|
? `Sammel-Scan: httpx-Vorabscan über ${httpxTargets.length} Ziel(e) (ip:port) abgeschlossen in ${(httpxDurationMs / 1000).toFixed(1)}s, ${httpxBatch.size} als HTTP/HTTPS bestätigt (Start: ${new Date(httpxStartedAt).toLocaleTimeString("de-DE")}, Ende: ${new Date().toLocaleTimeString("de-DE")})`
|
||||||
|
: `Sammel-Scan: httpx-Vorabscan über ${httpxTargets.length} Ziel(e) fehlgeschlagen nach ${(httpxDurationMs / 1000).toFixed(1)}s – ${httpxBatchError} (jeder Port wird stattdessen einzeln von jedem Gerät selbst geprüft)`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logRepo.logScan({
|
||||||
|
type: "device",
|
||||||
|
targetId: null,
|
||||||
|
level: "info",
|
||||||
|
message: `Sammel-Scan: httpx-Vorabscan übersprungen, da der masscan-Vorabscan fehlgeschlagen ist (keine Portliste zum Prüfen vorhanden)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mehrere Geräte GLEICHZEITIG scannen statt strikt nacheinander -
|
||||||
|
// bei z. B. 118 Geräten hätte ein rein sequenzieller Durchlauf der
|
||||||
|
// (jetzt viel schnelleren, weil rein HTTP-basierten) Detailabfragen
|
||||||
|
// trotzdem lange gedauert. Batch-Größe bewusst moderat (nicht alle
|
||||||
|
// Geräte auf einmal): jeder einzelne Geräte-Scan öffnet intern schon
|
||||||
|
// bis zu 1000 gleichzeitige Verbindungen (siehe ports.ts) - zu viele
|
||||||
|
// Geräte gleichzeitig könnte das eigene Netz/den Docker-Host
|
||||||
|
// überlasten.
|
||||||
|
const CONCURRENT_DEVICES = 10;
|
||||||
|
for (let i = 0; i < devices.length; i += CONCURRENT_DEVICES) {
|
||||||
|
if (isCancelRequested("devices")) {
|
||||||
|
cancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const batch = devices.slice(i, i + CONCURRENT_DEVICES);
|
||||||
|
const stillRunning = new Set(batch.map((d) => d.hostname));
|
||||||
|
|
||||||
|
const renderBatchLabel = () =>
|
||||||
|
`${batch.map((d) => (stillRunning.has(d.hostname) ? d.hostname : `✓ ${d.hostname}`)).join(", ")}`;
|
||||||
|
|
||||||
|
updateJobProgress("devices", scannedCount, renderBatchLabel());
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
batch.map(async (device) => {
|
||||||
|
if (!tryLockDevice(device.id)) {
|
||||||
|
// Für dieses eine Gerät läuft bereits ein separater Scan
|
||||||
|
// (z. B. über den "Jetzt scannen"-Button auf der
|
||||||
|
// Geräte-Seite) - überspringen statt zu überlappen, sonst
|
||||||
|
// könnten beide gleichzeitig denselben neuen Dienst anlegen
|
||||||
|
// wollen.
|
||||||
|
stillRunning.delete(device.hostname);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Router-IP: siehe Kommentar oben bei resolveRouterIp() -
|
||||||
|
// bewusst NICHT preFetched/fullScan nutzen, sondern die
|
||||||
|
// schonende Standard-Portliste (kein voller 1-65535-Scan
|
||||||
|
// gegen den Router selbst, keine Angriffserkennung
|
||||||
|
// getriggert).
|
||||||
|
const isRouter = routerIp !== null && device.ip === routerIp;
|
||||||
|
if (isRouter) {
|
||||||
|
const result = await performDeviceScan(device, false);
|
||||||
|
created += result.created;
|
||||||
|
updated += result.updated;
|
||||||
|
allStale.push(...result.staleServices);
|
||||||
|
allNameChanges.push(...result.nameChanges);
|
||||||
|
allNew.push(...result.newServices);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const masscanPorts = masscanBatch ? masscanBatch.get(device.ip) ?? [] : undefined;
|
||||||
|
const preFetched: PreFetchedScanData = {
|
||||||
|
masscanAttempted: true,
|
||||||
|
masscanPorts,
|
||||||
|
masscanError: masscanPorts ? null : masscanBatchError,
|
||||||
|
httpxResults: masscanPorts
|
||||||
|
? (() => {
|
||||||
|
// Aus der großen, geräteübergreifenden httpx-Map nur
|
||||||
|
// die Treffer DIESES Geräts herausfiltern.
|
||||||
|
if (httpxBatch === null) return null;
|
||||||
|
const map = new Map<number, HttpxResult>();
|
||||||
|
for (const port of masscanPorts) {
|
||||||
|
const hit = httpxBatch.get(`${device.ip}:${port}`);
|
||||||
|
if (hit) map.set(port, hit);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
})()
|
||||||
|
: undefined,
|
||||||
|
httpxError: masscanPorts ? httpxBatchError : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await performDeviceScan(device, true, preFetched);
|
||||||
|
created += result.created;
|
||||||
|
updated += result.updated;
|
||||||
|
allStale.push(...result.staleServices);
|
||||||
|
allNameChanges.push(...result.nameChanges);
|
||||||
|
allNew.push(...result.newServices);
|
||||||
|
} catch (err) {
|
||||||
|
// Einzelnes fehlgeschlagenes Gerät soll den Rest nicht
|
||||||
|
// abbrechen - ABER trotzdem persistent loggen, statt
|
||||||
|
// stillschweigend zu überspringen. Vorher war das ein
|
||||||
|
// stummer catch-Block: scheiterte ausgerechnet immer
|
||||||
|
// dasselbe Gerät (z. B. wegen eines kaputten Hostnamens),
|
||||||
|
// gab es dafür nie einen sichtbaren Hinweis.
|
||||||
|
const detail = err instanceof Error ? err.message : String(err);
|
||||||
|
logRepo.logScan({
|
||||||
|
type: "device",
|
||||||
|
targetId: device.id,
|
||||||
|
level: "error",
|
||||||
|
message: `${device.hostname} (${device.ip}): Scan im Sammel-Lauf fehlgeschlagen – ${detail}`,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
unlockDevice(device.id);
|
||||||
|
// Live sichtbar machen, WELCHES Gerät im aktuellen Batch
|
||||||
|
// schon fertig ist ("✓ Name") und welche noch laufen -
|
||||||
|
// bisher aktualisierte sich die Anzeige erst wieder, wenn
|
||||||
|
// ALLE 10 Geräte des Batches fertig waren. Blieb eines davon
|
||||||
|
// hängen, sah der komplette Batch wie eingefroren aus, ohne
|
||||||
|
// erkennbar zu machen, WELCHES Gerät das eigentlich betraf
|
||||||
|
// (siehe Bugreport).
|
||||||
|
stillRunning.delete(device.hostname);
|
||||||
|
updateJobProgress("devices", scannedCount, renderBatchLabel());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
scannedCount += batch.length;
|
||||||
|
updateJobProgress("devices", scannedCount, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEU (round60, korrigiert round61): Vergleich mit dem VORHERIGEN
|
||||||
|
// Sammel-Scan (siehe gleiches Prinzip beim FritzBox-Scan oben) -
|
||||||
|
// Mengenvergleich der Dienst-IDs statt reiner Zahlenvergleich.
|
||||||
|
//
|
||||||
|
// WICHTIG: Als "aktueller Stand" wird eine FRISCHE, VOLLSTÄNDIGE
|
||||||
|
// Abfrage aller Dienste aus der Datenbank genutzt (serviceRepo.
|
||||||
|
// listServices()) - NICHT die Liste der in DIESEM Lauf tatsächlich
|
||||||
|
// erfolgreich gescannten Dienste. Der Unterschied ist entscheidend:
|
||||||
|
// Geräte, die in diesem Lauf übersprungen wurden (z. B. weil parallel
|
||||||
|
// schon ein Einzelgeräte-Scan für sie lief, siehe tryLockDevice) oder
|
||||||
|
// bei denen der Scan mit einem Fehler abgebrochen ist, haben ihre
|
||||||
|
// bestehenden Dienste NIE in eine nur-aus-diesem-Lauf gesammelte
|
||||||
|
// Liste geschafft - beim NÄCHSTEN Lauf sahen deren ganz normalen,
|
||||||
|
// unveränderten Dienste dann fälschlich wie "neu" aus (siehe
|
||||||
|
// Bugreport: Zusammenfassung zeigte "3 neue Dienste", obwohl nur 1
|
||||||
|
// Dienst tatsächlich neu angelegt wurde). Eine frische DB-Abfrage hat
|
||||||
|
// dieses Problem nicht, sie spiegelt immer den WIRKLICHEN
|
||||||
|
// Gesamtstand wider, unabhängig davon, welche Geräte in diesem
|
||||||
|
// konkreten Lauf erfolgreich waren.
|
||||||
|
//
|
||||||
|
// Das ändert auch die Bedeutung von "nicht mehr da": Diese Dienste
|
||||||
|
// wurden nicht in diesem Scan einfach nicht gefunden (dafür gibt es
|
||||||
|
// ja bereits staleServices mit eigener Karenzzeit und eigenem
|
||||||
|
// Bestätigen/Verwerfen-Dialog weiter unten) - sie wurden
|
||||||
|
// TATSÄCHLICH AUS DER DATENBANK GELÖSCHT, entweder über genau diesen
|
||||||
|
// Bestätigen-Dialog oder manuell. Das ist eine reine Feststellung
|
||||||
|
// ("das ist seit dem letzten Mal passiert"), keine neue
|
||||||
|
// Entscheidung, die hier nochmal angezeigt/genehmigt werden müsste -
|
||||||
|
// die Genehmigung ist ja bereits an anderer Stelle erfolgt.
|
||||||
|
const previousServiceIdsRaw = settingsRepo.getSetting("deviceScanLastServiceIds");
|
||||||
|
const previousServiceIds: string[] | null = previousServiceIdsRaw ? JSON.parse(previousServiceIdsRaw) : null;
|
||||||
|
const currentServiceIds = serviceRepo.listServices().map((s) => s.id);
|
||||||
|
const currentServiceIdSet = new Set(currentServiceIds);
|
||||||
|
|
||||||
|
let changesSinceLastScan: string | null;
|
||||||
|
if (cancelled) {
|
||||||
|
changesSinceLastScan = "Abgebrochen - kein vollständiger Vergleich möglich.";
|
||||||
|
} else if (previousServiceIds === null) {
|
||||||
|
changesSinceLastScan = "Erster Scan - kein Vergleich zu einem vorherigen Lauf möglich.";
|
||||||
|
} else {
|
||||||
|
const previousSet = new Set(previousServiceIds);
|
||||||
|
const newlyAppeared = currentServiceIds.filter((id) => !previousSet.has(id));
|
||||||
|
const deletedSinceLastScan = previousServiceIds.filter((id) => !currentServiceIdSet.has(id));
|
||||||
|
// Der Satz ist reine Wiederholung, wenn er GENAU dasselbe aussagt
|
||||||
|
// wie die Standard-Zusammenfassung ("X neue(r) Dienst(e) gefunden")
|
||||||
|
// ohnehin schon (newlyAppeared === created, nichts gelöscht) - dann
|
||||||
|
// komplett weglassen statt zweimal dasselbe zu sagen. Weicht die
|
||||||
|
// Zahl dagegen ab (z. B. weil zwischen zwei Sammel-Scans ein
|
||||||
|
// Einzelgeräte-Scan lief, oder weil ein vorheriger Lauf
|
||||||
|
// abgebrochen wurde und die Vergleichsbasis dadurch älter ist),
|
||||||
|
// ist das selbst eine nützliche Information und bleibt sichtbar.
|
||||||
|
const isRedundant = newlyAppeared.length === created && deletedSinceLastScan.length === 0;
|
||||||
|
if (isRedundant) {
|
||||||
|
changesSinceLastScan = null;
|
||||||
|
} else if (newlyAppeared.length === 0 && deletedSinceLastScan.length === 0) {
|
||||||
|
changesSinceLastScan = "Keine Änderungen seit dem letzten Sammel-Scan.";
|
||||||
|
} else {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (newlyAppeared.length > 0) parts.push(`${newlyAppeared.length} neue(r) Dienst(e)`);
|
||||||
|
if (deletedSinceLastScan.length > 0)
|
||||||
|
parts.push(`${deletedSinceLastScan.length} Dienst(e) seither gelöscht`);
|
||||||
|
changesSinceLastScan = `Änderungen seit dem letzten Sammel-Scan: ${parts.join(", ")}.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bei einem abgebrochenen Lauf NICHT als Vergleichsbasis speichern -
|
||||||
|
// sonst würde ein unvollständiger Durchlauf fälschlich melden, dass
|
||||||
|
// Dienste "gelöscht" wurden, nur weil noch nicht alle Geräte an der
|
||||||
|
// Reihe waren.
|
||||||
|
if (!cancelled) {
|
||||||
|
settingsRepo.setSetting("deviceScanLastServiceIds", JSON.stringify(currentServiceIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
finishJob("devices", {
|
||||||
|
totalDevices: devices.length,
|
||||||
|
scannedCount,
|
||||||
|
cancelled,
|
||||||
|
created,
|
||||||
|
updated,
|
||||||
|
staleServices: allStale,
|
||||||
|
nameChanges: allNameChanges,
|
||||||
|
newServices: allNew,
|
||||||
|
changesSinceLastScan,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// WICHTIG: Vorher wurde ein Abbruch des GESAMTEN Sammel-Scans nur im
|
||||||
|
// In-Memory-Jobstatus (lastError) vermerkt, nie in der persistenten
|
||||||
|
// scan_logs-Tabelle - Admin -> Logs blieb dadurch leer, obwohl der
|
||||||
|
// Scan sichtbar fehlgeschlagen war (Badge in der Seitenleiste
|
||||||
|
// verschwindet, weil running=false wird, aber ohne jede Erklärung
|
||||||
|
// warum). Jetzt zusätzlich persistent geloggt.
|
||||||
|
const detail = err instanceof Error ? err.message : String(err);
|
||||||
|
logRepo.logScan({
|
||||||
|
type: "device",
|
||||||
|
targetId: null,
|
||||||
|
level: "error",
|
||||||
|
message: `Sammel-Scan abgebrochen – ${detail}`,
|
||||||
|
});
|
||||||
|
failJob("devices", detail);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return reply.code(202).send({ started: true, totalDevices: devices.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt den von der FritzBox selbst generierten Platzhalter-Namen für
|
||||||
|
* Geräte ohne bekannten Hostnamen (Muster "PC-192-168-1-73"). Ein solcher
|
||||||
|
* Name ist nie einer echten Namensänderung wert, sondern nur ein Rückfall.
|
||||||
|
*/
|
||||||
|
function isGenericFritzboxHostname(hostname: string, ip: string): boolean {
|
||||||
|
return hostname === `PC-${ip.replace(/\./g, "-")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die FritzBox liefert für ein einzelnes physisches Gerät oft MEHRERE
|
||||||
|
* TR-064-Einträge (IPv4+IPv6, WLAN+Mesh, oder ein alter Platzhaltername neben
|
||||||
|
* dem aktuellen) - alle mit derselben MAC-Adresse. Werden diese einzeln
|
||||||
|
* nacheinander upgeserted, legt der erste Eintrag das Gerät an und der
|
||||||
|
* zweite wird sofort als "Änderung" gegen das gerade erst selbst angelegte
|
||||||
|
* Gerät vorgeschlagen - verwirrend, besonders bei einer leeren Datenbank
|
||||||
|
* (siehe Bugreport). Deshalb hier VOR dem Upsert je MAC zu einem Eintrag
|
||||||
|
* zusammenführen und dabei den aussagekräftigsten Hostnamen wählen (echter
|
||||||
|
* Name statt "PC-<ip>"-Platzhalter, sofern vorhanden).
|
||||||
|
*/
|
||||||
|
function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] {
|
||||||
|
const byMac = new Map<string, FritzBoxHost[]>();
|
||||||
|
const withoutMac: FritzBoxHost[] = [];
|
||||||
|
|
||||||
|
for (const host of hosts) {
|
||||||
|
if (!host.mac) {
|
||||||
|
withoutMac.push(host);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = host.mac.toLowerCase();
|
||||||
|
const group = byMac.get(key);
|
||||||
|
if (group) group.push(host);
|
||||||
|
else byMac.set(key, [host]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged: FritzBoxHost[] = Array.from(byMac.values()).map((group) => {
|
||||||
|
const named = group.find((h) => !isGenericFritzboxHostname(h.hostname, h.ip));
|
||||||
|
const chosen = named ?? group[0];
|
||||||
|
const online = group.find((h) => h.online) ?? group[0];
|
||||||
|
return { ip: online.ip, mac: chosen.mac, hostname: chosen.hostname, online: online.online };
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...merged, ...withoutMac];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 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.get("/api/scan/fritzbox/status", async () => {
|
||||||
|
return reapplyDismissals(getJobStatus("fritzbox"));
|
||||||
|
});
|
||||||
|
|
||||||
|
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.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isJobRunning("fritzbox")) {
|
||||||
|
return reply.code(409).send({ error: "Es läuft bereits ein FritzBox-Scan." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = process.env.FRITZBOX_PORT ? Number(process.env.FRITZBOX_PORT) : 49000;
|
||||||
|
startJob("fritzbox", 1);
|
||||||
|
|
||||||
|
// Wie bei den anderen Scannern: läuft im Hintergrund weiter, die
|
||||||
|
// HTTP-Antwort kommt sofort - macht den Status auch hier
|
||||||
|
// geräteübergreifend abfragbar statt nur im Browser sichtbar, der ihn
|
||||||
|
// gestartet hat.
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const devicesBeforeScan = deviceRepo.listDevices().filter((d) => d.source === "fritzbox");
|
||||||
|
|
||||||
|
const hosts = await fetchFritzBoxHosts({ host, port, username, password });
|
||||||
|
// Mehrere TR-064-Einträge für dasselbe physische Gerät (gleiche MAC)
|
||||||
|
// werden VOR dem Upsert zusammengeführt (siehe mergeFritzboxHosts oben) -
|
||||||
|
// deshalb kann "gefundene Einträge" > Anzahl eindeutiger Geräte sein.
|
||||||
|
const mergedHosts = mergeFritzboxHosts(hosts);
|
||||||
|
const results = mergedHosts.map((h) =>
|
||||||
|
deviceRepo.upsertDeviceFromScan({
|
||||||
|
hostname: h.hostname,
|
||||||
|
ip: h.ip,
|
||||||
|
mac: h.mac,
|
||||||
|
online: h.online,
|
||||||
|
source: "fritzbox",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const devices = results.map((r) => r.device);
|
||||||
|
|
||||||
|
// Vorschläge für abweichende Hostnamen/IPs bereits bekannter Geräte
|
||||||
|
// einsammeln (siehe upsertDeviceFromScan) - werden NICHT automatisch
|
||||||
|
// übernommen, nur zur Bestätigung zurückgegeben.
|
||||||
|
//
|
||||||
|
// WICHTIG: Ein Vorschlag, der einen bereits bekannten ECHTEN Namen
|
||||||
|
// (z. B. "cam-reolink-einfahrt") auf den generischen
|
||||||
|
// FritzBox-Platzhalter "PC-192-168-1-39" zurückstufen würde, wird
|
||||||
|
// hier ausgefiltert und gar nicht erst vorgeschlagen. Das war die
|
||||||
|
// Ursache der Namens-Regression: mergeFritzboxHosts() bevorzugt den
|
||||||
|
// echten Namen nur, wenn IN DEMSELBEN Scan-Durchlauf mehrere
|
||||||
|
// TR-064-Einträge für dieselbe MAC vorliegen - hat die FritzBox in
|
||||||
|
// einem Durchlauf für ein Gerät NUR den Platzhalter gemeldet (z. B.
|
||||||
|
// kurz nach einem Neustart/DHCP-Lease-Renewal, bevor sie den
|
||||||
|
// mDNS/NetBIOS-Namen kennt), gab es bisher keine zweite Quelle zum
|
||||||
|
// Vergleich - der Platzhalter wurde als ganz normale "Namensänderung"
|
||||||
|
// vorgeschlagen und konnte (z. B. über "Alle bestätigen") den guten
|
||||||
|
// Namen dauerhaft überschreiben.
|
||||||
|
const dismissedDeviceChangeKeys = dismissalRepo.getDismissedKeys("device_change");
|
||||||
|
const deviceChanges = results
|
||||||
|
.flatMap((r, i) => {
|
||||||
|
const scannedIp = mergedHosts[i].ip;
|
||||||
|
return r.changes
|
||||||
|
.filter((c) => {
|
||||||
|
if (c.field !== "hostname") return true;
|
||||||
|
const suggestedIsPlaceholder = isGenericFritzboxHostname(c.suggested, scannedIp);
|
||||||
|
const currentIsPlaceholder = isGenericFritzboxHostname(c.current, scannedIp);
|
||||||
|
return !(suggestedIsPlaceholder && !currentIsPlaceholder);
|
||||||
|
})
|
||||||
|
.map((c) => ({ deviceId: r.device.id, deviceHostname: r.device.hostname, ...c }));
|
||||||
|
})
|
||||||
|
.filter((c) => !dismissedDeviceChangeKeys.has(`${c.deviceId}:${c.field}:${c.suggested}`));
|
||||||
|
|
||||||
|
// Geräte, die die FritzBox früher gemeldet hatte, diesmal aber nicht
|
||||||
|
// mehr in der Liste sind – werden erst nach mindestens
|
||||||
|
// staleDeviceThresholdDays Tagen Abwesenheit zur Durchsicht
|
||||||
|
// vorgeschlagen (nicht schon beim ersten verpassten Scan), damit ein
|
||||||
|
// kurzzeitig offline/im Standby befindliches Gerät (Reboot,
|
||||||
|
// WLAN-Aussetzer, Nacht-Standby) nicht sofort zum Löschen
|
||||||
|
// vorgeschlagen wird. lastScan wird bei jedem Fund aktualisiert (siehe
|
||||||
|
// upsertDeviceFromScan) - bleibt es stehen, war das Gerät seither nicht
|
||||||
|
// mehr da.
|
||||||
|
//
|
||||||
|
// "Gefunden" wird über die ID der in DIESEM Scan tatsächlich
|
||||||
|
// upgeserteten Geräte geprüft (nicht über einen IP-Abgleich!): ändert
|
||||||
|
// sich die IP eines Geräts (z. B. DHCP-Neuvergabe), wird es trotzdem
|
||||||
|
// korrekt über seine MAC wiedererkannt und upgeserted - ein reiner
|
||||||
|
// IP-Vergleich hätte es fälschlich als "nicht mehr gefunden" gemeldet,
|
||||||
|
// obwohl es im selben Scan noch da war, nur unter neuer IP.
|
||||||
|
const foundDeviceIds = new Set(devices.map((d) => d.id));
|
||||||
|
|
||||||
|
// Wie bei staleServices (siehe performDeviceScan oben): ein wieder
|
||||||
|
// gefundenes Gerät löst eine frühere "verschwunden"-Bestätigung auf,
|
||||||
|
// damit eine KÜNFTIGE Abwesenheit erneut vorgeschlagen wird, statt
|
||||||
|
// von einem alten Dismiss-Eintrag stumm unterdrückt zu werden.
|
||||||
|
dismissalRepo.clearDismissals("stale_device", Array.from(foundDeviceIds));
|
||||||
|
|
||||||
|
const staleDeviceThresholdDays = Number(settingsRepo.listSettings().staleDeviceThresholdDays ?? 7);
|
||||||
|
const staleThresholdMs = staleDeviceThresholdDays * 24 * 60 * 60 * 1000;
|
||||||
|
const now = Date.now();
|
||||||
|
const dismissedStaleDeviceIds = dismissalRepo.getDismissedKeys("stale_device");
|
||||||
|
const staleDevices = devicesBeforeScan.filter((d) => {
|
||||||
|
if (foundDeviceIds.has(d.id)) return false;
|
||||||
|
if (dismissedStaleDeviceIds.has(d.id)) return false;
|
||||||
|
const lastSeen = d.lastScan ? new Date(d.lastScan).getTime() : 0;
|
||||||
|
return now - lastSeen >= staleThresholdMs;
|
||||||
|
});
|
||||||
|
|
||||||
|
// NEU (round60): Vergleich mit dem VORHERIGEN Scan-Lauf - vorher
|
||||||
|
// stand hier bei jedem Scan nur eine absolute Zahl ("45 Einträge"),
|
||||||
|
// egal ob sich seit dem letzten Mal etwas geändert hatte oder nicht
|
||||||
|
// (siehe Bugreport: "wird immer nur angezeigt wieviel Einträge beim
|
||||||
|
// letzten Scan waren, das die ganze Zeit"). Die Geräte-IDs des
|
||||||
|
// letzten erfolgreichen Laufs werden dafür in den Einstellungen
|
||||||
|
// (app_settings) gemerkt und mit den JETZT gefundenen verglichen -
|
||||||
|
// ein einfacher Mengenvergleich (nicht nur ein Zahlenvergleich),
|
||||||
|
// damit "3 raus, 3 rein" nicht fälschlich als "keine Änderung"
|
||||||
|
// durchgeht.
|
||||||
|
const previousDeviceIdsRaw = settingsRepo.getSetting("fritzboxScanLastDeviceIds");
|
||||||
|
const previousDeviceIds: string[] = previousDeviceIdsRaw ? JSON.parse(previousDeviceIdsRaw) : null;
|
||||||
|
const currentDeviceIds = devices.map((d) => d.id);
|
||||||
|
const currentDeviceIdSet = new Set(currentDeviceIds);
|
||||||
|
|
||||||
|
let changesSinceLastScan: string;
|
||||||
|
if (previousDeviceIds === null) {
|
||||||
|
changesSinceLastScan = "Erster Scan - kein Vergleich zu einem vorherigen Lauf möglich.";
|
||||||
|
} else {
|
||||||
|
const previousSet = new Set(previousDeviceIds);
|
||||||
|
const newlyAppeared = currentDeviceIds.filter((id) => !previousSet.has(id));
|
||||||
|
const noLongerSeen = previousDeviceIds.filter((id) => !currentDeviceIdSet.has(id));
|
||||||
|
if (newlyAppeared.length === 0 && noLongerSeen.length === 0) {
|
||||||
|
changesSinceLastScan = "Keine Änderungen seit dem letzten Scan.";
|
||||||
|
} else {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (newlyAppeared.length > 0) parts.push(`${newlyAppeared.length} neue(s) Gerät(e)`);
|
||||||
|
if (noLongerSeen.length > 0) parts.push(`${noLongerSeen.length} Gerät(e) fehlen jetzt`);
|
||||||
|
changesSinceLastScan = `Änderungen seit dem letzten Scan: ${parts.join(", ")}.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settingsRepo.setSetting("fritzboxScanLastDeviceIds", JSON.stringify(currentDeviceIds));
|
||||||
|
|
||||||
|
logRepo.logScan({
|
||||||
|
type: "fritzbox",
|
||||||
|
level: "info",
|
||||||
|
message: `FritzBox-Scan: ${hosts.length} Eintrag/Einträge (${devices.length} eindeutige Geräte)${staleDevices.length > 0 ? `, ${staleDevices.length} nicht mehr gemeldet` : ""}${deviceChanges.length > 0 ? `, ${deviceChanges.length} Änderung(en) vorgeschlagen` : ""} - ${changesSinceLastScan}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
finishJob("fritzbox", {
|
||||||
|
found: hosts.length,
|
||||||
|
uniqueDevices: devices.length,
|
||||||
|
staleDevices,
|
||||||
|
deviceChanges,
|
||||||
|
changesSinceLastScan,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const detail = err instanceof Error ? err.message : String(err);
|
||||||
|
logRepo.logScan({
|
||||||
|
type: "fritzbox",
|
||||||
|
level: "error",
|
||||||
|
message: `FritzBox-Scan fehlgeschlagen – ${detail}`,
|
||||||
|
});
|
||||||
|
failJob("fritzbox", detail);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return reply.code(202).send({ started: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
93
apps/backend/src/routes/services.ts
Normal file
93
apps/backend/src/routes/services.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { ServiceCreateSchema, ServiceReorderSchema, ServiceUpdateSchema } from "@launchpad/shared";
|
||||||
|
import * as deviceRepo from "../db/repositories/devices.js";
|
||||||
|
import * as serviceRepo from "../db/repositories/services.js";
|
||||||
|
import { suggestIconsForServiceName } from "../scanner/iconDb.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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Muss vor der /:id-Route stehen, damit "reorder" nicht als ID interpretiert wird.
|
||||||
|
app.patch("/api/services/reorder", async (request, reply) => {
|
||||||
|
const parsed = ServiceReorderSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
return serviceRepo.reorderServices(parsed.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Liefert für jeden Dienst OHNE Favicon eine Liste möglicher Treffer aus
|
||||||
|
// der externen Icon-Datenbank (siehe scanner/iconDb.ts), damit der Nutzer
|
||||||
|
// selbst auswählen kann statt dass automatisch geraten wird (mehrdeutige
|
||||||
|
// Namen wie "LXC Adguard" könnten sonst falsch zugeordnet werden).
|
||||||
|
app.get("/api/services/favicon-suggestions", async () => {
|
||||||
|
const services = serviceRepo.listServices().filter((s) => !s.favicon);
|
||||||
|
const suggestions = [];
|
||||||
|
for (const service of services) {
|
||||||
|
const candidates = await suggestIconsForServiceName(service.displayName);
|
||||||
|
if (candidates.length > 0) {
|
||||||
|
suggestions.push({ serviceId: service.id, serviceName: service.displayName, candidates });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { checked: services.length, suggestions };
|
||||||
|
});
|
||||||
|
}
|
||||||
109
apps/backend/src/routes/settings.ts
Normal file
109
apps/backend/src/routes/settings.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import * as settingsRepo from "../db/repositories/settings.js";
|
||||||
|
|
||||||
|
function serializeSettings() {
|
||||||
|
const all = settingsRepo.listSettings();
|
||||||
|
return {
|
||||||
|
recentVisitsLimit: Number(all.recentVisitsLimit ?? 5),
|
||||||
|
readLaterLimit: Number(all.readLaterLimit ?? 5),
|
||||||
|
staleDeviceThresholdDays: Number(all.staleDeviceThresholdDays ?? 7),
|
||||||
|
liveStatusEnabled: all.liveStatusEnabled === "true",
|
||||||
|
liveStatusIntervalMinutes: Number(all.liveStatusIntervalMinutes ?? 5),
|
||||||
|
liveStatusLastRunAt: all.liveStatusLastRunAt ?? null,
|
||||||
|
// NEU (round64): Wahl zwischen dem bisherigen (Listen-)Design und einem
|
||||||
|
// neuen, Android-Startbildschirm-artigen Design für die MOBILE Ansicht
|
||||||
|
// (Desktop bleibt davon unberührt). Bewusst hier in den serverseitigen
|
||||||
|
// Einstellungen statt nur im sessionStorage/localStorage des jeweiligen
|
||||||
|
// Browsers, damit die Wahl wirklich app-weit gilt, nicht nur pro Gerät.
|
||||||
|
mobileHomeStyle: all.mobileHomeStyle === "android" ? "android" : "classic",
|
||||||
|
// NEU (round65): Frei angeordnetes Layout des Android-Startbildschirms
|
||||||
|
// (welche Favoriten wo liegen, selbst angelegte Ordner, Aufteilung
|
||||||
|
// zwischen Hauptbereich und angehefteter Dock-Leiste unten) - als JSON
|
||||||
|
// gespeichert, roh durchgereicht. Der genaue Aufbau ist reine
|
||||||
|
// Frontend-Angelegenheit (siehe useHomeLayout.ts) - das Backend
|
||||||
|
// validiert hier nur, dass es sich überhaupt um ein Objekt mit den
|
||||||
|
// erwarteten Top-Level-Schlüsseln handelt, nicht den vollen Inhalt.
|
||||||
|
mobileHomeLayout: all.mobileHomeLayout || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/api/settings", async () => {
|
||||||
|
return serializeSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/settings", async (request, reply) => {
|
||||||
|
const body = request.body as
|
||||||
|
| {
|
||||||
|
recentVisitsLimit?: number;
|
||||||
|
readLaterLimit?: number;
|
||||||
|
staleDeviceThresholdDays?: number;
|
||||||
|
liveStatusEnabled?: boolean;
|
||||||
|
liveStatusIntervalMinutes?: number;
|
||||||
|
mobileHomeStyle?: string;
|
||||||
|
mobileHomeLayout?: unknown;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (body?.recentVisitsLimit !== undefined) {
|
||||||
|
const value = Number(body.recentVisitsLimit);
|
||||||
|
if (!Number.isFinite(value) || value < 0 || value > 50) {
|
||||||
|
return reply.code(400).send({ error: "recentVisitsLimit muss zwischen 0 und 50 liegen" });
|
||||||
|
}
|
||||||
|
settingsRepo.setSetting("recentVisitsLimit", String(Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body?.readLaterLimit !== undefined) {
|
||||||
|
const value = Number(body.readLaterLimit);
|
||||||
|
if (!Number.isFinite(value) || value < 0 || value > 50) {
|
||||||
|
return reply.code(400).send({ error: "readLaterLimit muss zwischen 0 und 50 liegen" });
|
||||||
|
}
|
||||||
|
settingsRepo.setSetting("readLaterLimit", String(Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body?.staleDeviceThresholdDays !== undefined) {
|
||||||
|
const value = Number(body.staleDeviceThresholdDays);
|
||||||
|
if (!Number.isFinite(value) || value < 0 || value > 90) {
|
||||||
|
return reply.code(400).send({ error: "staleDeviceThresholdDays muss zwischen 0 und 90 liegen" });
|
||||||
|
}
|
||||||
|
settingsRepo.setSetting("staleDeviceThresholdDays", String(Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body?.liveStatusEnabled !== undefined) {
|
||||||
|
settingsRepo.setSetting("liveStatusEnabled", body.liveStatusEnabled ? "true" : "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body?.liveStatusIntervalMinutes !== undefined) {
|
||||||
|
const value = Number(body.liveStatusIntervalMinutes);
|
||||||
|
if (!Number.isFinite(value) || value < 1 || value > 1440) {
|
||||||
|
return reply.code(400).send({ error: "liveStatusIntervalMinutes muss zwischen 1 und 1440 liegen" });
|
||||||
|
}
|
||||||
|
settingsRepo.setSetting("liveStatusIntervalMinutes", String(Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body?.mobileHomeStyle !== undefined) {
|
||||||
|
if (body.mobileHomeStyle !== "classic" && body.mobileHomeStyle !== "android") {
|
||||||
|
return reply.code(400).send({ error: "mobileHomeStyle muss 'classic' oder 'android' sein" });
|
||||||
|
}
|
||||||
|
settingsRepo.setSetting("mobileHomeStyle", body.mobileHomeStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body?.mobileHomeLayout !== undefined) {
|
||||||
|
const layout = body.mobileHomeLayout;
|
||||||
|
const looksValid =
|
||||||
|
layout === null ||
|
||||||
|
(typeof layout === "object" &&
|
||||||
|
layout !== null &&
|
||||||
|
Array.isArray((layout as Record<string, unknown>).grid) &&
|
||||||
|
Array.isArray((layout as Record<string, unknown>).dock));
|
||||||
|
if (!looksValid) {
|
||||||
|
return reply
|
||||||
|
.code(400)
|
||||||
|
.send({ error: "mobileHomeLayout muss null oder ein Objekt mit 'grid' und 'dock' als Arrays sein" });
|
||||||
|
}
|
||||||
|
settingsRepo.setSetting("mobileHomeLayout", layout === null ? "" : JSON.stringify(layout));
|
||||||
|
}
|
||||||
|
|
||||||
|
return serializeSettings();
|
||||||
|
});
|
||||||
|
}
|
||||||
250
apps/backend/src/routes/transfer.ts
Normal file
250
apps/backend/src/routes/transfer.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
import * as deviceRepo from "../db/repositories/devices.js";
|
||||||
|
import * as serviceRepo from "../db/repositories/services.js";
|
||||||
|
import * as categoryRepo from "../db/repositories/categories.js";
|
||||||
|
|
||||||
|
interface TransferRow {
|
||||||
|
type: "device" | "service";
|
||||||
|
displayName: string;
|
||||||
|
hostname: string;
|
||||||
|
ip: string;
|
||||||
|
mac: string;
|
||||||
|
manufacturer: string;
|
||||||
|
model: string;
|
||||||
|
category: string;
|
||||||
|
alias: string;
|
||||||
|
favorite: string;
|
||||||
|
visible: string;
|
||||||
|
order: number | string;
|
||||||
|
port: string | number;
|
||||||
|
https: string;
|
||||||
|
url: string;
|
||||||
|
deviceHostname: string;
|
||||||
|
deviceIp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExportRows(): TransferRow[] {
|
||||||
|
const devices = deviceRepo.listDevices();
|
||||||
|
const services = serviceRepo.listServices();
|
||||||
|
const deviceById = new Map(devices.map((d) => [d.id, d]));
|
||||||
|
|
||||||
|
const deviceRows: TransferRow[] = devices.map((d) => ({
|
||||||
|
type: "device",
|
||||||
|
displayName: d.hostname,
|
||||||
|
hostname: d.hostname,
|
||||||
|
ip: d.ip,
|
||||||
|
mac: d.mac ?? "",
|
||||||
|
manufacturer: d.manufacturer ?? "",
|
||||||
|
model: d.model ?? "",
|
||||||
|
category: "",
|
||||||
|
alias: "",
|
||||||
|
favorite: "",
|
||||||
|
visible: "",
|
||||||
|
order: "",
|
||||||
|
port: "",
|
||||||
|
https: "",
|
||||||
|
url: "",
|
||||||
|
deviceHostname: "",
|
||||||
|
deviceIp: "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const serviceRows: TransferRow[] = services.map((s) => {
|
||||||
|
const device = deviceById.get(s.deviceId);
|
||||||
|
return {
|
||||||
|
type: "service",
|
||||||
|
displayName: s.displayName,
|
||||||
|
hostname: s.hostname,
|
||||||
|
ip: "",
|
||||||
|
mac: "",
|
||||||
|
manufacturer: "",
|
||||||
|
model: "",
|
||||||
|
category: s.category ?? "",
|
||||||
|
alias: s.alias.join(";"),
|
||||||
|
favorite: s.favorite ? "true" : "false",
|
||||||
|
visible: s.visible ? "true" : "false",
|
||||||
|
order: s.order,
|
||||||
|
port: s.port,
|
||||||
|
https: s.https ? "true" : "false",
|
||||||
|
url: s.url,
|
||||||
|
deviceHostname: device?.hostname ?? "",
|
||||||
|
deviceIp: device?.ip ?? "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...deviceRows, ...serviceRows];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transferRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/api/transfer/export", async (request, reply) => {
|
||||||
|
const query = request.query as { format?: string };
|
||||||
|
const format = query.format === "xlsx" ? "xlsx" : query.format === "json" ? "json" : "csv";
|
||||||
|
const rows = buildExportRows();
|
||||||
|
|
||||||
|
if (format === "json") {
|
||||||
|
reply.header("Content-Disposition", 'attachment; filename="launchpad-export.json"');
|
||||||
|
reply.type("application/json");
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const worksheet = XLSX.utils.json_to_sheet(rows);
|
||||||
|
|
||||||
|
if (format === "xlsx") {
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, "LaunchPad");
|
||||||
|
const buffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }) as Buffer;
|
||||||
|
reply.header("Content-Disposition", 'attachment; filename="launchpad-export.xlsx"');
|
||||||
|
reply.type("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
return reply.send(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const csv = XLSX.utils.sheet_to_csv(worksheet);
|
||||||
|
reply.header("Content-Disposition", 'attachment; filename="launchpad-export.csv"');
|
||||||
|
reply.type("text/csv; charset=utf-8");
|
||||||
|
return reply.send(csv);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/transfer/import", async (request, reply) => {
|
||||||
|
const body = request.body as { format?: string; content?: string } | undefined;
|
||||||
|
if (!body?.content) {
|
||||||
|
return reply.code(400).send({ error: "Kein Dateiinhalt übermittelt" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows: Record<string, unknown>[];
|
||||||
|
try {
|
||||||
|
if (body.format === "json") {
|
||||||
|
const text = Buffer.from(body.content, "base64").toString("utf-8");
|
||||||
|
rows = JSON.parse(text);
|
||||||
|
} else {
|
||||||
|
const buffer = Buffer.from(body.content, "base64");
|
||||||
|
const workbook = XLSX.read(buffer, { type: "buffer" });
|
||||||
|
const sheetName = workbook.SheetNames[0];
|
||||||
|
rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { defval: "" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: "Datei konnte nicht gelesen werden",
|
||||||
|
detail: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let devicesImported = 0;
|
||||||
|
let devicesSkipped = 0;
|
||||||
|
let servicesImported = 0;
|
||||||
|
let servicesSkipped = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Geräte-Zeilen zuerst verarbeiten, damit Dienst-Zeilen aus derselben
|
||||||
|
// Datei sie bereits über deviceHostname/deviceIp finden können.
|
||||||
|
const deviceRows = rows.filter((r) => String(r.type ?? "").trim() === "device");
|
||||||
|
const serviceRows = rows.filter((r) => String(r.type ?? "").trim() === "service");
|
||||||
|
|
||||||
|
for (const row of deviceRows) {
|
||||||
|
try {
|
||||||
|
const hostname = String(row.hostname ?? row.displayName ?? "").trim();
|
||||||
|
const ip = String(row.ip ?? "").trim();
|
||||||
|
if (!hostname || !ip) {
|
||||||
|
errors.push(`Geräte-Zeile übersprungen (hostname/ip fehlt): ${JSON.stringify(row)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDevices = deviceRepo.listDevices();
|
||||||
|
const existing =
|
||||||
|
allDevices.find((d) => d.ip === ip) ?? allDevices.find((d) => d.hostname === hostname);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
devicesSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceRepo.createDevice({
|
||||||
|
hostname,
|
||||||
|
ip,
|
||||||
|
mac: row.mac ? String(row.mac).trim() : undefined,
|
||||||
|
manufacturer: row.manufacturer ? String(row.manufacturer).trim() : undefined,
|
||||||
|
model: row.model ? String(row.model).trim() : undefined,
|
||||||
|
});
|
||||||
|
devicesImported++;
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of serviceRows) {
|
||||||
|
try {
|
||||||
|
const deviceHostname = String(row.deviceHostname ?? "").trim();
|
||||||
|
const deviceIp = String(row.deviceIp ?? "").trim();
|
||||||
|
const hostname = String(row.hostname ?? deviceHostname).trim();
|
||||||
|
const port = Number(row.port);
|
||||||
|
const displayName = String(row.displayName ?? "").trim();
|
||||||
|
const url = String(row.url ?? "").trim();
|
||||||
|
|
||||||
|
if (!displayName || !hostname || !port || !url) {
|
||||||
|
errors.push(`Dienst-Zeile übersprungen (Pflichtfelder fehlen): ${JSON.stringify(row)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDevices = deviceRepo.listDevices();
|
||||||
|
let device = deviceIp ? allDevices.find((d) => d.ip === deviceIp) : undefined;
|
||||||
|
if (!device && deviceHostname) {
|
||||||
|
device = allDevices.find((d) => d.hostname === deviceHostname);
|
||||||
|
}
|
||||||
|
if (!device) {
|
||||||
|
device = deviceRepo.createDevice({
|
||||||
|
hostname: deviceHostname || hostname,
|
||||||
|
ip: deviceIp || hostname,
|
||||||
|
});
|
||||||
|
devicesImported++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingService = serviceRepo
|
||||||
|
.listServicesByDevice(device.id)
|
||||||
|
.find((s) => s.port === port);
|
||||||
|
if (existingService) {
|
||||||
|
servicesSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = row.category ? String(row.category).trim() : undefined;
|
||||||
|
|
||||||
|
serviceRepo.createService({
|
||||||
|
deviceId: device.id,
|
||||||
|
displayName,
|
||||||
|
hostname,
|
||||||
|
url,
|
||||||
|
https: String(row.https ?? "").toLowerCase() === "true",
|
||||||
|
port,
|
||||||
|
category: category || undefined,
|
||||||
|
alias: row.alias
|
||||||
|
? String(row.alias)
|
||||||
|
.split(";")
|
||||||
|
.map((a) => a.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: undefined,
|
||||||
|
favorite: String(row.favorite ?? "").toLowerCase() === "true",
|
||||||
|
order: row.order !== undefined && row.order !== "" ? Number(row.order) : undefined,
|
||||||
|
visible:
|
||||||
|
row.visible !== undefined && row.visible !== ""
|
||||||
|
? String(row.visible).toLowerCase() === "true"
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
categoryRepo.ensureCategory(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
servicesImported++;
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
devicesImported,
|
||||||
|
devicesSkipped,
|
||||||
|
servicesImported,
|
||||||
|
servicesSkipped,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
117
apps/backend/src/scanJobs.ts
Normal file
117
apps/backend/src/scanJobs.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
export type ScanJobType = "devices" | "apis" | "fritzbox";
|
||||||
|
|
||||||
|
export interface ScanJobProgress {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
currentLabel: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanJobStatus {
|
||||||
|
running: boolean;
|
||||||
|
startedAt: string | null;
|
||||||
|
progress: ScanJobProgress | null;
|
||||||
|
lastResult: Record<string, unknown> | null;
|
||||||
|
lastError: string | null;
|
||||||
|
cancelRequested: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobs = new Map<ScanJobType, ScanJobStatus>();
|
||||||
|
|
||||||
|
function emptyStatus(): ScanJobStatus {
|
||||||
|
return {
|
||||||
|
running: false,
|
||||||
|
startedAt: null,
|
||||||
|
progress: null,
|
||||||
|
lastResult: null,
|
||||||
|
lastError: null,
|
||||||
|
cancelRequested: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentraler, server-seitiger Status je Scanner-Typ ("devices" = "Alle
|
||||||
|
* Geräte scannen", "apis" = API-Scanner). Bewusst NICHT in der Datenbank,
|
||||||
|
* nur im Arbeitsspeicher des Backend-Prozesses - der Zustand "ein Scan läuft
|
||||||
|
* gerade" muss keinen Neustart überleben, aber er MUSS geräteübergreifend
|
||||||
|
* sichtbar sein (jeder Browser/jedes Gerät, das den Adminbereich öffnet,
|
||||||
|
* soll denselben laufenden Scan sehen). Ein rein browserseitiger Zustand
|
||||||
|
* (sessionStorage) kann das prinzipiell nicht leisten.
|
||||||
|
*/
|
||||||
|
export function getJobStatus(type: ScanJobType): ScanJobStatus {
|
||||||
|
return jobs.get(type) ?? emptyStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isJobRunning(type: ScanJobType): boolean {
|
||||||
|
return getJobStatus(type).running;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startJob(type: ScanJobType, total: number): void {
|
||||||
|
jobs.set(type, {
|
||||||
|
running: true,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
progress: { current: 0, total, currentLabel: null },
|
||||||
|
lastResult: null,
|
||||||
|
lastError: null,
|
||||||
|
cancelRequested: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateJobProgress(type: ScanJobType, current: number, currentLabel: string | null): void {
|
||||||
|
const job = jobs.get(type);
|
||||||
|
if (!job) return;
|
||||||
|
job.progress = { current, total: job.progress?.total ?? current, currentLabel };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCancelRequested(type: ScanJobType): boolean {
|
||||||
|
return jobs.get(type)?.cancelRequested ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestCancel(type: ScanJobType): void {
|
||||||
|
const job = jobs.get(type);
|
||||||
|
if (job) job.cancelRequested = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finishJob(type: ScanJobType, result: Record<string, unknown>): void {
|
||||||
|
const job = jobs.get(type) ?? emptyStatus();
|
||||||
|
jobs.set(type, { ...job, running: false, lastResult: result, cancelRequested: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function failJob(type: ScanJobType, error: string): void {
|
||||||
|
const job = jobs.get(type) ?? emptyStatus();
|
||||||
|
jobs.set(type, { ...job, running: false, lastError: error, cancelRequested: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sperrt ein einzelnes Gerät gegen ÜBERLAPPENDE Scans - unabhängig davon, ob
|
||||||
|
* der Scan über den Einzelgerät-Endpunkt oder als Teil des Sammel-Scans
|
||||||
|
* ("Alle Geräte scannen") läuft. Ohne das könnten zwei gleichzeitige Scans
|
||||||
|
* desselben Geräts beide unabhängig voneinander denselben neuen Dienst
|
||||||
|
* anlegen wollen (siehe UNIQUE-Index in client.ts als zusätzliches
|
||||||
|
* Sicherheitsnetz auf Datenbankebene).
|
||||||
|
*/
|
||||||
|
const devicesCurrentlyScanning = new Set<string>();
|
||||||
|
|
||||||
|
export function tryLockDevice(deviceId: string): boolean {
|
||||||
|
if (devicesCurrentlyScanning.has(deviceId)) return false;
|
||||||
|
devicesCurrentlyScanning.add(deviceId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unlockDevice(deviceId: string): void {
|
||||||
|
devicesCurrentlyScanning.delete(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die IDs aller Geräte, die GERADE JETZT gescannt werden - egal ob
|
||||||
|
* über den Einzelgerät-Endpunkt (manueller "Jetzt scannen"-Button auf der
|
||||||
|
* Geräteseite) oder als Teil eines laufenden Sammel-Scans. Genutzt für die
|
||||||
|
* geräteübergreifende Anzeige "Ein Scan läuft gerade" in der Seitenleiste
|
||||||
|
* (siehe AdminLayout.tsx/useScanActivity.ts) - vorher war ein einzelner
|
||||||
|
* manueller Geräte-Scan NUR im Browser sichtbar, der ihn ausgelöst hat (der
|
||||||
|
* Einzelgerät-Endpunkt ist eine simple, blockierende Anfrage ohne eigenen
|
||||||
|
* Job-Status wie der Sammel-Scan), auf jedem anderen Browser/Gerät gab es
|
||||||
|
* dafür überhaupt keine Anzeige.
|
||||||
|
*/
|
||||||
|
export function getScanningDeviceIds(): string[] {
|
||||||
|
return Array.from(devicesCurrentlyScanning);
|
||||||
|
}
|
||||||
193
apps/backend/src/scanner/apiDetector.ts
Normal file
193
apps/backend/src/scanner/apiDetector.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
import https from "node:https";
|
||||||
|
|
||||||
|
export interface DetectedApi {
|
||||||
|
path: string;
|
||||||
|
type: string;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawProbeResult {
|
||||||
|
status: number;
|
||||||
|
contentType: string | null;
|
||||||
|
wwwAuthenticate: string | null;
|
||||||
|
bodySnippet: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 8_192;
|
||||||
|
|
||||||
|
function fetchRaw(url: string, timeoutMs = 2500): Promise<RawProbeResult | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const isHttps = url.startsWith("https://");
|
||||||
|
const client = isHttps ? https : http;
|
||||||
|
|
||||||
|
const req = client.get(
|
||||||
|
url,
|
||||||
|
{ timeout: timeoutMs, rejectUnauthorized: false, headers: { Accept: "application/json, */*" } },
|
||||||
|
(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 contentType = res.headers["content-type"] ?? null;
|
||||||
|
const wwwAuthenticate = res.headers["www-authenticate"] ?? null;
|
||||||
|
resolve({
|
||||||
|
status: res.statusCode ?? 0,
|
||||||
|
contentType,
|
||||||
|
wwwAuthenticate: Array.isArray(wwwAuthenticate) ? wwwAuthenticate[0] : wwwAuthenticate,
|
||||||
|
bodySnippet: body,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
res.on("error", () => resolve(null));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on("timeout", () => {
|
||||||
|
req.destroy();
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
req.on("error", () => resolve(null));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wohlbekannte Pfade, unter denen selbstgehostete Software üblicherweise
|
||||||
|
* ihre API bzw. deren Dokumentation/Schema anbietet - sowohl generische
|
||||||
|
* REST/OpenAPI/GraphQL-Konventionen als auch Pfade konkreter, in Homelabs
|
||||||
|
* verbreiteter Software (Home Assistant, Proxmox, Portainer, die *arr-Reihe,
|
||||||
|
* Grafana, Jellyfin/Plex, Pi-hole, Unifi, ...). Bewusst eine kuratierte
|
||||||
|
* Liste statt eines vollständigen Wortlisten-Bruteforce - das hier ist ein
|
||||||
|
* Hinweis-Scanner, kein Sicherheits-/Pentesting-Werkzeug.
|
||||||
|
*/
|
||||||
|
const CANDIDATE_PATHS: { path: string; type: string }[] = [
|
||||||
|
// Generische REST-/OpenAPI-/GraphQL-Konventionen - bewusst nur Pfade, die
|
||||||
|
// auf eine tatsächlich NUTZBARE/dokumentierte API hindeuten. Reine
|
||||||
|
// Status-/Health-/Metrik-Endpunkte (/health, /metrics, /actuator, ...)
|
||||||
|
// wurden bewusst rausgenommen: die bestätigen nur "hier läuft Software",
|
||||||
|
// sind aber keine API, mit der man tatsächlich etwas anfangen kann.
|
||||||
|
{ path: "/openapi.json", type: "OpenAPI" },
|
||||||
|
{ path: "/openapi.yaml", type: "OpenAPI" },
|
||||||
|
{ path: "/swagger.json", type: "OpenAPI (Swagger)" },
|
||||||
|
{ path: "/swagger.yaml", type: "OpenAPI (Swagger)" },
|
||||||
|
{ path: "/api-docs", type: "OpenAPI (Swagger)" },
|
||||||
|
{ path: "/v2/api-docs", type: "OpenAPI (Swagger)" },
|
||||||
|
{ path: "/swagger/index.html", type: "Swagger-UI" },
|
||||||
|
{ path: "/swagger-ui", type: "Swagger-UI" },
|
||||||
|
{ path: "/redoc", type: "OpenAPI (ReDoc)" },
|
||||||
|
{ path: "/graphql", type: "GraphQL" },
|
||||||
|
{ path: "/graphiql", type: "GraphQL" },
|
||||||
|
{ path: "/api/graphql", type: "GraphQL" },
|
||||||
|
{ path: "/.well-known/openapi.json", type: "OpenAPI" },
|
||||||
|
{ path: "/api/v3", type: "REST-API" },
|
||||||
|
{ path: "/api/v2", type: "REST-API" },
|
||||||
|
{ path: "/api/v1", type: "REST-API" },
|
||||||
|
{ path: "/api", type: "REST-API" },
|
||||||
|
{ path: "/rest", type: "REST-API" },
|
||||||
|
{ path: "/rpc", type: "JSON-RPC" },
|
||||||
|
{ path: "/jsonrpc", type: "JSON-RPC" },
|
||||||
|
// Konkrete, in Homelabs verbreitete Software - jeweils ein Endpunkt IHRER
|
||||||
|
// eigenen echten API (nicht nur ein Status-Ping), deshalb behalten.
|
||||||
|
{ path: "/api/config/core", type: "Home Assistant API" }, // erfordert Auth, meldet sich aber als API
|
||||||
|
{ path: "/api2/json/version", type: "Proxmox API" },
|
||||||
|
{ path: "/api/status", type: "Portainer API" },
|
||||||
|
{ path: "/api/v3/system/status", type: "Sonarr/Radarr/Prowlarr API" },
|
||||||
|
{ path: "/admin/api.php", type: "Pi-hole API" },
|
||||||
|
{ path: "/System/Info/Public", type: "Jellyfin API" },
|
||||||
|
{ path: "/identity", type: "Plex API" },
|
||||||
|
{ path: "/api/self", type: "Unifi-Controller API" },
|
||||||
|
{ path: "/api/v2/server/about", type: "Nextcloud API" },
|
||||||
|
{ path: "/ocs/v1.php/cloud/capabilities", type: "Nextcloud API" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function looksLikeJson(body: string): boolean {
|
||||||
|
const trimmed = body.trim();
|
||||||
|
return trimmed.startsWith("{") || trimmed.startsWith("[");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isApiLikeResponse(result: RawProbeResult): boolean {
|
||||||
|
const contentTypeIsApi =
|
||||||
|
!!result.contentType &&
|
||||||
|
(result.contentType.includes("json") ||
|
||||||
|
result.contentType.includes("graphql") ||
|
||||||
|
result.contentType.includes("xml"));
|
||||||
|
const bodyIsJson = looksLikeJson(result.bodySnippet);
|
||||||
|
const hasAuthChallenge = !!result.wwwAuthenticate;
|
||||||
|
return contentTypeIsApi || bodyIsJson || hasAuthChallenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft die kuratierten Kandidaten-Pfade unter einer Basis-URL parallel und
|
||||||
|
* liefert alle, die auf eine tatsächlich vorhandene API hindeuten:
|
||||||
|
* - eine JSON-Antwort (egal ob 200 oder z. B. 401 "unauthorized" - eine
|
||||||
|
* JSON-Fehlermeldung zeigt trotzdem "hier läuft eine API"),
|
||||||
|
* - ein Content-Type, der explizit auf JSON/GraphQL/XML-API hindeutet,
|
||||||
|
* - oder ein "WWW-Authenticate"-Header (401 mit diesem Header ist normalerweise
|
||||||
|
* ein starkes Signal für eine authentifizierungspflichtige API).
|
||||||
|
* Reine HTML-Seiten (z. B. eine 404-Fehlerseite des Frontends oder eine
|
||||||
|
* Login-Weiterleitung ohne API-Signal) zählen nicht.
|
||||||
|
*
|
||||||
|
* KEIN pauschaler Kontroll-Check gegen einen zufälligen Pfad mehr (frühere
|
||||||
|
* Version): viele Selfhosted-Programme (Sonarr, Portainer, Home Assistant, …)
|
||||||
|
* fordern für JEDE unauthentifizierte Anfrage einheitlich eine Anmeldung -
|
||||||
|
* das sah wie ein Gerät aus, "dem man nicht trauen kann", war aber normales,
|
||||||
|
* erwartbares Verhalten und hat massenhaft echte Treffer unterdrückt.
|
||||||
|
* Stattdessen wird JEDER einzelne Treffer für sich geprüft (siehe unten).
|
||||||
|
*/
|
||||||
|
export async function detectApis(baseUrl: string): Promise<DetectedApi[]> {
|
||||||
|
const checks = await Promise.all(
|
||||||
|
CANDIDATE_PATHS.map(async ({ path, type }) => {
|
||||||
|
const result = await fetchRaw(`${baseUrl}${path}`);
|
||||||
|
if (!result || result.status === 0 || result.status === 404) return null;
|
||||||
|
if (!isApiLikeResponse(result)) return null;
|
||||||
|
|
||||||
|
const detected: DetectedApi = { path, type, status: result.status };
|
||||||
|
return detected;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let matches = checks.filter((c): c is DetectedApi => c !== null);
|
||||||
|
|
||||||
|
// Gegen Catch-all-Verhalten (z. B. FritzBox/Hue Bridge: 404 für "echte"
|
||||||
|
// Fremdpfade, aber jede Anfrage UNTER einem bekannten API-Pfad wird gleich
|
||||||
|
// generisch beantwortet - ganz unabhängig vom genauen Unterpfad): für
|
||||||
|
// jeden Treffer werden ZWEI erfundene Varianten geprüft - (a) derselbe
|
||||||
|
// Pfad mit verändertem letzten Segment, (b) ein komplett zufälliger Pfad
|
||||||
|
// im selben Verzeichnis. Antwortet EINE der beiden genauso "positiv", war
|
||||||
|
// der ursprüngliche Treffer nur ein genereller Handler, kein echter,
|
||||||
|
// spezifischer Endpunkt.
|
||||||
|
const verified = await Promise.all(
|
||||||
|
matches.map(async (m) => {
|
||||||
|
const mutatedLastSegment = m.path.replace(/([^/]+)$/, `$1-x${Date.now().toString(36)}`);
|
||||||
|
const directoryPrefix = m.path.slice(0, m.path.lastIndexOf("/") + 1);
|
||||||
|
const randomSibling = `${directoryPrefix}__lp_probe_${Date.now().toString(36)}__`;
|
||||||
|
|
||||||
|
const [siblingA, siblingB] = await Promise.all([
|
||||||
|
fetchRaw(`${baseUrl}${mutatedLastSegment}`),
|
||||||
|
directoryPrefix.length > 1 ? fetchRaw(`${baseUrl}${randomSibling}`) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isCatchAll = [siblingA, siblingB].some(
|
||||||
|
(s) => !!s && s.status !== 404 && s.status !== 0 && isApiLikeResponse(s)
|
||||||
|
);
|
||||||
|
return isCatchAll ? null : m;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
matches = verified.filter((m): m is DetectedApi => m !== null);
|
||||||
|
|
||||||
|
// Mehrere Kandidaten-Einträge können denselben Pfad haben (z. B. das
|
||||||
|
// generische "/api/status" -> "REST-API" UND das Portainer-spezifische
|
||||||
|
// "/api/status" -> "Portainer API") - nach Pfad deduplizieren, dabei die
|
||||||
|
// spezifischere (nicht-generische) Beschriftung bevorzugen.
|
||||||
|
const GENERIC_TYPES = new Set(["REST-API", "JSON-RPC", "Health-Endpunkt"]);
|
||||||
|
const byPath = new Map<string, DetectedApi>();
|
||||||
|
for (const match of matches) {
|
||||||
|
const existing = byPath.get(match.path);
|
||||||
|
if (!existing || (GENERIC_TYPES.has(existing.type) && !GENERIC_TYPES.has(match.type))) {
|
||||||
|
byPath.set(match.path, match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byPath.values());
|
||||||
|
}
|
||||||
139
apps/backend/src/scanner/digestAuth.ts
Normal file
139
apps/backend/src/scanner/digestAuth.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import http, { type IncomingHttpHeaders } from "node:http";
|
||||||
|
import https from "node:https";
|
||||||
|
|
||||||
|
export interface DigestAuthOptions {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
https?: boolean;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DigestChallenge {
|
||||||
|
realm: string;
|
||||||
|
nonce: string;
|
||||||
|
qop?: string;
|
||||||
|
opaque?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function md5(input: string): string {
|
||||||
|
return createHash("md5").update(input).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDigestHeader(header: string): DigestChallenge {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
const regex = /(\w+)=("([^"]*)"|[^,]*)/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = regex.exec(header))) {
|
||||||
|
params[match[1]] = match[3] ?? match[2];
|
||||||
|
}
|
||||||
|
return { realm: params.realm, nonce: params.nonce, qop: params.qop, opaque: params.opaque };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawResponse {
|
||||||
|
status: number;
|
||||||
|
headers: IncomingHttpHeaders;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawRequest(
|
||||||
|
options: DigestAuthOptions,
|
||||||
|
path: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
body: string
|
||||||
|
): Promise<RawResponse> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const client = options.https ? https : http;
|
||||||
|
const req = client.request(
|
||||||
|
{
|
||||||
|
host: options.host,
|
||||||
|
port: options.port,
|
||||||
|
path,
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...headers, "Content-Length": Buffer.byteLength(body) },
|
||||||
|
timeout: options.timeoutMs ?? 4000,
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (chunk: Buffer) => (data += chunk.toString("utf-8")));
|
||||||
|
res.on("end", () =>
|
||||||
|
resolve({ status: res.statusCode ?? 0, headers: res.headers, body: data })
|
||||||
|
);
|
||||||
|
res.on("error", reject);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
req.on("timeout", () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error("Zeitüberschreitung bei der Verbindung zur FritzBox"));
|
||||||
|
});
|
||||||
|
req.on("error", reject);
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt einen TR-064-SOAP-Request gegen `/upnp/control/hosts` aus.
|
||||||
|
* Läuft zunächst unauthentifiziert; antwortet die FritzBox mit 401 und einer
|
||||||
|
* WWW-Authenticate-Digest-Challenge, wird automatisch mit korrekt berechnetem
|
||||||
|
* Digest-Response-Header erneut angefragt (RFC 2617).
|
||||||
|
*/
|
||||||
|
export async function soapRequest(
|
||||||
|
options: DigestAuthOptions,
|
||||||
|
soapAction: string,
|
||||||
|
body: string
|
||||||
|
): Promise<string> {
|
||||||
|
const path = "/upnp/control/hosts";
|
||||||
|
const baseHeaders = {
|
||||||
|
"Content-Type": 'text/xml; charset="utf-8"',
|
||||||
|
SOAPACTION: soapAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = await rawRequest(options, path, baseHeaders, body);
|
||||||
|
if (first.status === 200) {
|
||||||
|
return first.body;
|
||||||
|
}
|
||||||
|
if (first.status !== 401) {
|
||||||
|
throw new Error(`Unerwarteter Status von der FritzBox: ${first.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wwwAuth = first.headers["www-authenticate"];
|
||||||
|
if (!wwwAuth) {
|
||||||
|
throw new Error("FritzBox verlangt Authentifizierung, sendet aber keine Digest-Challenge");
|
||||||
|
}
|
||||||
|
const challenge = parseDigestHeader(Array.isArray(wwwAuth) ? wwwAuth[0] : wwwAuth);
|
||||||
|
if (!challenge.realm || !challenge.nonce) {
|
||||||
|
throw new Error("Digest-Challenge der FritzBox konnte nicht gelesen werden");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ha1 = md5(`${options.username}:${challenge.realm}:${options.password}`);
|
||||||
|
const ha2 = md5(`POST:${path}`);
|
||||||
|
const nc = "00000001";
|
||||||
|
const cnonce = randomBytes(8).toString("hex");
|
||||||
|
const response = challenge.qop
|
||||||
|
? md5(`${ha1}:${challenge.nonce}:${nc}:${cnonce}:${challenge.qop}:${ha2}`)
|
||||||
|
: md5(`${ha1}:${challenge.nonce}:${ha2}`);
|
||||||
|
|
||||||
|
const authHeader =
|
||||||
|
`Digest username="${options.username}", realm="${challenge.realm}", ` +
|
||||||
|
`nonce="${challenge.nonce}", uri="${path}", response="${response}"` +
|
||||||
|
(challenge.qop ? `, qop=${challenge.qop}, nc=${nc}, cnonce="${cnonce}"` : "") +
|
||||||
|
(challenge.opaque ? `, opaque="${challenge.opaque}"` : "");
|
||||||
|
|
||||||
|
const second = await rawRequest(
|
||||||
|
options,
|
||||||
|
path,
|
||||||
|
{ ...baseHeaders, Authorization: authHeader },
|
||||||
|
body
|
||||||
|
);
|
||||||
|
|
||||||
|
if (second.status !== 200) {
|
||||||
|
throw new Error(`FritzBox-Authentifizierung fehlgeschlagen (Status ${second.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return second.body;
|
||||||
|
}
|
||||||
80
apps/backend/src/scanner/dns.ts
Normal file
80
apps/backend/src/scanner/dns.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { lookup, reverse } from "node:dns/promises";
|
||||||
|
|
||||||
|
export interface DnsResolution {
|
||||||
|
hostname: string;
|
||||||
|
ip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GEFUNDENER FLASCHENHALS (round55): dns.lookup()/dns.reverse() aus
|
||||||
|
// node:dns/promises laufen NICHT direkt asynchron über den Netzwerk-Stack,
|
||||||
|
// sondern über getaddrinfo() im Betriebssystem - Node schickt das über den
|
||||||
|
// libuv-Threadpool ab, der standardmäßig nur 4 Threads hat
|
||||||
|
// (UV_THREADPOOL_SIZE=4), UNABHÄNGIG von Node's eigentlich nicht-blockierendem
|
||||||
|
// I/O-Modell. Ohne eigenen Timeout kann eine einzelne langsame/hängende
|
||||||
|
// Abfrage (z. B. für Handys/Smart-Geräte mit FritzBox-Spitznamen wie
|
||||||
|
// "S24-Ultra-von-Sebastian", die weder per normaler DNS noch per .home/.local
|
||||||
|
// auflösbar sind, aber je nach Resolver/Musl-libc im Alpine-Image erst nach
|
||||||
|
// langer Zeit "nicht gefunden" zurückmelden) einen der 4 Threads über
|
||||||
|
// Sekunden bis Minuten blockieren. Bei bis zu 10 Geräten gleichzeitig (siehe
|
||||||
|
// routes/scan.ts CONCURRENT_DEVICES) mit je bis zu 4 DNS-Abfragen (3x
|
||||||
|
// resolveHostname-Varianten + 1x reverseLookup) stauen sich die restlichen
|
||||||
|
// Abfragen dann VOR dem 4er-Nadelöhr - das erklärt einen Sammel-Scan, der
|
||||||
|
// nach einem erfolgreichen masscan-/httpx-Vorabscan plötzlich minutenlang in
|
||||||
|
// der eigentlichen Geräte-Schleife hängt, obwohl dort eigentlich nur noch
|
||||||
|
// leichte HTTP-Detailabfragen laufen sollten (siehe Bugreport).
|
||||||
|
const DNS_TIMEOUT_MS = 1500;
|
||||||
|
|
||||||
|
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error("DNS-Zeitüberschreitung")), ms);
|
||||||
|
promise.then(
|
||||||
|
(value) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versucht der Spezifikation folgend: hostname, hostname.home, hostname.local.
|
||||||
|
* Gibt die erste erfolgreich aufgelöste Variante zurück, sonst null. Jeder
|
||||||
|
* einzelne Versuch ist auf DNS_TIMEOUT_MS begrenzt (siehe Kommentar oben) -
|
||||||
|
* eine hängende Abfrage blockiert damit höchstens 3 × 1,5s statt potenziell
|
||||||
|
* unbegrenzt.
|
||||||
|
*/
|
||||||
|
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 withTimeout(lookup(hostname), DNS_TIMEOUT_MS);
|
||||||
|
return { hostname, ip: address };
|
||||||
|
} catch {
|
||||||
|
// nächste Variante versuchen (Fehler ODER Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Umgekehrte DNS-Abfrage (PTR-Record): fragt anhand der IP nach einem Namen.
|
||||||
|
* Viele Router/DHCP-Server (FritzBox, Pi-hole, AdGuard Home, ...) tragen hier
|
||||||
|
* automatisch den vom Gerät gemeldeten DHCP-Hostnamen ein, auch wenn die
|
||||||
|
* normale Vorwärtsauflösung (Name -> IP) nichts findet. Nützlich als
|
||||||
|
* zusätzliche Quelle für Gerätenamen, siehe scanner/networkScanner.ts. Mit
|
||||||
|
* demselben Timeout abgesichert wie resolveHostname() oben.
|
||||||
|
*/
|
||||||
|
export async function reverseLookup(ip: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const names = await withTimeout(reverse(ip), DNS_TIMEOUT_MS);
|
||||||
|
return names[0] ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
apps/backend/src/scanner/fritzbox.ts
Normal file
66
apps/backend/src/scanner/fritzbox.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { soapRequest, type DigestAuthOptions } from "./digestAuth.js";
|
||||||
|
|
||||||
|
const SERVICE_TYPE = "urn:dslforum-org:service:Hosts:1";
|
||||||
|
|
||||||
|
function actionEnvelope(action: string, params: Record<string, string | number> = {}): string {
|
||||||
|
const args = Object.entries(params)
|
||||||
|
.map(([key, value]) => `<${key}>${value}</${key}>`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<?xml version="1.0" encoding="utf-8"?>` +
|
||||||
|
`<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" ` +
|
||||||
|
`s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">` +
|
||||||
|
`<s:Body><u:${action} xmlns:u="${SERVICE_TYPE}">${args}</u:${action}></s:Body>` +
|
||||||
|
`</s:Envelope>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTag(xml: string, tag: string): string | null {
|
||||||
|
const match = xml.match(new RegExp(`<${tag}>([^<]*)</${tag}>`, "i"));
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FritzBoxHost {
|
||||||
|
ip: string;
|
||||||
|
mac: string | null;
|
||||||
|
hostname: string;
|
||||||
|
online: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die vollständige Geräteliste der FritzBox über die TR-064-Aktionen
|
||||||
|
* GetHostNumberOfEntries + GetGenericHostEntry (urn:dslforum-org:service:Hosts:1).
|
||||||
|
*/
|
||||||
|
export async function fetchFritzBoxHosts(options: DigestAuthOptions): Promise<FritzBoxHost[]> {
|
||||||
|
const countXml = await soapRequest(
|
||||||
|
options,
|
||||||
|
`${SERVICE_TYPE}#GetHostNumberOfEntries`,
|
||||||
|
actionEnvelope("GetHostNumberOfEntries")
|
||||||
|
);
|
||||||
|
const countStr = extractTag(countXml, "NewHostNumberOfEntries");
|
||||||
|
const count = countStr ? parseInt(countStr, 10) : 0;
|
||||||
|
|
||||||
|
const hosts: FritzBoxHost[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
const entryXml = await soapRequest(
|
||||||
|
options,
|
||||||
|
`${SERVICE_TYPE}#GetGenericHostEntry`,
|
||||||
|
actionEnvelope("GetGenericHostEntry", { NewIndex: index })
|
||||||
|
);
|
||||||
|
|
||||||
|
const ip = extractTag(entryXml, "NewIPAddress");
|
||||||
|
const hostname = extractTag(entryXml, "NewHostName");
|
||||||
|
if (!ip || !hostname) continue; // inaktive/unvollständige Einträge überspringen
|
||||||
|
|
||||||
|
hosts.push({
|
||||||
|
ip,
|
||||||
|
mac: extractTag(entryXml, "NewMACAddress"),
|
||||||
|
hostname,
|
||||||
|
online: extractTag(entryXml, "NewActive") === "1",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return hosts;
|
||||||
|
}
|
||||||
101
apps/backend/src/scanner/http.ts
Normal file
101
apps/backend/src/scanner/http.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
import https from "node:https";
|
||||||
|
|
||||||
|
export interface HttpProbeResult {
|
||||||
|
ok: boolean;
|
||||||
|
status?: number;
|
||||||
|
title?: string;
|
||||||
|
faviconUrl?: string;
|
||||||
|
description?: 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),
|
||||||
|
description: extractDescription(body),
|
||||||
|
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];
|
||||||
|
// Kein <link rel="icon"> im HTML gefunden -> hier NICHT mehr blind auf
|
||||||
|
// "/favicon.ico" raten (das täuscht ein bestätigtes Favicon vor, das oft
|
||||||
|
// gar nicht existiert - 404). Der Aufrufer (scanDeviceServices) entscheidet
|
||||||
|
// die Fallback-Reihenfolge: erst ein Favicon eines anderen Dienstes auf
|
||||||
|
// demselben Gerät übernehmen, erst danach selbst raten.
|
||||||
|
if (!href) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(href, baseUrl).toString();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDescription(html: string): string | undefined {
|
||||||
|
// <meta name="description" content="..."> in beiden Attribut-Reihenfolgen
|
||||||
|
const match =
|
||||||
|
html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i) ??
|
||||||
|
html.match(/<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["']/i);
|
||||||
|
const description = match?.[1]?.trim();
|
||||||
|
return description ? description : undefined;
|
||||||
|
}
|
||||||
280
apps/backend/src/scanner/httpx.ts
Normal file
280
apps/backend/src/scanner/httpx.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
export interface HttpxResult {
|
||||||
|
port: number;
|
||||||
|
scheme: "http" | "https";
|
||||||
|
statusCode?: number;
|
||||||
|
title?: string;
|
||||||
|
webserver?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let httpxConfirmedUnavailable = false;
|
||||||
|
|
||||||
|
// Klartext-Grund des letzten Fehlschlags (stderr/Fehlermeldung) - wird im
|
||||||
|
// Scan-Log angezeigt, damit sichtbar ist WARUM httpx nicht greift, statt nur
|
||||||
|
// "nicht genutzt/verfügbar" ohne jede Erklärung.
|
||||||
|
let httpxLastError: string | null = null;
|
||||||
|
|
||||||
|
export function isHttpxConfirmedUnavailable(): boolean {
|
||||||
|
return httpxConfirmedUnavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHttpxLastError(): string | null {
|
||||||
|
return httpxLastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einfacher Zähl-Semaphore (siehe auch masscan.ts), Kapazität 2: Seit
|
||||||
|
* round50 läuft pro Scan-JOB nur noch EIN httpx-Prozess für ALLE Geräte
|
||||||
|
* gleichzeitig (siehe probeManyWithHttpx unten), nicht mehr einer PRO Gerät
|
||||||
|
* wie bis round49. Der Semaphore schützt nur noch den seltenen Fall, dass
|
||||||
|
* zwei unabhängige Scan-Jobs (Sammel-Scan + gleichzeitig ein manueller
|
||||||
|
* Einzelgeräte-Scan) überlappen.
|
||||||
|
*/
|
||||||
|
class Semaphore {
|
||||||
|
private available: number;
|
||||||
|
private readonly queue: (() => void)[] = [];
|
||||||
|
|
||||||
|
constructor(count: number) {
|
||||||
|
this.available = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
acquire(): Promise<void> {
|
||||||
|
if (this.available > 0) {
|
||||||
|
this.available--;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => this.queue.push(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
release(): void {
|
||||||
|
const next = this.queue.shift();
|
||||||
|
if (next) next();
|
||||||
|
else this.available++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpxSemaphore = new Semaphore(2);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Threads für den httpx-Aufruf: skaliert leicht mit der Zielanzahl (mehr
|
||||||
|
* Ziele -> mehr Parallelität sinnvoll), aber mit Ober-/Untergrenze. Seit nur
|
||||||
|
* noch EIN httpx-Prozess pro Scan-Job läuft (statt bis zu 10 gleichzeitig),
|
||||||
|
* ist ein höherer Wert als die bisherigen festen 50 unproblematisch - dieser
|
||||||
|
* eine Prozess hat die volle Bandbreite für sich.
|
||||||
|
*/
|
||||||
|
function computeThreads(targetCount: number): number {
|
||||||
|
return Math.min(200, Math.max(50, targetCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schätzt einen angemessenen Gesamt-Timeout für einen httpx-Lauf über
|
||||||
|
* mehrere Ziele: (Zielanzahl / Threads) * Zeit pro Versuch (Timeout *
|
||||||
|
* (1 + Retries)), mit Sicherheitsfaktor und Grenzen (30s..10min).
|
||||||
|
*/
|
||||||
|
function computeTimeoutMs(targetCount: number, threads: number, perProbeTimeoutSec: number, retries: number): number {
|
||||||
|
const perProbeSeconds = perProbeTimeoutSec * (1 + retries);
|
||||||
|
const estimatedSeconds = (targetCount / threads) * perProbeSeconds;
|
||||||
|
const withMargin = estimatedSeconds * 1.5 + 10;
|
||||||
|
// Math.round() analog zu masscan.ts: auch wenn Node's setTimeout() Floats
|
||||||
|
// technisch toleriert, bleibt der Wert so konsistent nachvollziehbar
|
||||||
|
// (ganze Sekunden in den Log-/Fehlermeldungen weiter oben).
|
||||||
|
return Math.round(Math.min(Math.max(withMargin * 1000, 30_000), 10 * 60_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nutzt httpx (ProjectDiscovery, falls im Container installiert - siehe
|
||||||
|
* Dockerfile), um eine LISTE von "ip:port"-Zielen ÜBER MEHRERE GERÄTE HINWEG
|
||||||
|
* in einem einzigen Prozesslauf zu prüfen, statt pro Gerät einen eigenen
|
||||||
|
* httpx-Prozess zu starten (das führte bei vielen parallel gescannten
|
||||||
|
* Geräten - siehe routes/scan.ts CONCURRENT_DEVICES - zu bis zu 500
|
||||||
|
* gleichzeitigen HTTP-Anfragen allein durch httpx und damit zu Timeouts
|
||||||
|
* unter Last, siehe Bugreport).
|
||||||
|
*
|
||||||
|
* Ergebnis ist eine Map, deren Schlüssel "ip:port" ist (z. B.
|
||||||
|
* "192.168.1.50:8080") - zusammengesetzt aus httpx' eigenem "url"-Feld
|
||||||
|
* (IMMER Teil der -json-Ausgabe, im Gegensatz zu ggf. je nach Version
|
||||||
|
* abweichenden Feldern wie "input"), damit die Zuordnung robust gegenüber
|
||||||
|
* kleineren httpx-Versionsunterschieden ist.
|
||||||
|
*
|
||||||
|
* Liefert null (nicht eine leere Map!), wenn httpx fehlt oder komplett
|
||||||
|
* fehlschlägt - der Aufrufer erkennt daran "kein httpx verfügbar" und fällt
|
||||||
|
* für ALLE betroffenen Ports auf die eingebaute Methode zurück. Ein Ziel,
|
||||||
|
* das httpx innerhalb des Timeouts nicht erreicht, taucht in der Map
|
||||||
|
* schlicht nicht auf - von hier aus nicht unterscheidbar von "spricht kein
|
||||||
|
* HTTP". Die Aufrufer (networkScanner.ts) prüfen daher jeden offenen Port,
|
||||||
|
* der NICHT in der Map auftaucht, sicherheitshalber trotzdem noch einmal
|
||||||
|
* selbst nach, statt ihn zu verwerfen.
|
||||||
|
*/
|
||||||
|
export async function probeManyWithHttpx(
|
||||||
|
targets: Array<{ ip: string; port: number }>,
|
||||||
|
onProgress?: (confirmedSoFar: number) => void
|
||||||
|
): Promise<Map<string, HttpxResult> | null> {
|
||||||
|
if (httpxConfirmedUnavailable || targets.length === 0) {
|
||||||
|
return targets.length === 0 ? new Map() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await httpxSemaphore.acquire();
|
||||||
|
try {
|
||||||
|
const threads = computeThreads(targets.length);
|
||||||
|
const perProbeTimeoutSec = 8;
|
||||||
|
const retries = 1;
|
||||||
|
const timeoutMs = computeTimeoutMs(targets.length, threads, perProbeTimeoutSec, retries);
|
||||||
|
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
const child = spawn(
|
||||||
|
"httpx",
|
||||||
|
[
|
||||||
|
"-silent",
|
||||||
|
"-json",
|
||||||
|
"-timeout",
|
||||||
|
String(perProbeTimeoutSec),
|
||||||
|
"-retries",
|
||||||
|
String(retries),
|
||||||
|
"-no-color",
|
||||||
|
"-threads",
|
||||||
|
String(threads),
|
||||||
|
],
|
||||||
|
{ stdio: ["pipe", "pipe", "pipe"] }
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let settled = false;
|
||||||
|
// Zählt Zeilen live mit, während sie eintreffen (httpx schreibt jedes
|
||||||
|
// Ergebnis sofort nach erfolgreicher Prüfung, nicht erst am Ende) -
|
||||||
|
// für eine Live-Anzeige "X bereits gefunden" im Sammel-Scan-Job.
|
||||||
|
// Erfasst nur ALS HTTP/HTTPS BESTÄTIGTE Ziele (jede Zeile ist ein
|
||||||
|
// Treffer), nicht die Gesamtzahl geprüfter Ziele - httpx meldet
|
||||||
|
// erfolglose Versuche gar nicht erst (siehe Kommentar an der Funktion
|
||||||
|
// oben).
|
||||||
|
let confirmedCount = 0;
|
||||||
|
let progressParseBuffer = "";
|
||||||
|
|
||||||
|
const finish = (result: Map<string, HttpxResult> | null) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
child.on("error", (err) => {
|
||||||
|
httpxLastError = err.message.slice(0, 400);
|
||||||
|
// ENOENT (Programm fehlt) - künftig direkt auf den Fallback gehen,
|
||||||
|
// statt bei jedem Scan erneut (dann garantiert erfolglos) zu versuchen.
|
||||||
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
|
httpxConfirmedUnavailable = true;
|
||||||
|
}
|
||||||
|
finish(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdout.on("data", (chunk: Buffer) => {
|
||||||
|
const text = chunk.toString("utf-8");
|
||||||
|
stdout += text;
|
||||||
|
|
||||||
|
if (!onProgress) return;
|
||||||
|
progressParseBuffer += text;
|
||||||
|
const lines = progressParseBuffer.split("\n");
|
||||||
|
progressParseBuffer = lines.pop() ?? "";
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim()) {
|
||||||
|
confirmedCount += 1;
|
||||||
|
onProgress(confirmedCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString("utf-8");
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
httpxLastError = `Zeitüberschreitung (${Math.round(timeoutMs / 1000)}s) - httpx wurde beendet, bevor alle ${targets.length} Ziel(e) geprüft waren`;
|
||||||
|
child.kill();
|
||||||
|
finish(null);
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
// War der Lauf schon durch den Timeout oben als fehlgeschlagen
|
||||||
|
// markiert (settled=true), NICHT mehr überschreiben - sonst geht die
|
||||||
|
// konkrete "Zeitüberschreitung"-Meldung unter der generischen
|
||||||
|
// Exit-Code-Meldung unter, die "close" hier sonst noch setzen würde
|
||||||
|
// (der Kill selbst löst "close" erst danach aus).
|
||||||
|
if (settled) return;
|
||||||
|
|
||||||
|
if (code !== 0 && stdout.trim().length === 0) {
|
||||||
|
httpxLastError = (stderr.trim() || `httpx beendet mit Exit-Code ${code}`).slice(0, 400);
|
||||||
|
if (/not found|no such file/i.test(stderr)) httpxConfirmedUnavailable = true;
|
||||||
|
finish(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
httpxLastError = null; // erfolgreicher Lauf - alten Fehler nicht mehr anzeigen
|
||||||
|
|
||||||
|
const results = new Map<string, HttpxResult>();
|
||||||
|
for (const line of stdout.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(trimmed) as {
|
||||||
|
url?: string;
|
||||||
|
port?: string;
|
||||||
|
scheme?: string;
|
||||||
|
status_code?: number;
|
||||||
|
title?: string;
|
||||||
|
webserver?: string;
|
||||||
|
};
|
||||||
|
const port = Number(obj.port);
|
||||||
|
if (!Number.isFinite(port) || !obj.url) continue;
|
||||||
|
|
||||||
|
// IP aus dem "url"-Feld ziehen (immer vorhanden, z. B.
|
||||||
|
// "https://192.168.1.50:8080") statt aus ggf. je nach
|
||||||
|
// httpx-Version abweichenden Zusatzfeldern - siehe Kommentar
|
||||||
|
// oben an der Funktion.
|
||||||
|
let ip: string;
|
||||||
|
try {
|
||||||
|
ip = new URL(obj.url).hostname;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.set(`${ip}:${port}`, {
|
||||||
|
port,
|
||||||
|
scheme: obj.scheme === "https" ? "https" : "http",
|
||||||
|
statusCode: obj.status_code,
|
||||||
|
title: obj.title,
|
||||||
|
webserver: obj.webserver,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// einzelne kaputte Zeile ignorieren, Rest trotzdem verwerten
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finish(results);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Eingabe: "ip:port" pro Zeile, httpx erkennt Schema (http/https) je
|
||||||
|
// Eintrag selbst (Standardverhalten: erst https versuchen, sonst http).
|
||||||
|
for (const t of targets) {
|
||||||
|
child.stdin.write(`${t.ip}:${t.port}\n`);
|
||||||
|
}
|
||||||
|
child.stdin.end();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
httpxSemaphore.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einzelgerät-Variante von probeManyWithHttpx() - für den "Jetzt scannen"-
|
||||||
|
* Button auf der Geräteseite (ein Gerät, kein Sammel-Scan).
|
||||||
|
*/
|
||||||
|
export async function probeWithHttpx(ip: string, ports: number[]): Promise<Map<number, HttpxResult> | null> {
|
||||||
|
const result = await probeManyWithHttpx(ports.map((port) => ({ ip, port })));
|
||||||
|
if (result === null) return null;
|
||||||
|
|
||||||
|
const map = new Map<number, HttpxResult>();
|
||||||
|
for (const port of ports) {
|
||||||
|
const hit = result.get(`${ip}:${port}`);
|
||||||
|
if (hit) map.set(port, hit);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
266
apps/backend/src/scanner/iconDb.ts
Normal file
266
apps/backend/src/scanner/iconDb.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import { request } from "node:https";
|
||||||
|
|
||||||
|
const TREE_URL = "https://raw.githubusercontent.com/homarr-labs/dashboard-icons/main/tree.json";
|
||||||
|
const CDN_BASE = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png";
|
||||||
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 Tag
|
||||||
|
|
||||||
|
let cachedNames: string[] | null = null;
|
||||||
|
let cachedAt = 0;
|
||||||
|
let inFlight: Promise<string[]> | null = null;
|
||||||
|
|
||||||
|
function fetchJson(url: string): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = request(url, { timeout: 8000 }, (res) => {
|
||||||
|
if (res.statusCode && res.statusCode >= 400) {
|
||||||
|
reject(new Error(`HTTP ${res.statusCode}`));
|
||||||
|
res.resume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on("data", (c) => chunks.push(c));
|
||||||
|
res.on("end", () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on("timeout", () => req.destroy(new Error("Timeout")));
|
||||||
|
req.on("error", reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt (und cacht 24h im Speicher) die Liste aller Icon-Namen aus dem
|
||||||
|
* homarr-labs/dashboard-icons-Projekt (freie Icon-Sammlung speziell für
|
||||||
|
* Homelab-/Selfhosted-Dashboards, via jsDelivr-CDN ausgeliefert).
|
||||||
|
*
|
||||||
|
* Bewusst als einzige Stelle im Projekt, die tatsächlich einen externen
|
||||||
|
* Dienst kontaktiert (abgesehen vom eigentlichen Aufruf der Dienste selbst)
|
||||||
|
* - das Icon-Datenbank-Feature wurde ausdrücklich so gewünscht. Schlägt der
|
||||||
|
* Abruf fehl (kein Internetzugang, Dienst nicht erreichbar), liefert die
|
||||||
|
* Suche einfach keine Treffer statt einen Fehler zu werfen.
|
||||||
|
*/
|
||||||
|
async function getIconNames(): Promise<string[]> {
|
||||||
|
if (cachedNames && Date.now() - cachedAt < CACHE_TTL_MS) return cachedNames;
|
||||||
|
if (inFlight) return inFlight;
|
||||||
|
|
||||||
|
inFlight = (async () => {
|
||||||
|
try {
|
||||||
|
const tree = (await fetchJson(TREE_URL)) as { png?: string[] };
|
||||||
|
const names = (tree.png ?? []).map((f) => f.replace(/\.png$/, ""));
|
||||||
|
cachedNames = names;
|
||||||
|
cachedAt = Date.now();
|
||||||
|
return names;
|
||||||
|
} catch {
|
||||||
|
// Kein Absturz, nur keine Treffer - die restliche Favicon-Auswahl
|
||||||
|
// (bereits im System verwendete Favicons) funktioniert unabhängig davon weiter.
|
||||||
|
return cachedNames ?? [];
|
||||||
|
} finally {
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IconSearchResult {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchIconDb(query: string, limit = 24): Promise<IconSearchResult[]> {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (q.length < 2) return [];
|
||||||
|
|
||||||
|
const names = await getIconNames();
|
||||||
|
const matches = names.filter((n) => n.includes(q)).slice(0, limit);
|
||||||
|
return matches.map((name) => ({ name, url: `${CDN_BASE}/${name}.png` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begriffe, die beim Zerlegen eines Dienstnamens in Wörter komplett
|
||||||
|
* ignoriert werden - reine Infrastruktur-/Container-Präfixe, nie ein
|
||||||
|
* sinnvoller Icon-Treffer ("LXC Adguard" soll nach "Adguard" suchen, nicht
|
||||||
|
* nach "LXC").
|
||||||
|
*/
|
||||||
|
const STOPWORDS = new Set([
|
||||||
|
"lxc", "vm", "docker", "container", "srv", "ct", "instance",
|
||||||
|
"the", "der", "die", "das",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generische Kategorie-Wörter - beschreiben WAS etwas ist, nicht WELCHES
|
||||||
|
* Produkt es ist ("Cam Reolink Keller" soll primär nach "Reolink" suchen,
|
||||||
|
* nicht nach "Cam"). Werden nicht komplett verworfen (manchmal ist die
|
||||||
|
* Kategorie das einzige brauchbare Wort), aber erst als letzter Ausweg
|
||||||
|
* versucht, nachdem alle spezifischeren Wörter erfolglos blieben.
|
||||||
|
*/
|
||||||
|
const GENERIC_WORDS = new Set([
|
||||||
|
"cam", "camera", "media", "print", "printer", "bridge", "hub", "sync",
|
||||||
|
"box", "sensor", "plug", "light", "dock", "home", "server", "service",
|
||||||
|
"app", "web", "tv", "cast", "stream", "monitor", "gateway", "node",
|
||||||
|
"backup", "storage", "network", "device", "system",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function normalize(s: string): string {
|
||||||
|
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wörter in ursprünglicher Reihenfolge (für Bigramme wie "philips-hue" wichtig). */
|
||||||
|
function significantWords(name: string): string[] {
|
||||||
|
return name
|
||||||
|
.split(/[\s_-]+/)
|
||||||
|
.map((w) => normalize(w))
|
||||||
|
.filter((w) => w.length >= 3 && !STOPWORDS.has(w));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aufeinanderfolgende Wortpaare in Original-Reihenfolge, z. B. "philips hue bridge" -> ["philips-hue", "hue-bridge"]. */
|
||||||
|
function bigrams(words: string[]): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (let i = 0; i < words.length - 1; i++) out.push(`${words[i]}-${words[i + 1]}`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spezifische Wörter zuerst (längste zuerst - meist Markennamen), generische zuletzt. */
|
||||||
|
function byPriority(words: string[]): string[] {
|
||||||
|
const specific = words.filter((w) => !GENERIC_WORDS.has(w)).sort((a, b) => b.length - a.length);
|
||||||
|
const generic = words.filter((w) => GENERIC_WORDS.has(w)).sort((a, b) => b.length - a.length);
|
||||||
|
return [...specific, ...generic];
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactMatch(candidate: string, names: string[]): string | null {
|
||||||
|
if (names.includes(candidate)) return candidate;
|
||||||
|
const collapsed = candidate.replace(/-/g, "");
|
||||||
|
return names.find((n) => n.replace(/-/g, "") === collapsed) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sucht den besten Treffer in der Icon-Datenbank für einen Dienst-/
|
||||||
|
* Software-Namen ("Plex", "LXC Adguard", "Philips Hue Bridge") - für den
|
||||||
|
* Fall, dass der Scanner selbst kein Favicon gefunden hat (siehe
|
||||||
|
* networkScanner.ts). Läuft unbeaufsichtigt während eines Scans, liefert
|
||||||
|
* deshalb nur EINEN Treffer und ausschließlich über exakte (bzw. nach
|
||||||
|
* Entfernen von Bindestrichen exakte) Übereinstimmungen - keine
|
||||||
|
* Teilstring-Suche, die wäre für eine automatische, unbeaufsichtigte
|
||||||
|
* Zuordnung zu unzuverlässig.
|
||||||
|
*
|
||||||
|
* Reihenfolge: ganzer Name -> Wortpaare in Originalreihenfolge (erkennt
|
||||||
|
* "Philips Hue" als Einheit) -> einzelne Wörter, spezifische (längere,
|
||||||
|
* nicht-generische) zuerst.
|
||||||
|
*/
|
||||||
|
export async function findBestIconMatch(name: string): Promise<string | null> {
|
||||||
|
const names = await getIconNames();
|
||||||
|
|
||||||
|
const wholeName = normalize(name);
|
||||||
|
if (wholeName.length >= 2) {
|
||||||
|
const match = exactMatch(wholeName, names);
|
||||||
|
if (match) return `${CDN_BASE}/${match}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = significantWords(name);
|
||||||
|
|
||||||
|
for (const pair of bigrams(words)) {
|
||||||
|
const match = exactMatch(pair, names);
|
||||||
|
if (match) return `${CDN_BASE}/${match}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const word of byPriority(words)) {
|
||||||
|
const match = exactMatch(word, names);
|
||||||
|
if (match) return `${CDN_BASE}/${match}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letzter, bewusst noch immer präziser Versuch: ein Name wie "adguard"
|
||||||
|
// trifft "adguard-home" nicht exakt (auch nicht nach Bindestrich-Vergleich),
|
||||||
|
// ist aber ein eindeutiger Wortanfang-Treffer, wenn KEIN anderer Icon-Name
|
||||||
|
// mit "<wort>-" beginnt. Nur dann automatisch übernehmen - bei mehreren
|
||||||
|
// möglichen Treffern bleibt es bei "kein Favicon" (dafür gibt es die
|
||||||
|
// interaktive Auswahl mit mehreren Kandidaten).
|
||||||
|
for (const candidate of [wholeName, ...byPriority(words)]) {
|
||||||
|
if (candidate.length < 3) continue;
|
||||||
|
const prefixMatches = names.filter((n) => n === candidate || n.startsWith(`${candidate}-`));
|
||||||
|
if (prefixMatches.length === 1) return `${CDN_BASE}/${prefixMatches[0]}.png`;
|
||||||
|
if (prefixMatches.length > 1) {
|
||||||
|
// Mehrere Treffer (z. B. "adguard-home" UND "adguard-home-sync" für
|
||||||
|
// "adguard") - den kürzesten (meist der "Haupt"-Name der Software)
|
||||||
|
// automatisch nehmen, aber NUR wenn er eindeutig der kürzeste ist,
|
||||||
|
// sonst bleibt es zweideutig (dafür gibt es die interaktive Auswahl).
|
||||||
|
const sorted = [...prefixMatches].sort((a, b) => a.length - b.length);
|
||||||
|
if (sorted[0].length < sorted[1].length) return `${CDN_BASE}/${sorted[0]}.png`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie findBestIconMatch, liefert aber ALLE plausiblen Kandidaten statt nur
|
||||||
|
* den ersten - für die interaktive "Fehlende Favicons ergänzen"-Übersicht,
|
||||||
|
* wo der Mensch selbst auswählt, welcher Treffer stimmt.
|
||||||
|
*
|
||||||
|
* Reihenfolge: erst alle exakten Treffer (ganzer Name, Wortpaare, einzelne
|
||||||
|
* Wörter nach Priorität), DANACH erst - falls noch Platz bis zum Limit ist -
|
||||||
|
* Teilstring-Treffer, wieder erst über spezifische Wörter. Generische
|
||||||
|
* Kategorie-Wörter (siehe GENERIC_WORDS) werden für die Teilstring-Suche nur
|
||||||
|
* herangezogen, wenn die spezifischen Wörter GAR NICHTS geliefert haben -
|
||||||
|
* genau das war das gemeldete Problem ("Cam Blink Sync" fand nur
|
||||||
|
* Kamera-Zubehör über "Cam" statt "Blink").
|
||||||
|
*/
|
||||||
|
export async function suggestIconsForServiceName(name: string, limit = 8): Promise<IconSearchResult[]> {
|
||||||
|
const names = await getIconNames();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const results: IconSearchResult[] = [];
|
||||||
|
|
||||||
|
function addIfNew(iconName: string) {
|
||||||
|
if (seen.has(iconName) || results.length >= limit) return;
|
||||||
|
seen.add(iconName);
|
||||||
|
results.push({ name: iconName, url: `${CDN_BASE}/${iconName}.png` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = significantWords(name);
|
||||||
|
const specificWords = byPriority(words).filter((w) => !GENERIC_WORDS.has(w));
|
||||||
|
const genericWords = byPriority(words).filter((w) => GENERIC_WORDS.has(w));
|
||||||
|
|
||||||
|
const wholeName = normalize(name);
|
||||||
|
if (wholeName.length >= 2) {
|
||||||
|
const exact = exactMatch(wholeName, names);
|
||||||
|
if (exact) addIfNew(exact);
|
||||||
|
}
|
||||||
|
for (const pair of bigrams(words)) {
|
||||||
|
const exact = exactMatch(pair, names);
|
||||||
|
if (exact) addIfNew(exact);
|
||||||
|
}
|
||||||
|
for (const word of specificWords) {
|
||||||
|
const exact = exactMatch(word, names);
|
||||||
|
if (exact) addIfNew(exact);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teilstring-Suche nur über spezifische Wörter, solange noch Platz ist.
|
||||||
|
for (const word of specificWords) {
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
for (const n of names) {
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
if (n.includes(word)) addIfNew(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generische Wörter (z. B. "cam", "media") nur als allerletzter Ausweg,
|
||||||
|
// wenn spezifische Wörter überhaupt nichts geliefert haben.
|
||||||
|
if (results.length === 0) {
|
||||||
|
for (const word of genericWords) {
|
||||||
|
const exact = exactMatch(word, names);
|
||||||
|
if (exact) addIfNew(exact);
|
||||||
|
}
|
||||||
|
for (const word of genericWords) {
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
for (const n of names) {
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
if (n.includes(word)) addIfNew(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
61
apps/backend/src/scanner/macVendors.ts
Normal file
61
apps/backend/src/scanner/macVendors.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Ordnet die ersten drei Oktette einer MAC-Adresse (OUI-Präfix) einem
|
||||||
|
* Hersteller zu. Bewusst eine KLEINE, aber jeweils einzeln online
|
||||||
|
* verifizierte Auswahl (nicht aus dem Gedächtnis geraten) - NICHT die
|
||||||
|
* vollständige IEEE-OUI-Registrierung (die hat >50.000 Einträge). Komplett
|
||||||
|
* offline zur Laufzeit, kein externer Dienst.
|
||||||
|
*
|
||||||
|
* Deckt bewusst die im Homelab-Kontext häufigsten "Kern"-Geräte ab: Single-
|
||||||
|
* Board-Computer, NAS-Systeme und Virtualisierung. Router/Switches/IoT
|
||||||
|
* wurden nicht aufgenommen, weil sich die exakten Präfixe nicht ohne
|
||||||
|
* weitere ausführliche Recherche verlässlich verifizieren ließen - lieber
|
||||||
|
* gar keinen Treffer als einen falschen.
|
||||||
|
*
|
||||||
|
* Unbekannte Präfixe liefern null, das Feld bleibt dann leer und frei
|
||||||
|
* editierbar. Bereits gesetzte Werte werden nie überschrieben (siehe
|
||||||
|
* Aufrufer in devices.ts).
|
||||||
|
*/
|
||||||
|
const OUI_VENDORS: Record<string, string> = {
|
||||||
|
// Raspberry Pi Foundation / Raspberry Pi Trading Ltd
|
||||||
|
// Quelle: forums.raspberrypi.com/viewtopic.php?t=347926, maclookup.app/macaddress/b827eb
|
||||||
|
"28CDC1": "Raspberry Pi",
|
||||||
|
B827EB: "Raspberry Pi",
|
||||||
|
D83ADD: "Raspberry Pi",
|
||||||
|
DCA632: "Raspberry Pi",
|
||||||
|
E45F01: "Raspberry Pi",
|
||||||
|
|
||||||
|
// VMware (virtuelle Netzwerkkarten)
|
||||||
|
// Quelle: maclookup.app/macaddress/005056, maclookup.app/macaddress/000c29
|
||||||
|
"005056": "VMware (VM)",
|
||||||
|
"000C29": "VMware (VM)",
|
||||||
|
|
||||||
|
// Synology
|
||||||
|
// Quelle: maclookup.app/vendors/synology-incorporated
|
||||||
|
"001132": "Synology",
|
||||||
|
"9009D0": "Synology",
|
||||||
|
|
||||||
|
// QNAP
|
||||||
|
// Quelle: maclookup.app/vendors/qnap-systems-inc
|
||||||
|
"245EBE": "QNAP",
|
||||||
|
E843B6: "QNAP",
|
||||||
|
|
||||||
|
// QEMU/KVM (Standard-Präfix für automatisch generierte VM-MACs,
|
||||||
|
// u. a. von Proxmox VE verwendet) - allgemein bekannte Konvention.
|
||||||
|
"525400": "QEMU/Proxmox (VM)",
|
||||||
|
|
||||||
|
// Docker (docker0-Bridge-Netzwerk) - Software-Konvention, kein
|
||||||
|
// IEEE-registriertes OUI, aber in der Praxis sehr zuverlässig.
|
||||||
|
"0242AC": "Docker (virtuell)",
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeOui(mac: string): string | null {
|
||||||
|
const cleaned = mac.replace(/[^0-9A-Fa-f]/g, "").toUpperCase();
|
||||||
|
if (cleaned.length < 6) return null;
|
||||||
|
return cleaned.slice(0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lookupVendorFromMac(mac: string): string | null {
|
||||||
|
const oui = normalizeOui(mac);
|
||||||
|
if (!oui) return null;
|
||||||
|
return OUI_VENDORS[oui] ?? null;
|
||||||
|
}
|
||||||
274
apps/backend/src/scanner/masscan.ts
Normal file
274
apps/backend/src/scanner/masscan.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
let masscanConfirmedUnavailable = false;
|
||||||
|
|
||||||
|
// Klartext-Grund des letzten Fehlschlags - wird im Scan-Log angezeigt, damit
|
||||||
|
// man sieht WARUM masscan nicht greift, statt nur "nicht genutzt/verfügbar"
|
||||||
|
// ohne jede Erklärung.
|
||||||
|
let masscanLastError: string | null = null;
|
||||||
|
|
||||||
|
export function getMasscanLastError(): string | null {
|
||||||
|
return masscanLastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einfacher Zähl-Semaphore mit Kapazität 1. Seit round49 lief HIERÜBER noch
|
||||||
|
* pro GERÄT ein eigener masscan-Prozess - bei bis zu 10 parallel gescannten
|
||||||
|
* Geräten (siehe routes/scan.ts CONCURRENT_DEVICES) wurden die vollständigen
|
||||||
|
* Portscans dadurch faktisch serialisiert und der Gesamtscan massiv
|
||||||
|
* verlangsamt (siehe Bugreport). Seit round50 gibt es dafür
|
||||||
|
* scanHostsWithMasscan() weiter unten: EIN masscan-Prozess für ALLE Geräte
|
||||||
|
* eines Scan-Laufs gleichzeitig (masscan kann das nativ, siehe
|
||||||
|
* Mehrfach-Ziele als Argumente). Der Semaphore hier schützt nur noch den
|
||||||
|
* seltenen Fall, dass zwei UNABHÄNGIGE Scan-Jobs (z. B. Sammel-Scan +
|
||||||
|
* gleichzeitig ein manueller Einzelgeräte-Scan per Button) zufällig
|
||||||
|
* überlappen.
|
||||||
|
*/
|
||||||
|
class Semaphore {
|
||||||
|
private available: number;
|
||||||
|
private readonly queue: (() => void)[] = [];
|
||||||
|
|
||||||
|
constructor(count: number) {
|
||||||
|
this.available = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
acquire(): Promise<void> {
|
||||||
|
if (this.available > 0) {
|
||||||
|
this.available--;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => this.queue.push(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
release(): void {
|
||||||
|
const next = this.queue.shift();
|
||||||
|
if (next) next();
|
||||||
|
else this.available++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const masscanSemaphore = new Semaphore(1);
|
||||||
|
|
||||||
|
// Rate war bisher 2000 Pakete/Sekunde - ein sehr konservativer, für
|
||||||
|
// Internet-weite Scans gedachter Wert. Auf einem lokalen Heimnetz verkraftet
|
||||||
|
// masscan problemlos deutlich mehr, gerade jetzt wo nur noch EIN Prozess
|
||||||
|
// gleichzeitig läuft (siehe Semaphore oben). 20.000 pps ist für
|
||||||
|
// masscan-Verhältnisse immer noch sehr moderat.
|
||||||
|
const DEFAULT_RATE = 20_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zählt die Ports in einem masscan-Portbereich ("1-65535", "80,443,8000-8100"
|
||||||
|
* usw.) - für die Timeout-Schätzung unten. Fällt auf 65535 zurück, falls das
|
||||||
|
* Format nicht erkannt wird (sichere Obergrenze, lieber zu großzügig als zu
|
||||||
|
* knapp beim Timeout).
|
||||||
|
*/
|
||||||
|
function countPortsInRange(portRange: string): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const part of portRange.split(",")) {
|
||||||
|
const [a, b] = part.split("-").map(Number);
|
||||||
|
if (Number.isFinite(a) && Number.isFinite(b)) count += Math.max(0, b - a + 1);
|
||||||
|
else if (Number.isFinite(a)) count += 1;
|
||||||
|
}
|
||||||
|
return count > 0 ? count : 65535;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schätzt einen angemessenen Timeout für einen masscan-Lauf über mehrere
|
||||||
|
* Hosts: Sendezeit (Gesamtzahl Probes / Rate) plus großzügiger
|
||||||
|
* Sicherheitsfaktor (x2) plus masscans eigene Nachlaufzeit für spät
|
||||||
|
* eintreffende Antworten (~15s), gedeckelt auf sinnvolle Grenzen (60s..15min).
|
||||||
|
*/
|
||||||
|
function computeTimeoutMs(hostCount: number, portRange: string, rate: number): number {
|
||||||
|
const totalProbes = hostCount * countPortsInRange(portRange);
|
||||||
|
const estimatedSeconds = totalProbes / rate;
|
||||||
|
const withMargin = estimatedSeconds * 2 + 15;
|
||||||
|
// Math.round() ist Pflicht: eine Kommazahl hier führte zuvor zu "The value
|
||||||
|
// of "timeout" is out of range" (siehe Bugreport round53).
|
||||||
|
return Math.round(Math.min(Math.max(withMargin * 1000, 60_000), 15 * 60_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MasscanProgress {
|
||||||
|
/** Prozent (0-100) laut masscans eigener Fortschrittsanzeige. */
|
||||||
|
percent: number;
|
||||||
|
/** Bisher gefundene offene Ports, geräteübergreifend. */
|
||||||
|
foundSoFar: number;
|
||||||
|
/** Von masscan geschätzte verbleibende Sekunden, falls bekannt. */
|
||||||
|
etaSeconds: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// masscan gibt während des Laufs LAUFEND eine Statuszeile auf stderr aus
|
||||||
|
// (standardmäßig per \r überschrieben, für ein Terminal gedacht - beim
|
||||||
|
// Auslesen per Kindprozess kommen die \r trotzdem an), z. B.:
|
||||||
|
// "rate: 1234.56-kpps, 45.23% done, 0:00:12 remaining, found=7"
|
||||||
|
// oder kurz vor Schluss (fertig gesendet, wartet noch auf späte Antworten):
|
||||||
|
// "rate: 0.00-kpps, 100.00% done, waiting 5-secs, found=7"
|
||||||
|
// Diese Zeile wird unten live geparst, um echten Fortschritt (Prozent,
|
||||||
|
// geschätzte Restzeit, bisherige Treffer) nach außen zu geben - vorher
|
||||||
|
// wusste man während eines laufenden Sammel-Scans nur "masscan läuft…" ohne
|
||||||
|
// jede weitere Information (siehe Bugreport).
|
||||||
|
const MASSCAN_PROGRESS_RE =
|
||||||
|
/rate:\s*([\d.]+)-kpps,\s*([\d.]+)%\s*done,\s*(?:(\d+):(\d+):(\d+)\s*remaining|waiting\s*(-?\d+)-secs),\s*found=(\d+)/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nutzt masscan (falls im Container installiert, siehe Dockerfile) für den
|
||||||
|
* vollständigen Portscan (1-65535) EINER GANZEN GRUPPE von Hosts in einem
|
||||||
|
* einzigen Prozesslauf - masscan unterstützt mehrere Ziel-IPs nativ als
|
||||||
|
* Argumente und verschickt SYN-Pakete asynchron per Rohsocket, ohne auf jede
|
||||||
|
* einzelne TCP-Verbindung zu warten.
|
||||||
|
*
|
||||||
|
* Fällt automatisch auf null zurück, wenn masscan fehlt, keine Rechte hat
|
||||||
|
* (braucht CAP_NET_RAW, siehe docker-compose.yml) oder aus anderem Grund
|
||||||
|
* fehlschlägt - der Aufrufer nutzt dann stattdessen scanPortsInBatches PRO
|
||||||
|
* GERÄT. Der genaue Grund eines Fehlschlags steht danach in
|
||||||
|
* getMasscanLastError().
|
||||||
|
*
|
||||||
|
* @param onProgress Optionaler Callback, der bei JEDER von masscan
|
||||||
|
* ausgegebenen Statuszeile mit dem aktuellen Fortschritt aufgerufen wird
|
||||||
|
* (siehe MasscanProgress) - für eine Live-Anzeige im Sammel-Scan-Job.
|
||||||
|
*/
|
||||||
|
export async function scanHostsWithMasscan(
|
||||||
|
hosts: string[],
|
||||||
|
portRange: string,
|
||||||
|
rate: number = DEFAULT_RATE,
|
||||||
|
onProgress?: (progress: MasscanProgress) => void
|
||||||
|
): Promise<Map<string, number[]> | null> {
|
||||||
|
if (masscanConfirmedUnavailable || hosts.length === 0) {
|
||||||
|
return hosts.length === 0 ? new Map() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await masscanSemaphore.acquire();
|
||||||
|
try {
|
||||||
|
const timeoutMs = computeTimeoutMs(hosts.length, portRange, rate);
|
||||||
|
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
const child = spawn("masscan", [...hosts, "-p", portRange, "--rate", String(rate), "-oL", "-"], {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
// Nur die letzten ~2000 Zeichen von stderr vorhalten (für die
|
||||||
|
// Fehlermeldung bei einem Abbruch) - masscan schreibt bei langen
|
||||||
|
// Läufen laufend Statuszeilen, eine unbegrenzte Sammlung würde bei
|
||||||
|
// vielen Minuten Laufzeit unnötig Speicher aufbauen.
|
||||||
|
let stderrTail = "";
|
||||||
|
let stderrParseBuffer = "";
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const finish = (result: Map<string, number[]> | null) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
child.on("error", (err) => {
|
||||||
|
masscanLastError = err.message.slice(0, 400);
|
||||||
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
|
masscanConfirmedUnavailable = true;
|
||||||
|
}
|
||||||
|
finish(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdout.on("data", (chunk: Buffer) => {
|
||||||
|
// Defensive Obergrenze - siehe frühere maxBuffer-Absicherung bei
|
||||||
|
// execFile, hier von Hand nachgebildet, da spawn() das nicht kennt.
|
||||||
|
if (stdout.length < 64 * 1024 * 1024) {
|
||||||
|
stdout += chunk.toString("utf-8");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
const text = chunk.toString("utf-8");
|
||||||
|
stderrTail = (stderrTail + text).slice(-2000);
|
||||||
|
|
||||||
|
if (!onProgress) return;
|
||||||
|
// masscan trennt Status-Updates per \r (Terminal-Konvention) statt
|
||||||
|
// \n - beide als Trenner behandeln. Der letzte, noch unvollständige
|
||||||
|
// Rest bleibt im Puffer bis zum nächsten Chunk.
|
||||||
|
stderrParseBuffer += text;
|
||||||
|
const parts = stderrParseBuffer.split(/[\r\n]+/);
|
||||||
|
stderrParseBuffer = parts.pop() ?? "";
|
||||||
|
for (const line of parts) {
|
||||||
|
const match = line.match(MASSCAN_PROGRESS_RE);
|
||||||
|
if (!match) continue;
|
||||||
|
const percent = Number(match[2]);
|
||||||
|
const foundSoFar = Number(match[7]);
|
||||||
|
let etaSeconds: number | null = null;
|
||||||
|
if (match[3] !== undefined) {
|
||||||
|
etaSeconds = Number(match[3]) * 3600 + Number(match[4]) * 60 + Number(match[5]);
|
||||||
|
} else if (match[6] !== undefined) {
|
||||||
|
etaSeconds = Math.max(0, Number(match[6]));
|
||||||
|
}
|
||||||
|
if (Number.isFinite(percent)) onProgress({ percent, foundSoFar, etaSeconds });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
masscanLastError = `Zeitüberschreitung nach ${Math.round(timeoutMs / 1000)}s (${hosts.length} Host(s), Portbereich ${portRange}) - masscan wurde beendet, bevor der Scan fertig war. Letzter Stand vor Abbruch: "${stderrTail.trim() || "(keine Ausgabe)"}"`;
|
||||||
|
child.kill();
|
||||||
|
finish(null);
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (settled) return; // schon per Timeout oben abgeschlossen
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
const exitCode = code ?? "unbekannt";
|
||||||
|
masscanLastError = `Exit-Code ${exitCode}: ${stderrTail.trim() || "(keine Fehlerausgabe)"}`.slice(0, 400);
|
||||||
|
|
||||||
|
const msg = stderrTail.toLowerCase();
|
||||||
|
if (
|
||||||
|
msg.includes("permission") ||
|
||||||
|
msg.includes("operation not permitted") ||
|
||||||
|
msg.includes("pcap")
|
||||||
|
) {
|
||||||
|
masscanConfirmedUnavailable = true;
|
||||||
|
}
|
||||||
|
finish(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "-oL -" (List-Format) gibt pro offenem Port eine Zeile aus:
|
||||||
|
// "open tcp 80 192.168.1.50 1700000000" - die IP-Spalte (Index 3)
|
||||||
|
// ist bei mehreren Ziel-Hosts der Schlüssel, über den die Treffer
|
||||||
|
// den einzelnen Geräten zugeordnet werden.
|
||||||
|
const portsByHost = new Map<string, number[]>();
|
||||||
|
for (const line of stdout.split("\n")) {
|
||||||
|
const parts = line.trim().split(/\s+/);
|
||||||
|
if (parts[0] === "open" && parts[1] === "tcp") {
|
||||||
|
const port = Number(parts[2]);
|
||||||
|
const ip = parts[3];
|
||||||
|
if (Number.isFinite(port) && ip) {
|
||||||
|
const list = portsByHost.get(ip);
|
||||||
|
if (list) list.push(port);
|
||||||
|
else portsByHost.set(ip, [port]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
masscanLastError = null; // erfolgreicher Lauf - alten Fehler nicht mehr anzeigen
|
||||||
|
finish(portsByHost);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
masscanSemaphore.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einzelgerät-Variante von scanHostsWithMasscan() - für den "Jetzt scannen"-
|
||||||
|
* Button auf der Geräteseite (ein Gerät, kein Sammel-Scan).
|
||||||
|
*/
|
||||||
|
export async function scanWithMasscan(
|
||||||
|
host: string,
|
||||||
|
portRange: string,
|
||||||
|
rate: number = DEFAULT_RATE
|
||||||
|
): Promise<number[] | null> {
|
||||||
|
const result = await scanHostsWithMasscan([host], portRange, rate);
|
||||||
|
if (result === null) return null;
|
||||||
|
return result.get(host) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMasscanConfirmedUnavailable(): boolean {
|
||||||
|
return masscanConfirmedUnavailable;
|
||||||
|
}
|
||||||
424
apps/backend/src/scanner/networkScanner.ts
Normal file
424
apps/backend/src/scanner/networkScanner.ts
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
import { resolveHostname, reverseLookup } from "./dns.js";
|
||||||
|
import { isPortOpen, scanPortsInBatches, fullPortRange, TYPICAL_PORTS } from "./ports.js";
|
||||||
|
import { scanWithMasscan, getMasscanLastError } from "./masscan.js";
|
||||||
|
import { probeWithHttpx, getHttpxLastError, type HttpxResult } from "./httpx.js";
|
||||||
|
import { probeHttp } from "./http.js";
|
||||||
|
import { detectSoftware } from "./softwareDetection.js";
|
||||||
|
import { findBestIconMatch } from "./iconDb.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;
|
||||||
|
icon?: string;
|
||||||
|
/** Startwert für neu angelegte Dienste: false bei Fehlerseiten/generischen Ladetiteln. */
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt generische/nichtssagende Titel, die keine sinnvolle Dienst-
|
||||||
|
* Bezeichnung abgeben: HTTP-Statuszeilen ("404 Not Found", "302 Found") und
|
||||||
|
* Lade-/Weiterleitungsplatzhalter (der Scanner führt kein JavaScript aus,
|
||||||
|
* sieht bei manchen Web-UIs also nur den Ladebildschirm statt des echten Titels).
|
||||||
|
*/
|
||||||
|
function isLikelyJunkTitle(title: string | undefined): boolean {
|
||||||
|
if (!title) return true;
|
||||||
|
const trimmed = title.trim();
|
||||||
|
if (trimmed.length === 0) return true;
|
||||||
|
if (/^\d{3}\b/.test(trimmed)) return true; // "404 Not Found", "400 ...", "302 Found"
|
||||||
|
if (/^(loading|lädt|please wait|redirecting|weiterleitung)/i.test(trimmed)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prüft, ob ein String eine reine IPv4-Adresse ist (nicht als "Name" brauchbar). */
|
||||||
|
function isIpAddress(value: string): boolean {
|
||||||
|
return /^\d{1,3}(\.\d{1,3}){3}$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Macht aus einem technischen Hostnamen ("plug-shelly-heiz-links",
|
||||||
|
* "nas_synology-01") einen lesbaren Anzeigenamen ("Plug Shelly Heiz Links").
|
||||||
|
* Liefert null, wenn der Hostname selbst keine brauchbare Information trägt
|
||||||
|
* (leer oder reine IP) - dann bleibt nur der IP:Port-Fallback übrig.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Generische Infrastruktur-Präfixe/-Suffixe, die in Proxmox/Docker-Umfeld
|
||||||
|
* üblich, aber inhaltlich bedeutungslos sind ("lxc-adguard" soll als
|
||||||
|
* "Adguard" vorgeschlagen werden, nicht als "Lxc Adguard") - dieselbe Logik
|
||||||
|
* wie die STOPWORDS in scanner/iconDb.ts, hier auf den Hostnamen angewendet.
|
||||||
|
*/
|
||||||
|
const GENERIC_INFRA_WORDS = new Set(["lxc", "vm", "ct", "docker", "container"]);
|
||||||
|
|
||||||
|
function humanizeHostname(hostname: string): string | null {
|
||||||
|
if (!hostname || isIpAddress(hostname)) return null;
|
||||||
|
const words = hostname
|
||||||
|
.replace(/\.(home|local|fritz\.box)$/i, "")
|
||||||
|
.split(/[-_.\s]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((w) => !GENERIC_INFRA_WORDS.has(w.toLowerCase()));
|
||||||
|
if (words.length === 0) return null;
|
||||||
|
return words.map((w) => (w.length > 0 ? w[0].toUpperCase() + w.slice(1) : w)).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceScanResult {
|
||||||
|
services: DiscoveredService[];
|
||||||
|
/**
|
||||||
|
* Vorschlag für einen besseren Gerätenamen per Reverse-DNS (PTR), falls die
|
||||||
|
* normale Vorwärtsauflösung nichts Besseres als die reine IP gefunden hat.
|
||||||
|
* Wird NICHT automatisch übernommen - nur zur manuellen Bestätigung
|
||||||
|
* zurückgegeben (siehe routes/scan.ts).
|
||||||
|
*/
|
||||||
|
suggestedHostname: string | null;
|
||||||
|
/**
|
||||||
|
* Welche optionalen Beschleuniger-Werkzeuge bei diesem Scan tatsächlich
|
||||||
|
* genutzt wurden (siehe scanner/masscan.ts, scanner/httpx.ts) - landet im
|
||||||
|
* Scan-Log (Admin -> Logs), damit sichtbar ist, ob z. B. masscan wirklich
|
||||||
|
* gegriffen hat oder (mangels Installation/Rechten) auf die eingebaute
|
||||||
|
* Methode zurückgefallen wurde.
|
||||||
|
*/
|
||||||
|
toolsUsed: {
|
||||||
|
masscan: boolean;
|
||||||
|
/** Ob masscan bei diesem Scan überhaupt versucht wurde (nur beim
|
||||||
|
* vollständigen Portscan, siehe unten) - bei "false" ist "masscan:
|
||||||
|
* nicht genutzt" also KEIN Fehler, sondern schlicht nicht relevant. */
|
||||||
|
masscanAttempted: boolean;
|
||||||
|
/** Klartext-Fehlermeldung, falls masscan versucht wurde und fehlschlug. */
|
||||||
|
masscanError: string | null;
|
||||||
|
httpx: boolean;
|
||||||
|
/** Klartext-Fehlermeldung, falls httpx fehlschlug. */
|
||||||
|
httpxError: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorab (außerhalb dieser Funktion) ermittelte Scan-Daten für EIN Gerät -
|
||||||
|
* genutzt vom Sammel-Scan (siehe routes/scan.ts), der masscan/httpx nicht
|
||||||
|
* mehr pro Gerät einzeln aufruft, sondern EINMAL für alle Geräte des
|
||||||
|
* Scan-Laufs gemeinsam (siehe scanHostsWithMasscan/probeManyWithHttpx) und
|
||||||
|
* das Ergebnis dann pro Gerät hier einspeist. Bleibt dieser Parameter ganz
|
||||||
|
* weg (Einzelgeräte-Scan über den "Jetzt scannen"-Button), verhält sich die
|
||||||
|
* Funktion wie zuvor und macht die masscan-/httpx-Aufrufe selbst.
|
||||||
|
*/
|
||||||
|
export interface PreFetchedScanData {
|
||||||
|
masscanAttempted: boolean;
|
||||||
|
/** undefined = masscan wurde für dieses Gerät nicht vorab versucht (dann selbst versuchen). */
|
||||||
|
masscanPorts?: number[];
|
||||||
|
masscanError: string | null;
|
||||||
|
/** undefined = httpx wurde für dieses Gerät nicht vorab versucht (dann selbst versuchen). */
|
||||||
|
httpxResults?: Map<number, HttpxResult> | null;
|
||||||
|
httpxError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
priorityPorts: number[] = [],
|
||||||
|
preFetched?: PreFetchedScanData
|
||||||
|
): Promise<DeviceScanResult> {
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Nur wenn die Vorwärtsauflösung nichts Besseres als die IP gefunden hat,
|
||||||
|
// zusätzlich per Reverse-DNS (PTR) versuchen - viele Router/DHCP-Server
|
||||||
|
// (FritzBox, Pi-hole, AdGuard Home, ...) tragen hier den vom Gerät
|
||||||
|
// gemeldeten Namen ein.
|
||||||
|
const suggestedHostname = dnsResult ? null : await reverseLookup(device.ip);
|
||||||
|
|
||||||
|
const candidatePorts = Array.from(new Set([80, 443, ...extraPorts, ...priorityPorts]));
|
||||||
|
|
||||||
|
// Batches statt alles auf einmal parallel - bei der "typischen Ports"-Liste
|
||||||
|
// (~30 Ports) macht das kaum einen Unterschied, ist aber zwingend nötig für
|
||||||
|
// den optionalen VOLLSTÄNDIGEN Portscan (1-65535, siehe Admin -> Geräte),
|
||||||
|
// wo alle Ports gleichzeitig öffnen würde.
|
||||||
|
// Bei einer großen Portliste (voller Scan, 1-65535) lohnt sich masscan,
|
||||||
|
// falls installiert (siehe scanner/masscan.ts + Dockerfile) - deutlich
|
||||||
|
// schneller als das eingebaute Batch-Scanning. Bei der kurzen "üblichen
|
||||||
|
// Ports"-Liste (~30 Ports) macht masscan kaum einen Unterschied, das
|
||||||
|
// eingebaute Scanning reicht dafür völlig.
|
||||||
|
let usedMasscan = false;
|
||||||
|
let masscanError: string | null = null;
|
||||||
|
let masscanAttempted: boolean;
|
||||||
|
let openPorts: number[];
|
||||||
|
if (preFetched) {
|
||||||
|
// Sammel-Scan: masscan wurde bereits VORAB für alle Geräte gemeinsam
|
||||||
|
// versucht (siehe routes/scan.ts) - hier nur noch das Ergebnis für DIESES
|
||||||
|
// Gerät übernehmen, kein eigener masscan-Aufruf mehr.
|
||||||
|
masscanAttempted = preFetched.masscanAttempted;
|
||||||
|
if (preFetched.masscanPorts) {
|
||||||
|
usedMasscan = true;
|
||||||
|
openPorts = preFetched.masscanPorts;
|
||||||
|
} else {
|
||||||
|
masscanError = preFetched.masscanError;
|
||||||
|
openPorts = await scanPortsInBatches(device.ip, candidatePorts);
|
||||||
|
}
|
||||||
|
} else if (candidatePorts.length > 1000) {
|
||||||
|
masscanAttempted = true;
|
||||||
|
const masscanResult = await scanWithMasscan(device.ip, "1-65535");
|
||||||
|
usedMasscan = masscanResult !== null;
|
||||||
|
if (!usedMasscan) masscanError = getMasscanLastError();
|
||||||
|
openPorts = masscanResult ?? (await scanPortsInBatches(device.ip, candidatePorts));
|
||||||
|
} else {
|
||||||
|
masscanAttempted = false;
|
||||||
|
openPorts = await scanPortsInBatches(device.ip, candidatePorts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bereits bekannte Dienst-Ports (priorityPorts, vom Aufrufer übergeben -
|
||||||
|
// siehe scan.ts), die im ersten, stark parallelen Durchlauf NICHT als
|
||||||
|
// offen erkannt wurden, bekommen einen zweiten, einzelnen Versuch mit
|
||||||
|
// etwas mehr Zeit. Grund: ~20 gleichzeitige Verbindungsversuche können
|
||||||
|
// schwächere Geräte (z. B. Echo-Lautsprecher, Smart-TVs) kurzzeitig
|
||||||
|
// überfordern und dabei ausgerechnet den einen tatsächlich offenen Port
|
||||||
|
// verpassen - das führte zu falschen "nicht mehr gefunden"-Meldungen,
|
||||||
|
// obwohl sich am Gerät nichts geändert hatte.
|
||||||
|
// Bereits bekannte Dienst-Ports (priorityPorts) UND immer 80/443 (die
|
||||||
|
// wichtigsten, am meisten erwarteten Web-Ports überhaupt) bekommen einen
|
||||||
|
// zweiten, einzelnen Versuch mit mehr Zeit, falls der erste, stark
|
||||||
|
// parallele Durchlauf sie verpasst hat. Grund: viele gleichzeitige
|
||||||
|
// Verbindungsversuche können schwächere/per WLAN angebundene Geräte (z. B.
|
||||||
|
// ein FritzOS-Mesh-Repeater, Echo-Lautsprecher, Smart-TVs) kurzzeitig
|
||||||
|
// überfordern oder brauchen einfach etwas länger als der knappe
|
||||||
|
// Batch-Timeout - das führte sonst zu falschen "nicht gefunden"-Meldungen
|
||||||
|
// ausgerechnet bei 80/443, obwohl der Dienst da ist.
|
||||||
|
const mustRetryPorts = Array.from(new Set([...priorityPorts, 80, 443]));
|
||||||
|
const missedPriorityPorts = mustRetryPorts.filter((p) => !openPorts.includes(p));
|
||||||
|
const retryResults = await Promise.all(
|
||||||
|
missedPriorityPorts.map(async (port) => ({ port, open: await isPortOpen(device.ip, port, 1500) }))
|
||||||
|
);
|
||||||
|
for (const r of retryResults) if (r.open) openPorts.push(r.port);
|
||||||
|
|
||||||
|
// AVM-/FritzOS-Geräte (Router UND Repeater) betreiben auf den Ports 49000
|
||||||
|
// (HTTP) und 49443 (HTTPS) ihre TR-064-SOAP-Schnittstelle - dieselbe, die
|
||||||
|
// der FritzBox-Scanner selbst benutzt (siehe scanner/fritzbox.ts). Das ist
|
||||||
|
// eine programmatische Steuerschnittstelle, KEINE für Menschen gedachte
|
||||||
|
// Weboberfläche, antwortet aber trotzdem auf einen einfachen HTTP-Request
|
||||||
|
// und würde sonst als (nutzloser) Dienst auftauchen. Wird deshalb komplett
|
||||||
|
// übersprungen - außer der Nutzer hat dort bereits bewusst einen Dienst
|
||||||
|
// behalten (dann nicht rückwirkend entfernen).
|
||||||
|
const AVM_MANAGEMENT_PORTS = new Set([49000, 49443]);
|
||||||
|
openPorts = openPorts.filter(
|
||||||
|
(p) => !AVM_MANAGEMENT_PORTS.has(p) || priorityPorts.includes(p)
|
||||||
|
);
|
||||||
|
|
||||||
|
// httpx (falls installiert, siehe Dockerfile) prüft die offenen Ports
|
||||||
|
// vorab in einem einzigen, hochparallelen Durchlauf und liefert für die
|
||||||
|
// tatsächlich HTTP/HTTPS-sprechenden gleich Schema/Titel/Server-Header
|
||||||
|
// mit - das spart probePort() unten den sonst nötigen Protokoll-Rate-
|
||||||
|
// Versuch (erst HTTP, bei Fehlschlag HTTPS) für JEDEN Port einzeln.
|
||||||
|
// httpxResults === null heißt "httpx nicht verfügbar" - dann bleibt es
|
||||||
|
// beim bisherigen Verhalten (jeder offene Port wird von probePort()
|
||||||
|
// geprüft, Protokoll wird pro Port erraten). Ein von httpx NICHT
|
||||||
|
// bestätigter Port wird (seit der Regression mit stillschweigend
|
||||||
|
// verschwundenen Diensten unter Last) trotzdem an probePort() weiter-
|
||||||
|
// gereicht statt verworfen - siehe Kommentar unten.
|
||||||
|
//
|
||||||
|
// Beim Sammel-Scan (preFetched gesetzt) wurde httpx bereits VORAB für alle
|
||||||
|
// Geräte gemeinsam aufgerufen (siehe routes/scan.ts) - hier nur noch das
|
||||||
|
// Ergebnis für DIESES Gerät übernehmen. Ports, die erst NACH dem
|
||||||
|
// vorab-Aufruf dazugekommen sind (z. B. durch den Prioritäts-Retry oben),
|
||||||
|
// fehlen dann in der vorab ermittelten Map - das ist unkritisch, sie
|
||||||
|
// durchlaufen einfach ganz normal probePort()s eigene Protokollprüfung.
|
||||||
|
const httpxResults = preFetched
|
||||||
|
? preFetched.httpxResults
|
||||||
|
: openPorts.length > 0
|
||||||
|
? await probeWithHttpx(device.ip, openPorts)
|
||||||
|
: new Map<number, HttpxResult>();
|
||||||
|
const usingHttpx = httpxResults !== null && httpxResults !== undefined;
|
||||||
|
const httpxError = preFetched ? preFetched.httpxError : !usingHttpx ? getHttpxLastError() : null;
|
||||||
|
// WICHTIG: openPorts wird NICHT mehr auf die von httpx bestätigten Ports
|
||||||
|
// eingeschränkt. httpx meldet für einen Port, den es nicht innerhalb
|
||||||
|
// seines Timeouts erreicht (z. B. unter Last durch mehrere gleichzeitige
|
||||||
|
// Geräte-Scans, siehe httpx.ts), schlicht GAR NICHTS - das ist von hier
|
||||||
|
// aus nicht von "spricht wirklich kein HTTP" zu unterscheiden. Frühere
|
||||||
|
// Version hat in diesem Fall den kompletten Port verworfen und damit
|
||||||
|
// echte, aber unter Last verpasste Webdienste stillschweigend als "kein
|
||||||
|
// Dienst gefunden" gemeldet. Jetzt bleiben alle offenen Ports erhalten;
|
||||||
|
// httpx liefert nur noch (wo vorhanden) das bereits bekannte Schema als
|
||||||
|
// Abkürzung für probePort() unten - fehlt ein Port in httpxResults, prüft
|
||||||
|
// probePort() ihn ganz normal selbst (identisch zum Verhalten ganz ohne
|
||||||
|
// httpx), statt ihn zu überspringen.
|
||||||
|
|
||||||
|
|
||||||
|
const found: DiscoveredService[] = [];
|
||||||
|
|
||||||
|
// Die HTTP-Abfrage je offenem Port läuft PARALLEL, nicht mehr
|
||||||
|
// nacheinander - bei bis zu 3 HTTP-Versuchen (Protokoll-Fallback +
|
||||||
|
// Titel-Wiederholung) à 2 Sekunden Timeout summierte sich das bei einem
|
||||||
|
// Gerät mit vielen offenen Ports (seit dem vollständigen 1-65535-Portscan
|
||||||
|
// deutlich häufiger) schnell auf mehrere Minuten PRO GERÄT.
|
||||||
|
async function probePort(port: number): Promise<DiscoveredService | null> {
|
||||||
|
const knownScheme = usingHttpx ? httpxResults!.get(port)?.scheme : undefined;
|
||||||
|
let isHttps = knownScheme
|
||||||
|
? knownScheme === "https"
|
||||||
|
: port === 443 || port === 9443 || port === 8006 || port === 8443;
|
||||||
|
let baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
|
||||||
|
let probe = await probeHttp(baseUrl);
|
||||||
|
|
||||||
|
// probe.status === undefined heißt: die Verbindung ist auf HTTP-Ebene
|
||||||
|
// gescheitert (z. B. TLS-Handshake-Fehler bei einem HTTPS-only-Dienst wie
|
||||||
|
// Proxmox auf Port 8006, den die Ports-Liste oben nicht kennt) - nicht
|
||||||
|
// bloß ein 4xx/5xx. In dem Fall lohnt sich ein zweiter Versuch mit dem
|
||||||
|
// jeweils anderen Protokoll, bevor der Port als "kein Web-Dienst"
|
||||||
|
// übersprungen wird. Der Port war laut isPortOpen offen, also lohnt sich
|
||||||
|
// der zusätzliche Versuch. Übersprungen, wenn httpx das Schema schon
|
||||||
|
// bestätigt hat - dann wissen wir es bereits sicher, ein Fehlschlag hier
|
||||||
|
// wäre ein einmaliger Ausrutscher, kein falsches Protokoll.
|
||||||
|
if (probe.status === undefined && !knownScheme) {
|
||||||
|
isHttps = !isHttps;
|
||||||
|
baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
|
||||||
|
const retryProbe = await probeHttp(baseUrl);
|
||||||
|
if (retryProbe.status !== undefined) probe = retryProbe;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weder als HTTP noch als HTTPS eine gültige Antwort bekommen (auch nach
|
||||||
|
// dem Protokoll-Fallback oben) - der Port ist zwar offen, spricht aber
|
||||||
|
// gar kein HTTP (z. B. SSH, eine Datenbank, ein roher TCP-Dienst). Seit
|
||||||
|
// der vollständige Portscan (1-65535) Standard ist, findet er sehr viele
|
||||||
|
// solcher Nicht-Web-Ports - die sollen NICHT als (dann meist
|
||||||
|
// ausgeblendeter) Dienst-Eintrag landen, sondern komplett übersprungen
|
||||||
|
// werden. "Mich interessieren doch nur Webifs."
|
||||||
|
if (probe.status === undefined) return null;
|
||||||
|
|
||||||
|
const software = detectSoftware({
|
||||||
|
server: probe.server,
|
||||||
|
body: probe.bodySnippet,
|
||||||
|
title: probe.title,
|
||||||
|
port,
|
||||||
|
});
|
||||||
|
|
||||||
|
const titleIsJunk = isLikelyJunkTitle(probe.title);
|
||||||
|
// Verbindung stand, aber weder Titel noch Software erkennbar (z. B. eine
|
||||||
|
// SPA, die beim allerersten Laden noch keinen Inhalt geliefert hat, oder
|
||||||
|
// ein Dienst, der kurz gebraucht hat um hochzufahren) - ein zweiter
|
||||||
|
// Versuch, bevor Sichtbarkeit/Favicon/Name dauerhaft auf Basis eines
|
||||||
|
// einzelnen, möglicherweise unvollständigen ersten Aufrufs entschieden
|
||||||
|
// werden.
|
||||||
|
let finalProbe = probe;
|
||||||
|
let finalSoftware = software;
|
||||||
|
let finalTitleIsJunk = titleIsJunk;
|
||||||
|
if (probe.ok && titleIsJunk && !software) {
|
||||||
|
const retryProbe = await probeHttp(baseUrl);
|
||||||
|
const retrySoftware = detectSoftware({
|
||||||
|
server: retryProbe.server,
|
||||||
|
body: retryProbe.bodySnippet,
|
||||||
|
title: retryProbe.title,
|
||||||
|
port,
|
||||||
|
});
|
||||||
|
const retryTitleIsJunk = isLikelyJunkTitle(retryProbe.title);
|
||||||
|
if (retrySoftware || !retryTitleIsJunk) {
|
||||||
|
finalProbe = retryProbe;
|
||||||
|
finalSoftware = retrySoftware;
|
||||||
|
finalTitleIsJunk = retryTitleIsJunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
probe = finalProbe;
|
||||||
|
const softwareResolved = finalSoftware;
|
||||||
|
const titleIsJunkResolved = finalTitleIsJunk;
|
||||||
|
|
||||||
|
// Kein brauchbarer Titel gefunden (z. B. weil eine SPA ihren echten Titel
|
||||||
|
// erst per JavaScript setzt, das der Scanner nicht ausführt) - dann lieber
|
||||||
|
// den bereits bekannten Gerätenamen ("plug-shelly-heiz-links" ->
|
||||||
|
// "Plug Shelly Heiz Links") als Name verwenden statt einer nichtssagenden
|
||||||
|
// IP:Port-Kombination. Nur wenn auch der Gerätename keine Information
|
||||||
|
// trägt (leer/reine IP, z. B. bei unbenannten Geräten), bleibt IP:Port
|
||||||
|
// als letzter Ausweg übrig.
|
||||||
|
const fallbackName = humanizeHostname(device.hostname) ?? `${address}:${port}`;
|
||||||
|
|
||||||
|
// Nur sichtbar, wenn Software erkannt wurde oder eine erfolgreiche Antwort
|
||||||
|
// (Status < 400) mit einem brauchbaren Titel vorliegt. Fehlerseiten und
|
||||||
|
// Ladebildschirme landen als ausgeblendeter Dienst im Adminbereich.
|
||||||
|
const visible = !!softwareResolved || (probe.ok && !titleIsJunkResolved);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hostname: address,
|
||||||
|
url: baseUrl,
|
||||||
|
https: isHttps,
|
||||||
|
port,
|
||||||
|
favicon: probe.faviconUrl,
|
||||||
|
description: softwareResolved ? `${softwareResolved.name} (automatisch erkannt)` : probe.title,
|
||||||
|
suggestedDisplayName:
|
||||||
|
softwareResolved?.name ?? (titleIsJunkResolved ? fallbackName : probe.title ?? fallbackName),
|
||||||
|
category: softwareResolved?.category,
|
||||||
|
icon: softwareResolved?.icon,
|
||||||
|
visible,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const probeResults = await Promise.all(openPorts.map(probePort));
|
||||||
|
for (const result of probeResults) {
|
||||||
|
if (result) found.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback für Dienste ohne bestätigtes Favicon (kein <link rel="icon">
|
||||||
|
// im HTML gefunden): das Favicon eines ANDEREN Dienstes auf demselben
|
||||||
|
// Gerät übernehmen (typischerweise dieselbe Software/dasselbe Gerät, das
|
||||||
|
// Icon passt meist trotzdem). Gibt es auch das nicht, bleibt favicon
|
||||||
|
// bewusst leer (Buchstaben-Fallback in der UI) statt blind auf
|
||||||
|
// "/favicon.ico" zu raten - eine geratene, unbestätigte Adresse sähe in
|
||||||
|
// der Favicon-Auswahl (Bearbeiten-Formular) wie ein echtes Icon aus,
|
||||||
|
// obwohl sie oft ins Leere läuft.
|
||||||
|
const firstSiblingFavicon = found.find((s) => s.favicon)?.favicon;
|
||||||
|
if (firstSiblingFavicon) {
|
||||||
|
for (const service of found) {
|
||||||
|
if (!service.favicon) service.favicon = firstSiblingFavicon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bleibt danach IMMER NOCH kein Favicon übrig (kein bestätigtes eigenes,
|
||||||
|
// kein Sibling), zuletzt versuchen, per erkanntem Software-/Anzeigenamen
|
||||||
|
// ein passendes Icon aus der externen Icon-Datenbank zu finden (siehe
|
||||||
|
// iconDb.ts). Nur bei eindeutigem Treffer - lieber gar kein Favicon als
|
||||||
|
// ein falsch zugeordnetes.
|
||||||
|
for (const service of found) {
|
||||||
|
if (service.favicon) continue;
|
||||||
|
// Vorher: nur versucht, wenn Software erkannt wurde. Jetzt auch bei
|
||||||
|
// einem aus dem Gerätenamen abgeleiteten Namen ("lxc-adguard" ->
|
||||||
|
// "Adguard", siehe humanizeHostname) - blieb Softwareerkennung aus,
|
||||||
|
// konnte trotzdem korrekt "adguard-home" gefunden werden. Nur der reine
|
||||||
|
// IP:Port-Nietenwert (keine Information vorhanden) wird übersprungen.
|
||||||
|
const isBareAddressFallback = /^[\d.:]+$/.test(service.suggestedDisplayName);
|
||||||
|
if (isBareAddressFallback) continue;
|
||||||
|
try {
|
||||||
|
const match = await findBestIconMatch(service.suggestedDisplayName);
|
||||||
|
if (match) {
|
||||||
|
service.favicon = match;
|
||||||
|
// Ein eindeutiger Icon-Datenbank-Treffer ist selbst ein starkes
|
||||||
|
// Erkennungssignal - auch wenn Titel/Software-Erkennung nichts
|
||||||
|
// Brauchbares geliefert haben (z. B. bei einer reinen
|
||||||
|
// Client-seitig-gerenderten Login-Seite ohne Server-Titel).
|
||||||
|
service.visible = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Icon-Datenbank nicht erreichbar (kein Internetzugang o. ä.) - der
|
||||||
|
// Scan soll deswegen nicht fehlschlagen, einfach ohne Favicon weiter.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
services: found,
|
||||||
|
suggestedHostname,
|
||||||
|
toolsUsed: {
|
||||||
|
masscan: usedMasscan,
|
||||||
|
masscanAttempted,
|
||||||
|
masscanError,
|
||||||
|
httpx: usingHttpx,
|
||||||
|
httpxError,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
45
apps/backend/src/scanner/ping.ts
Normal file
45
apps/backend/src/scanner/ping.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { exec } from "node:child_process";
|
||||||
|
import { platform } from "node:os";
|
||||||
|
|
||||||
|
let pingBinaryConfirmedMissing = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft per System-Ping (ICMP), ob eine IP erreichbar ist. Bewusst NICHT über
|
||||||
|
* einen Port-Connect (wie isPortOpen in ports.ts) - ein Gerät kann online
|
||||||
|
* sein, ohne einen der bekannten Ports offen zu haben (z. B. ein Drucker
|
||||||
|
* ohne Web-UI), ein echter Ping ist hier das treffendere Signal.
|
||||||
|
*
|
||||||
|
* Nutzt den System-ping-Befehl statt eines rohen ICMP-Sockets, da Node dafür
|
||||||
|
* root-Rechte bräuchte - für einen einzelnen Zwischencheck reicht das völlig.
|
||||||
|
* Timeout: 1 Sekunde, ein einzelnes Paket.
|
||||||
|
*/
|
||||||
|
export function pingHost(ip: string): Promise<boolean> {
|
||||||
|
const isWindows = platform() === "win32";
|
||||||
|
const command = isWindows ? `ping -n 1 -w 1000 ${ip}` : `ping -c 1 -W 1 ${ip}`;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
exec(command, { timeout: 2000 }, (error, _stdout, stderr) => {
|
||||||
|
// Fehlt der ping-Befehl im Container, meldet die Shell das über exec
|
||||||
|
// NICHT als Node-ENOENT, sondern als regulären Fehlschlag mit
|
||||||
|
// Exit-Code 127 und "not found" in stderr (z. B. "/bin/sh: 1: ping:
|
||||||
|
// not found") - das wird hier separat erkannt (siehe
|
||||||
|
// isPingBinaryConfirmedMissing), damit ein fehlendes ping-Programm
|
||||||
|
// nicht stillschweigend JEDEN Scan leerlaufen lässt.
|
||||||
|
if (error && (error.code === 127 || /not found/i.test(stderr))) {
|
||||||
|
pingBinaryConfirmedMissing = true;
|
||||||
|
}
|
||||||
|
resolve(!error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* true, wenn ein vorheriger pingHost()-Aufruf festgestellt hat, dass der
|
||||||
|
* ping-Befehl im Container gar nicht existiert (z. B. iputils-ping fehlt im
|
||||||
|
* Image). Wird von scanDeviceServices genutzt, um den Ping-Vorab-Check in
|
||||||
|
* dem Fall zu überspringen, statt fälschlich jedes Gerät als nicht
|
||||||
|
* erreichbar zu melden.
|
||||||
|
*/
|
||||||
|
export function isPingBinaryConfirmedMissing(): boolean {
|
||||||
|
return pingBinaryConfirmedMissing;
|
||||||
|
}
|
||||||
122
apps/backend/src/scanner/ports.ts
Normal file
122
apps/backend/src/scanner/ports.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { connect } from "node:net";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typische Ports für den optionalen Portscanner, gemäß Spezifikation.
|
||||||
|
*/
|
||||||
|
export const TYPICAL_PORTS = [
|
||||||
|
80, 443, 81, 8000, 8080, 8081, 8443, 8888,
|
||||||
|
3000, 3001, 5000, 5001,
|
||||||
|
8006, // Proxmox VE
|
||||||
|
9000, 9090, 9091, 9443, // Portainer, Cockpit/Prometheus, Transmission
|
||||||
|
8123, // Home Assistant
|
||||||
|
32400, // Plex
|
||||||
|
8096, 8920, // Emby (HTTP/HTTPS)
|
||||||
|
7878, 8989, 9117, 6789, 6767, 8686, 5055, // Radarr, Sonarr, Jackett/Prowlarr, NZBGet/SABnzbd, Bazarr, Lidarr, Overseerr
|
||||||
|
19999, // Netdata
|
||||||
|
5665, 5666, // Icinga/Nagios
|
||||||
|
8181, 8282,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einfacher Zähl-Semaphore, um die GESAMTZAHL gleichzeitig offener Sockets
|
||||||
|
* über den kompletten Backend-Prozess hinweg zu begrenzen - nicht nur pro
|
||||||
|
* einzelnem Geräte-Scan. Wichtig, seit mehrere Geräte gleichzeitig gescannt
|
||||||
|
* werden (siehe routes/scan.ts, CONCURRENT_DEVICES): ohne einen
|
||||||
|
* PROZESSWEITEN Deckel hätten sich Geräte-Parallelität (bis zu 10) und
|
||||||
|
* Port-Batch-Größe (bis zu 1000) multipliziert - bis zu 10.000 gleichzeitige
|
||||||
|
* Verbindungsversuche, die den Container/das Heimnetz überfordern und dabei
|
||||||
|
* auch eigentlich funktionierende Ports (wie 443 der FritzBox selbst)
|
||||||
|
* fehlschlagen lassen konnten.
|
||||||
|
*/
|
||||||
|
class Semaphore {
|
||||||
|
private available: number;
|
||||||
|
private readonly queue: (() => void)[] = [];
|
||||||
|
|
||||||
|
constructor(count: number) {
|
||||||
|
this.available = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
acquire(): Promise<void> {
|
||||||
|
if (this.available > 0) {
|
||||||
|
this.available--;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => this.queue.push(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
release(): void {
|
||||||
|
const next = this.queue.shift();
|
||||||
|
if (next) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
this.available++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prozessweiter Deckel für gleichzeitig offene Scan-Sockets - unabhängig
|
||||||
|
// davon, ob das gerade ein einzelner Geräte-Scan oder mehrere parallele
|
||||||
|
// Geräte-Scans sind (siehe Kommentar oben).
|
||||||
|
const globalScanSemaphore = new Semaphore(300);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft per TCP-Connect, ob ein Port offen ist. Kein Protokoll-Handshake,
|
||||||
|
* nur "kann eine Verbindung aufgebaut werden" – schnell und protokollunabhängig.
|
||||||
|
* Wartet ggf. kurz auf ein freies "Ticket" im prozessweiten Semaphore (siehe
|
||||||
|
* oben), bevor der eigentliche Verbindungsversuch losgeht.
|
||||||
|
*/
|
||||||
|
export async function isPortOpen(host: string, port: number, timeoutMs = 800): Promise<boolean> {
|
||||||
|
await globalScanSemaphore.acquire();
|
||||||
|
try {
|
||||||
|
return await 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));
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
globalScanSemaphore.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft eine (ggf. sehr lange) Liste von Ports - für den vollständigen
|
||||||
|
* Portscan (1-65535, siehe scanDeviceServices mit fullScan=true). Alle
|
||||||
|
* Verbindungsversuche werden sofort gestartet, die eigentliche Begrenzung
|
||||||
|
* der Gleichzeitigkeit übernimmt der prozessweite Semaphore in isPortOpen
|
||||||
|
* (siehe oben) - dadurch bleibt die Gesamtzahl gleichzeitiger Sockets sicher
|
||||||
|
* gedeckelt, auch wenn mehrere Geräte gleichzeitig gescannt werden.
|
||||||
|
*
|
||||||
|
* Timeout bewusst knapp, aber nicht zu knapp (500ms): im lokalen Netz
|
||||||
|
* (derselbe Subnetz-Bereich) liegt die Round-Trip-Zeit normalerweise im
|
||||||
|
* niedrigen einstelligen Millisekundenbereich, ABER per WLAN angebundene
|
||||||
|
* Geräte (Mesh-Repeater, IoT-Geräte) können spürbar länger brauchen. Ports
|
||||||
|
* 80/443 sowie bereits bekannte Dienst-Ports bekommen zusätzlich einen
|
||||||
|
* zweiten, noch großzügigeren Versuch (siehe scanDeviceServices), falls
|
||||||
|
* dieser erste, knappe Durchlauf sie verpasst.
|
||||||
|
*/
|
||||||
|
export async function scanPortsInBatches(
|
||||||
|
host: string,
|
||||||
|
ports: number[],
|
||||||
|
timeoutMs = 500
|
||||||
|
): Promise<number[]> {
|
||||||
|
const results = await Promise.all(
|
||||||
|
ports.map(async (port) => ({ port, open: await isPortOpen(host, port, timeoutMs) }))
|
||||||
|
);
|
||||||
|
return results.filter((r) => r.open).map((r) => r.port);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Ports von 1 bis 65535 - für den vollständigen (statt nur "typische Ports") Scan eines einzelnen Geräts. */
|
||||||
|
export function fullPortRange(): number[] {
|
||||||
|
const ports: number[] = [];
|
||||||
|
for (let p = 1; p <= 65535; p++) ports.push(p);
|
||||||
|
return ports;
|
||||||
|
}
|
||||||
|
|
||||||
88
apps/backend/src/scanner/softwareDetection.ts
Normal file
88
apps/backend/src/scanner/softwareDetection.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
export interface SoftwareSignatureInput {
|
||||||
|
server?: string;
|
||||||
|
body?: string;
|
||||||
|
title?: string;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SoftwareSignature {
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
matches: (input: SoftwareSignatureInput) => boolean;
|
||||||
|
/** Optional: Emoji oder Icon-URL, von Plugins bereitgestellt (siehe apps/backend/plugins). */
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyContains(input: SoftwareSignatureInput, pattern: RegExp): boolean {
|
||||||
|
return !!input.body && pattern.test(input.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleMatches(input: SoftwareSignatureInput, pattern: RegExp): boolean {
|
||||||
|
return !!input.title && pattern.test(input.title.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatische Softwareerkennung anhand von HTTP-Response-Merkmalen
|
||||||
|
* (Server-Header, HTML-Inhalt). Liste gemäß Spezifikation.
|
||||||
|
*
|
||||||
|
* Grundsatz: der `<title>`-Tag ist ein viel präziseres Signal als der
|
||||||
|
* komplette Seiteninhalt und wird deshalb wo möglich zuerst geprüft. Reine
|
||||||
|
* Volltextsuche im Body (bodyContains) besonders bei Marken-/Produktnamen
|
||||||
|
* aus generischen Alltagswörtern (z. B. "Home Assistant") kann in
|
||||||
|
* minifiziertem JavaScript anderer Web-UIs zufällig zutreffen - siehe
|
||||||
|
* Bugreport: FritzBox-Repeater wurden fälschlich als "Home Assistant"
|
||||||
|
* erkannt, weil beide Wörter irgendwo im ausgelieferten JS vorkamen.
|
||||||
|
*/
|
||||||
|
export const SOFTWARE_SIGNATURES: SoftwareSignature[] = [
|
||||||
|
{
|
||||||
|
name: "Home Assistant",
|
||||||
|
category: "Smart Home",
|
||||||
|
matches: (i) =>
|
||||||
|
titleMatches(i, /^home\s*assistant$/i) || bodyContains(i, /frontend_latest\//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) || titleMatches(i, /adguard/i),
|
||||||
|
},
|
||||||
|
{ name: "UniFi Network", category: "Netzwerk", matches: (i) => bodyContains(i, /unifi/i) },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zusätzliche Signaturen, die von Plugins zur Laufzeit registriert werden
|
||||||
|
* (siehe apps/backend/src/plugins/loader.ts). Eingebaute Signaturen haben
|
||||||
|
* Vorrang, falls beide zufällig auf denselben Dienst passen.
|
||||||
|
*/
|
||||||
|
const pluginSignatures: SoftwareSignature[] = [];
|
||||||
|
|
||||||
|
export function registerSoftwareSignature(signature: SoftwareSignature): void {
|
||||||
|
pluginSignatures.push(signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectSoftware(input: SoftwareSignatureInput): SoftwareSignature | null {
|
||||||
|
return (
|
||||||
|
SOFTWARE_SIGNATURES.find((signature) => signature.matches(input)) ??
|
||||||
|
pluginSignatures.find((signature) => signature.matches(input)) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
11
apps/backend/tsconfig.json
Normal file
11
apps/backend/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
35
apps/frontend/Dockerfile
Normal file
35
apps/frontend/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# 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
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
COPY apps/frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY apps/frontend/nginx-locations.conf /etc/nginx/launchpad-locations.conf
|
||||||
|
COPY apps/frontend/docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
COPY --from=build /app/apps/frontend/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80 443
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
82
apps/frontend/docker-entrypoint.sh
Normal file
82
apps/frontend/docker-entrypoint.sh
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CERT_DIR="/etc/nginx/certs"
|
||||||
|
CA_KEY="$CERT_DIR/ca-key.pem"
|
||||||
|
CA_CERT="$CERT_DIR/ca-cert.pem"
|
||||||
|
CERT_FILE="$CERT_DIR/fullchain.pem"
|
||||||
|
KEY_FILE="$CERT_DIR/privkey.pem"
|
||||||
|
CERT_HOST_FILE="$CERT_DIR/.cert-host"
|
||||||
|
HOST="${LAUNCHPAD_HOST:-localhost}"
|
||||||
|
EXTRA_HOST="${LAUNCHPAD_EXTRA_HOST:-}"
|
||||||
|
|
||||||
|
mkdir -p "$CERT_DIR"
|
||||||
|
|
||||||
|
# Baut einen SAN-Eintrag (IP:... oder DNS:...) für einen einzelnen Hostwert.
|
||||||
|
san_entry_for() {
|
||||||
|
case "$1" in
|
||||||
|
*[0-9]*.*[0-9]*.*[0-9]*.*[0-9]*)
|
||||||
|
echo "IP:$1"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "DNS:$1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ] && [ ! -f "$CA_CERT" ]; then
|
||||||
|
# fullchain.pem/privkey.pem wurden vom Nutzer eingebunden (z. B. eigenes
|
||||||
|
# Zertifikat) und es gibt keine von uns erzeugte CA dazu -> nichts anfassen.
|
||||||
|
echo "[entrypoint] Eingebundenes Zertifikat gefunden (kein CA-Download unter /ca.crt verfügbar)."
|
||||||
|
else
|
||||||
|
# Eigene lokale Root-CA erzeugen, falls noch keine existiert. Liegt im
|
||||||
|
# Volume "launchpad-certs" und bleibt über Neustarts/Rebuilds stabil -
|
||||||
|
# einmal pro Gerät importiert, reicht dauerhaft.
|
||||||
|
if [ ! -f "$CA_KEY" ] || [ ! -f "$CA_CERT" ]; then
|
||||||
|
echo "[entrypoint] Erzeuge neue lokale Root-CA für LaunchPad..."
|
||||||
|
openssl genrsa -out "$CA_KEY" 2048 2>/dev/null
|
||||||
|
openssl req -x509 -new -nodes -key "$CA_KEY" -sha256 -days 3650 \
|
||||||
|
-subj "/CN=LaunchPad Local CA" \
|
||||||
|
-out "$CA_CERT" 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Kombinierte Kennung aus HOST + EXTRA_HOST, um zu erkennen ob sich einer
|
||||||
|
# von beiden geändert hat -> dann Server-Zertifikat neu ausstellen. Die CA
|
||||||
|
# selbst bleibt davon unberührt (bereits erteiltes Gerätevertrauen gilt weiter).
|
||||||
|
HOST_SIGNATURE="$HOST|$EXTRA_HOST"
|
||||||
|
NEED_NEW_LEAF=false
|
||||||
|
if [ ! -f "$CERT_FILE" ] || [ ! -f "$KEY_FILE" ]; then
|
||||||
|
NEED_NEW_LEAF=true
|
||||||
|
elif [ "$(cat "$CERT_HOST_FILE" 2>/dev/null)" != "$HOST_SIGNATURE" ]; then
|
||||||
|
NEED_NEW_LEAF=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$NEED_NEW_LEAF" = "true" ]; then
|
||||||
|
SAN="$(san_entry_for "$HOST"),DNS:localhost,IP:127.0.0.1"
|
||||||
|
if [ -n "$EXTRA_HOST" ] && [ "$EXTRA_HOST" != "$HOST" ]; then
|
||||||
|
SAN="$SAN,$(san_entry_for "$EXTRA_HOST")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
EXTFILE=$(mktemp)
|
||||||
|
printf "subjectAltName=%s\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n" "$SAN" > "$EXTFILE"
|
||||||
|
|
||||||
|
openssl genrsa -out "$KEY_FILE" 2048 2>/dev/null
|
||||||
|
openssl req -new -key "$KEY_FILE" -subj "/CN=$HOST" -out "$CERT_DIR/server.csr" 2>/dev/null
|
||||||
|
openssl x509 -req -in "$CERT_DIR/server.csr" -CA "$CA_CERT" -CAkey "$CA_KEY" \
|
||||||
|
-CAcreateserial -days 825 -sha256 -extfile "$EXTFILE" \
|
||||||
|
-out "$CERT_FILE" 2>/dev/null
|
||||||
|
|
||||||
|
rm -f "$EXTFILE" "$CERT_DIR/server.csr"
|
||||||
|
echo "$HOST_SIGNATURE" > "$CERT_HOST_FILE"
|
||||||
|
echo "[entrypoint] Server-Zertifikat erzeugt (SAN: $SAN), mit lokaler CA signiert."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# CA-Zertifikat zum Download bereitstellen (Android/iOS/Windows bieten beim
|
||||||
|
# Öffnen einer .crt-Datei im Browser automatisch "Zertifikat installieren" an).
|
||||||
|
if [ -f "$CA_CERT" ]; then
|
||||||
|
mkdir -p /usr/share/nginx/html
|
||||||
|
cp "$CA_CERT" /usr/share/nginx/html/ca.crt
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
36
apps/frontend/index.html
Normal file
36
apps/frontend/index.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<!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" />
|
||||||
|
<link
|
||||||
|
rel="search"
|
||||||
|
type="application/opensearchdescription+xml"
|
||||||
|
title="LaunchPad"
|
||||||
|
href="/opensearch.xml"
|
||||||
|
/>
|
||||||
|
<title>LaunchPad</title>
|
||||||
|
<script>
|
||||||
|
// Muss inline und vor jedem Bundle-Download laufen, sonst blitzt beim
|
||||||
|
// Direktaufruf einer Unterseite (z. B. /admin/dashboard) kurz das
|
||||||
|
// falsche Theme auf, bevor React mountet.
|
||||||
|
(function () {
|
||||||
|
var stored = localStorage.getItem("launchpad-theme");
|
||||||
|
var dark =
|
||||||
|
stored === "dark" ||
|
||||||
|
(stored !== "light" &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
document.documentElement.classList.toggle("dark", dark);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-white dark:bg-black">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
40
apps/frontend/nginx-locations.conf
Normal file
40
apps/frontend/nginx-locations.conf
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Root-CA zum Download – Android/iOS erkennen diesen MIME-Type und bieten
|
||||||
|
# beim Öffnen im Browser direkt "Zertifikat installieren" an.
|
||||||
|
location = /ca.crt {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
default_type application/x-x509-ca-cert;
|
||||||
|
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";
|
||||||
|
}
|
||||||
26
apps/frontend/nginx.conf
Normal file
26
apps/frontend/nginx.conf
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# HTTP – bleibt zusätzlich zu HTTPS verfügbar (kein erzwungener Redirect,
|
||||||
|
# damit bestehende http://-Zugriffe nicht plötzlich brechen).
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
include /etc/nginx/launchpad-locations.conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS – Zertifikat wird vom Entrypoint-Skript automatisch erzeugt
|
||||||
|
# (selbstsigniert) oder ist vom Nutzer eingebunden, siehe README.
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
|
include /etc/nginx/launchpad-locations.conf;
|
||||||
|
}
|
||||||
37
apps/frontend/package.json
Normal file
37
apps/frontend/package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"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": {
|
||||||
|
"@fortawesome/fontawesome-svg-core": "^7.3.1",
|
||||||
|
"@fortawesome/free-regular-svg-icons": "^7.3.1",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^7.3.1",
|
||||||
|
"@fortawesome/react-fontawesome": "^3.5.0",
|
||||||
|
"@launchpad/shared": "workspace:*",
|
||||||
|
"@launchpad/ui": "workspace:*",
|
||||||
|
"@tanstack/react-query": "5.101.2",
|
||||||
|
"@tanstack/query-sync-storage-persister": "5.101.2",
|
||||||
|
"@tanstack/react-query-persist-client": "5.101.2",
|
||||||
|
"@tanstack/react-router": "^1.58.3",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.41",
|
||||||
|
"tailwindcss": "^3.4.10",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.1",
|
||||||
|
"vite-plugin-pwa": "^0.20.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/frontend/postcss.config.js
Normal file
6
apps/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
apps/frontend/public/apple-touch-icon.png
Normal file
BIN
apps/frontend/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
5
apps/frontend/public/favicon.svg
Normal file
5
apps/frontend/public/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="512" height="512" rx="112" fill="#0a0a0a"/>
|
||||||
|
<path d="M 200 140 L 200 372 L 372 256 Z" fill="#ffffff"/>
|
||||||
|
<circle cx="150" cy="256" r="26" fill="#ffffff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 267 B |
BIN
apps/frontend/public/icons/icon-192.png
Normal file
BIN
apps/frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
BIN
apps/frontend/public/icons/icon-512.png
Normal file
BIN
apps/frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
apps/frontend/public/icons/icon-maskable-192.png
Normal file
BIN
apps/frontend/public/icons/icon-maskable-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
apps/frontend/public/icons/icon-maskable-512.png
Normal file
BIN
apps/frontend/public/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
8
apps/frontend/public/opensearch.xml
Normal file
8
apps/frontend/public/opensearch.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
||||||
|
<ShortName>LaunchPad</ShortName>
|
||||||
|
<Description>Dienste und Lesezeichen in LaunchPad suchen und direkt öffnen</Description>
|
||||||
|
<InputEncoding>UTF-8</InputEncoding>
|
||||||
|
<Image height="16" width="16" type="image/svg+xml">/favicon.svg</Image>
|
||||||
|
<Url type="text/html" method="get" template="/search?q={searchTerms}"/>
|
||||||
|
</OpenSearchDescription>
|
||||||
866
apps/frontend/src/components/MobileAndroidHome.tsx
Normal file
866
apps/frontend/src/components/MobileAndroidHome.tsx
Normal file
@@ -0,0 +1,866 @@
|
|||||||
|
import { useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import {
|
||||||
|
faGear,
|
||||||
|
faMagnifyingGlass,
|
||||||
|
faXmark,
|
||||||
|
faClockRotateLeft,
|
||||||
|
faBookmark,
|
||||||
|
faPen,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import type { Category, Service, Bookmark, SearchResult } from "@launchpad/shared";
|
||||||
|
import { rankSearchResults } from "@launchpad/shared";
|
||||||
|
import { ResultsList, Button } from "@launchpad/ui";
|
||||||
|
import { useHomeLayout, type HomeSlot, type ItemRef } from "../hooks/useHomeLayout.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android-Startbildschirm-artiges alternatives Design für die mobile Ansicht
|
||||||
|
* (siehe Einstellungen -> "Design"). Zeigt AUSSCHLIESSLICH als Favorit
|
||||||
|
* markierte Dienste/Lesezeichen (siehe Bugreport round65: vorher wurden auch
|
||||||
|
* alle Kategorien automatisch als Ordner gezeigt - das war zu viel/nicht
|
||||||
|
* kuratiert genug). Die Anordnung (Reihenfolge, eigene Ordner, Aufteilung
|
||||||
|
* Hauptbereich/Dock) legt der Nutzer selbst per Ziehen fest, genau wie auf
|
||||||
|
* einem echten Android-Handy - siehe useHomeLayout.ts für die
|
||||||
|
* Datenstruktur/Persistierung und die Drag-Logik weiter unten in dieser Datei.
|
||||||
|
*
|
||||||
|
* WICHTIG zur Interaktion: klassisches HTML5-Drag&Drop (draggable=true,
|
||||||
|
* onDragStart/onDrop, siehe FavoritesBar.tsx an anderer Stelle im Projekt)
|
||||||
|
* feuert auf Touchscreens NICHT zuverlässig - für eine mobile Oberfläche
|
||||||
|
* daher bewusst mit der Pointer-Events-API nachgebaut (vereinheitlicht
|
||||||
|
* Maus/Touch/Stift), inklusive Lang-Drücken zum Start (350ms), damit
|
||||||
|
* normales Scrollen nicht versehentlich als Ziehen interpretiert wird.
|
||||||
|
*
|
||||||
|
* Zusätzlich: mobile Browser fangen ein Lang-Drücken auf einem <img> (bzw.
|
||||||
|
* dem umgebenden Element) standardmäßig SELBST ab und zeigen ihr eigenes
|
||||||
|
* Kontextmenü ("Bild speichern"/"Bild kopieren" usw.), noch bevor unser
|
||||||
|
* eigener Drag-Timer greift. Dagegen: draggable={false} + pointer-events-none
|
||||||
|
* auf den <img>-Tags (Touch-Events landen dadurch immer auf dem umgebenden
|
||||||
|
* <button>), -webkit-touch-callout:none (unterdrückt das iOS-Kontextmenü),
|
||||||
|
* onContextMenu-preventDefault (unterdrückt das Android-Pendant).
|
||||||
|
*
|
||||||
|
* Außerdem (Korrektur nach erstem Anlauf): die Kacheln brauchen
|
||||||
|
* touch-action:"none" von Anfang an, NICHT "manipulation" - mit
|
||||||
|
* "manipulation" beansprucht der Browser eine Zieh-nach-unten-Geste bereits
|
||||||
|
* für natives Scrollen, bevor unser preventDefault() in handlePointerMove
|
||||||
|
* überhaupt zum Zug kommt (führte dazu, dass beim Ziehen das
|
||||||
|
* Handy-Benachrichtigungsmenü von oben aufging statt das Icon zu bewegen).
|
||||||
|
* Mit touch-action:"none" überlässt der Browser Touch-Gesten auf den
|
||||||
|
* Kacheln von vornherein komplett uns. Nebeneffekt: ein Scroll-Versuch, der
|
||||||
|
* exakt auf einem Icon beginnt, scrollt nicht mehr nativ - das ist
|
||||||
|
* hinnehmbar (Wischen zwischen den Icons/auf dem Hintergrund scrollt
|
||||||
|
* weiterhin normal) und entspricht dem Verhalten eines echten
|
||||||
|
* Android-Homescreens, wo ein Icon selbst auch nicht als Scroll-Fläche dient.
|
||||||
|
*
|
||||||
|
* Signatur-Element: der Wallpaper-Hintergrund besteht aus den tatsächlichen,
|
||||||
|
* in den eigenen Kategorien hinterlegten Farben (Admin -> Kategorien).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FALLBACK_PALETTE = ["#6366f1", "#a855f7", "#06b6d4", "#f97316"];
|
||||||
|
const BLOB_POSITIONS = [
|
||||||
|
{ top: "-10%", left: "-15%" },
|
||||||
|
{ top: "55%", left: "60%" },
|
||||||
|
{ top: "70%", left: "-20%" },
|
||||||
|
{ top: "-15%", left: "55%" },
|
||||||
|
];
|
||||||
|
const LONG_PRESS_MS = 350;
|
||||||
|
const MOVE_CANCEL_PX = 10;
|
||||||
|
|
||||||
|
function resolveIconSrc(src: string): string {
|
||||||
|
if (src.startsWith("data:")) return src;
|
||||||
|
return `/api/favicon-proxy?url=${encodeURIComponent(src)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedItem {
|
||||||
|
id: string;
|
||||||
|
type: "service" | "bookmark";
|
||||||
|
displayName: string;
|
||||||
|
favicon: string | null;
|
||||||
|
url: string;
|
||||||
|
category: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotId(): string {
|
||||||
|
return `slot-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSavedAt(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return "";
|
||||||
|
const datePart = date.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "2-digit" });
|
||||||
|
const timePart = date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||||
|
return `${datePart}, ${timePart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reine Funktion, die aus dem Ist-Zustand (Layout) + der abgeschlossenen
|
||||||
|
* Ziehgeste das NEUE Layout berechnet - bewusst ohne React-State-Zugriff,
|
||||||
|
* damit sie unabhängig testbar/nachvollziehbar bleibt.
|
||||||
|
*
|
||||||
|
* Regeln (angelehnt an Android):
|
||||||
|
* - Item auf Item gezogen -> neuer Ordner mit beiden.
|
||||||
|
* - Item auf Ordner gezogen -> tritt dem Ordner bei.
|
||||||
|
* - Ordner auf irgendwas gezogen (oder Item auf leeren Bereich) -> reine
|
||||||
|
* Neusortierung, kein Verschmelzen (keine verschachtelten Ordner).
|
||||||
|
* - Dock hat eine feste Obergrenze (siehe maxDockSlots) - ein Ablegen dort,
|
||||||
|
* das die Grenze sprengen würde, wird verworfen (Layout bleibt
|
||||||
|
* unverändert, wie ein Android-Dock, das "voll" ist).
|
||||||
|
*/
|
||||||
|
function applyDragResult(
|
||||||
|
layout: { grid: HomeSlot[]; dock: HomeSlot[] },
|
||||||
|
dragged: HomeSlot,
|
||||||
|
hover: { area: "grid" | "dock"; slotId: string } | null,
|
||||||
|
maxDockSlots: number
|
||||||
|
): { grid: HomeSlot[]; dock: HomeSlot[] } | null {
|
||||||
|
if (!hover) return null;
|
||||||
|
|
||||||
|
const grid = layout.grid.filter((s) => s.id !== dragged.id);
|
||||||
|
const dock = layout.dock.filter((s) => s.id !== dragged.id);
|
||||||
|
const targetArr = hover.area === "grid" ? grid : dock;
|
||||||
|
|
||||||
|
if (hover.slotId === "__end__") {
|
||||||
|
if (hover.area === "dock" && dock.length >= maxDockSlots) return null;
|
||||||
|
targetArr.push(dragged);
|
||||||
|
return { grid, dock };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetIndex = targetArr.findIndex((s) => s.id === hover.slotId);
|
||||||
|
if (targetIndex === -1) return null;
|
||||||
|
const targetSlot = targetArr[targetIndex];
|
||||||
|
|
||||||
|
if (dragged.kind === "item" && targetSlot.kind === "item") {
|
||||||
|
targetArr.splice(targetIndex, 1, {
|
||||||
|
id: slotId(),
|
||||||
|
kind: "folder",
|
||||||
|
name: "Neuer Ordner",
|
||||||
|
items: [targetSlot.ref, dragged.ref],
|
||||||
|
});
|
||||||
|
} else if (dragged.kind === "item" && targetSlot.kind === "folder") {
|
||||||
|
targetArr.splice(targetIndex, 1, { ...targetSlot, items: [...targetSlot.items, dragged.ref] });
|
||||||
|
} else {
|
||||||
|
if (hover.area === "dock" && dock.length >= maxDockSlots) return null;
|
||||||
|
targetArr.splice(targetIndex, 0, dragged);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { grid, dock };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TileVisualProps {
|
||||||
|
favicon?: string | null;
|
||||||
|
label: string;
|
||||||
|
size?: "normal" | "small";
|
||||||
|
dimmed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TileIcon({ favicon, label, size = "normal", dimmed }: TileVisualProps) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const dimension = size === "small" ? "h-9 w-9" : "h-14 w-14";
|
||||||
|
const rounding = size === "small" ? "rounded-xl" : "rounded-[20px]";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`flex ${dimension} ${rounding} shrink-0 items-center justify-center bg-white/75
|
||||||
|
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm transition-opacity
|
||||||
|
dark:bg-white/10 dark:ring-white/10 ${dimmed ? "opacity-30" : ""}`}
|
||||||
|
>
|
||||||
|
{favicon && !failed ? (
|
||||||
|
<img
|
||||||
|
src={resolveIconSrc(favicon)}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
className={`pointer-events-none select-none [-webkit-touch-callout:none] ${
|
||||||
|
size === "small" ? "h-5 w-5" : "h-8 w-8"
|
||||||
|
} object-contain`}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className={`font-semibold text-black/70 dark:text-white/70 ${size === "small" ? "text-xs" : "text-lg"}`}>
|
||||||
|
{label.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderTile({ icons, dimmed }: { icons: Array<{ favicon: string | null; label: string }>; dimmed?: boolean }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`grid h-14 w-14 shrink-0 grid-cols-2 grid-rows-2 gap-1 rounded-[20px] bg-white/60 p-1.5
|
||||||
|
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm transition-opacity
|
||||||
|
dark:bg-white/10 dark:ring-white/10 ${dimmed ? "opacity-30" : ""}`}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => {
|
||||||
|
const item = icons[i];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-center overflow-hidden rounded-md bg-white/70 dark:bg-white/10"
|
||||||
|
>
|
||||||
|
{item?.favicon ? (
|
||||||
|
<img
|
||||||
|
src={resolveIconSrc(item.favicon)}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
className="pointer-events-none h-full w-full select-none object-contain p-0.5 [-webkit-touch-callout:none]"
|
||||||
|
/>
|
||||||
|
) : item ? (
|
||||||
|
<span className="text-[8px] font-semibold text-black/50">{item.label.charAt(0).toUpperCase()}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenTarget = { url: string; id: string; kind: "service" | "bookmark" };
|
||||||
|
|
||||||
|
interface MinimalVisitItem {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
displayName: string;
|
||||||
|
favicon: string | null;
|
||||||
|
kind?: "service" | "bookmark";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobileAndroidHomeProps {
|
||||||
|
services: Service[];
|
||||||
|
bookmarks: Bookmark[];
|
||||||
|
categories: Category[];
|
||||||
|
categoryColors: Record<string, string>;
|
||||||
|
allSearchItems: SearchResult[];
|
||||||
|
recentVisits: MinimalVisitItem[];
|
||||||
|
readLaterItems: Array<{ id: string; url: string; displayName: string; favicon: string | null; savedAt: string }>;
|
||||||
|
onOpen: (item: OpenTarget) => void;
|
||||||
|
onToggleFavorite: (item: SearchResult) => void;
|
||||||
|
onAddReadLater: (url: string) => void;
|
||||||
|
isOnline: boolean;
|
||||||
|
/**
|
||||||
|
* Wird erst true, wenn ALLE für das Raster nötigen Daten (Dienste,
|
||||||
|
* Lesezeichen, Kategorien, Zuletzt-besucht, Einstellungen/Layout)
|
||||||
|
* mindestens einmal geladen sind - siehe Kommentar in HomePage.tsx. Bis
|
||||||
|
* dahin wird ein einfacher Platzhalter statt des Rasters gezeigt, damit
|
||||||
|
* nicht einzelne Kacheln nacheinander "nachrücken".
|
||||||
|
*/
|
||||||
|
isReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileAndroidHome({
|
||||||
|
services,
|
||||||
|
bookmarks,
|
||||||
|
categories,
|
||||||
|
categoryColors,
|
||||||
|
allSearchItems,
|
||||||
|
recentVisits,
|
||||||
|
readLaterItems,
|
||||||
|
onOpen,
|
||||||
|
onToggleFavorite,
|
||||||
|
onAddReadLater,
|
||||||
|
isOnline,
|
||||||
|
isReady,
|
||||||
|
}: MobileAndroidHomeProps) {
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const [openFolderId, setOpenFolderId] = useState<string | null>(null);
|
||||||
|
const [readLaterUrl, setReadLaterUrl] = useState("");
|
||||||
|
const [renamingFolder, setRenamingFolder] = useState(false);
|
||||||
|
const [folderNameDraft, setFolderNameDraft] = useState("");
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const palette = useMemo(() => {
|
||||||
|
const fromCategories = (categories ?? []).map((c) => c.color).filter((c): c is string => Boolean(c));
|
||||||
|
const unique = Array.from(new Set(fromCategories));
|
||||||
|
return unique.length > 0 ? unique.slice(0, 4) : FALLBACK_PALETTE;
|
||||||
|
}, [categories]);
|
||||||
|
|
||||||
|
const serviceById = useMemo(() => new Map(services.map((s) => [s.id, s])), [services]);
|
||||||
|
const bookmarkById = useMemo(() => new Map(bookmarks.map((b) => [b.id, b])), [bookmarks]);
|
||||||
|
|
||||||
|
const resolveRef = (ref: ItemRef): ResolvedItem | null => {
|
||||||
|
if (ref.type === "service") {
|
||||||
|
const s = serviceById.get(ref.id);
|
||||||
|
if (!s || !s.visible) return null;
|
||||||
|
return { id: s.id, type: "service", displayName: s.displayName, favicon: s.favicon, url: s.url, category: s.category };
|
||||||
|
}
|
||||||
|
const b = bookmarkById.get(ref.id);
|
||||||
|
if (!b) return null;
|
||||||
|
return { id: b.id, type: "bookmark", displayName: b.displayName, favicon: b.favicon, url: b.url, category: b.category };
|
||||||
|
};
|
||||||
|
|
||||||
|
const favoriteRefs: ItemRef[] = useMemo(
|
||||||
|
() => [
|
||||||
|
...services.filter((s) => s.visible && s.favorite).map((s) => ({ id: s.id, type: "service" as const })),
|
||||||
|
...bookmarks.filter((b) => b.favorite).map((b) => ({ id: b.id, type: "bookmark" as const })),
|
||||||
|
],
|
||||||
|
[services, bookmarks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { layout, saveLayout, maxDockSlots } = useHomeLayout(favoriteRefs);
|
||||||
|
|
||||||
|
// --- Ziehen (Pointer Events, siehe Kommentar oben an der Datei) ---
|
||||||
|
const [drag, setDrag] = useState<{
|
||||||
|
slot: HomeSlot;
|
||||||
|
pointerId: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [hover, setHover] = useState<{ area: "grid" | "dock"; slotId: string } | null>(null);
|
||||||
|
const longPressTimer = useRef<number | null>(null);
|
||||||
|
const pressStart = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const suppressNextClick = useRef(false);
|
||||||
|
|
||||||
|
function cancelPendingPress() {
|
||||||
|
if (longPressTimer.current !== null) {
|
||||||
|
window.clearTimeout(longPressTimer.current);
|
||||||
|
longPressTimer.current = null;
|
||||||
|
}
|
||||||
|
pressStart.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(e: ReactPointerEvent<HTMLButtonElement>, slot: HomeSlot) {
|
||||||
|
if (e.pointerType === "mouse" && e.button !== 0) return;
|
||||||
|
pressStart.current = { x: e.clientX, y: e.clientY };
|
||||||
|
const target = e.currentTarget;
|
||||||
|
const pointerId = e.pointerId;
|
||||||
|
const clientX = e.clientX;
|
||||||
|
const clientY = e.clientY;
|
||||||
|
longPressTimer.current = window.setTimeout(() => {
|
||||||
|
target.setPointerCapture(pointerId);
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
setDrag({
|
||||||
|
slot,
|
||||||
|
pointerId,
|
||||||
|
x: clientX,
|
||||||
|
y: clientY,
|
||||||
|
offsetX: clientX - rect.left,
|
||||||
|
offsetY: clientY - rect.top,
|
||||||
|
});
|
||||||
|
}, LONG_PRESS_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(e: ReactPointerEvent<HTMLButtonElement>) {
|
||||||
|
if (!drag) {
|
||||||
|
if (pressStart.current && longPressTimer.current !== null) {
|
||||||
|
const dx = e.clientX - pressStart.current.x;
|
||||||
|
const dy = e.clientY - pressStart.current.y;
|
||||||
|
if (Math.hypot(dx, dy) > MOVE_CANCEL_PX) cancelPendingPress();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.pointerId !== drag.pointerId) return;
|
||||||
|
// Solange aktiv gezogen wird, das native Scrollen unterdrücken.
|
||||||
|
// WICHTIG (Korrektur): touch-action:"manipulation" reicht dafür NICHT -
|
||||||
|
// das erlaubt dem Browser bereits VOR diesem preventDefault(), die Geste
|
||||||
|
// für natives Scrollen zu beanspruchen (führte dazu, dass beim Ziehen
|
||||||
|
// nach unten das Handy-Benachrichtigungsmenü aufging, siehe Bugreport).
|
||||||
|
// Die Kacheln nutzen daher touch-action:"none" von Anfang an - der
|
||||||
|
// Browser überlässt Touch-Gesten auf ihnen komplett uns/JavaScript.
|
||||||
|
e.preventDefault();
|
||||||
|
setDrag((d) => (d ? { ...d, x: e.clientX, y: e.clientY } : d));
|
||||||
|
|
||||||
|
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||||
|
const slotEl = el?.closest("[data-slot-id]") as HTMLElement | null;
|
||||||
|
const areaEl = el?.closest("[data-drop-area]") as HTMLElement | null;
|
||||||
|
if (slotEl && slotEl.dataset.slotId && slotEl.dataset.slotId !== drag.slot.id) {
|
||||||
|
setHover({ area: (slotEl.dataset.area as "grid" | "dock") ?? "grid", slotId: slotEl.dataset.slotId });
|
||||||
|
} else if (areaEl?.dataset.dropArea) {
|
||||||
|
setHover({ area: areaEl.dataset.dropArea as "grid" | "dock", slotId: "__end__" });
|
||||||
|
} else {
|
||||||
|
setHover(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(e: ReactPointerEvent<HTMLButtonElement>) {
|
||||||
|
cancelPendingPress();
|
||||||
|
if (!drag || e.pointerId !== drag.pointerId) return;
|
||||||
|
const next = applyDragResult(layout, drag.slot, hover, maxDockSlots);
|
||||||
|
if (next) {
|
||||||
|
saveLayout(next);
|
||||||
|
suppressNextClick.current = true;
|
||||||
|
}
|
||||||
|
setDrag(null);
|
||||||
|
setHover(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerCancel() {
|
||||||
|
cancelPendingPress();
|
||||||
|
setDrag(null);
|
||||||
|
setHover(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTileClick(action: () => void) {
|
||||||
|
if (suppressNextClick.current) {
|
||||||
|
suppressNextClick.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (drag) return;
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchResults = useMemo(() => {
|
||||||
|
// WICHTIG (Bugfix): vorher stand hier ein eigener, simpler
|
||||||
|
// .includes()-Filter, der zusätzlich Geräte komplett ausgeschlossen hat
|
||||||
|
// - dadurch fand die Suche im Android-Design weniger als im normalen
|
||||||
|
// Design (siehe Bugreport). Jetzt exakt dieselbe Rangier-Funktion wie im
|
||||||
|
// klassischen Design (siehe HomePage.tsx), inklusive Geräten - ein Klick
|
||||||
|
// auf ein Gerät bleibt trotzdem ohne Wirkung (kein eigenes Ziel, siehe
|
||||||
|
// onOpen/openItem in HomePage.tsx), aber es taucht wie gewohnt als
|
||||||
|
// Treffer auf.
|
||||||
|
return rankSearchResults(allSearchItems, query);
|
||||||
|
}, [query, allSearchItems]);
|
||||||
|
|
||||||
|
const closeSearch = () => {
|
||||||
|
setSearchOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFolder =
|
||||||
|
openFolderId && openFolderId !== "__recent__" && openFolderId !== "__readlater__"
|
||||||
|
? ([...layout.grid, ...layout.dock].find((s) => s.id === openFolderId && s.kind === "folder") as
|
||||||
|
| Extract<HomeSlot, { kind: "folder" }>
|
||||||
|
| undefined)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
function removeFromFolder(folder: Extract<HomeSlot, { kind: "folder" }>, ref: ItemRef) {
|
||||||
|
const items = folder.items.filter((i) => !(i.id === ref.id && i.type === ref.type));
|
||||||
|
const patch = (slots: HomeSlot[]) =>
|
||||||
|
slots
|
||||||
|
.map((s) => (s.id === folder.id ? (items.length > 0 ? { ...s, items } : null) : s))
|
||||||
|
.filter((s): s is HomeSlot => s !== null);
|
||||||
|
saveLayout({ grid: patch(layout.grid), dock: patch(layout.dock) });
|
||||||
|
if (items.length === 0) setOpenFolderId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameFolder(folder: Extract<HomeSlot, { kind: "folder" }>, name: string) {
|
||||||
|
const patch = (slots: HomeSlot[]) => slots.map((s) => (s.id === folder.id ? { ...s, name } : s));
|
||||||
|
saveLayout({ grid: patch(layout.grid), dock: patch(layout.dock) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTile(slot: HomeSlot, area: "grid" | "dock", size: "normal" | "small" = "normal") {
|
||||||
|
const isDragging = drag?.slot.id === slot.id;
|
||||||
|
const isHoverTarget = hover?.slotId === slot.id;
|
||||||
|
|
||||||
|
if (slot.kind === "folder") {
|
||||||
|
const icons = slot.items
|
||||||
|
.map((ref) => resolveRef(ref))
|
||||||
|
.filter((i): i is ResolvedItem => i !== null)
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((i) => ({ favicon: i.favicon, label: i.displayName }));
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={slot.id}
|
||||||
|
type="button"
|
||||||
|
data-slot-id={slot.id}
|
||||||
|
data-area={area}
|
||||||
|
onPointerDown={(e) => handlePointerDown(e, slot)}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerCancel}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
onClick={() => handleTileClick(() => setOpenFolderId(slot.id))}
|
||||||
|
style={{ touchAction: "none", WebkitTouchCallout: "none", WebkitUserSelect: "none", userSelect: "none" }}
|
||||||
|
className={`flex flex-col items-center gap-1.5 ${isHoverTarget ? "scale-110" : ""} transition-transform`}
|
||||||
|
>
|
||||||
|
<FolderTile icons={icons} dimmed={isDragging} />
|
||||||
|
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
|
||||||
|
{slot.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = resolveRef(slot.ref);
|
||||||
|
if (!item) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={slot.id}
|
||||||
|
type="button"
|
||||||
|
data-slot-id={slot.id}
|
||||||
|
data-area={area}
|
||||||
|
onPointerDown={(e) => handlePointerDown(e, slot)}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerCancel}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
onClick={() => handleTileClick(() => onOpen({ url: item.url, id: item.id, kind: item.type }))}
|
||||||
|
style={{ touchAction: "none", WebkitTouchCallout: "none", WebkitUserSelect: "none", userSelect: "none" }}
|
||||||
|
className={`flex flex-col items-center gap-1.5 ${isHoverTarget ? "scale-110" : ""} transition-transform`}
|
||||||
|
>
|
||||||
|
<TileIcon favicon={item.favicon} label={item.displayName} dimmed={isDragging} />
|
||||||
|
{size === "normal" ? (
|
||||||
|
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
|
||||||
|
{item.displayName}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-dvh overflow-hidden bg-neutral-100 dark:bg-neutral-950">
|
||||||
|
{/* Wallpaper */}
|
||||||
|
<div className="pointer-events-none fixed inset-0 overflow-hidden">
|
||||||
|
{palette.map((color, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="absolute h-[60vmax] w-[60vmax] rounded-full opacity-25 motion-safe:animate-drift dark:opacity-30"
|
||||||
|
style={{
|
||||||
|
background: color,
|
||||||
|
filter: "blur(60px)",
|
||||||
|
top: BLOB_POSITIONS[i % BLOB_POSITIONS.length].top,
|
||||||
|
left: BLOB_POSITIONS[i % BLOB_POSITIONS.length].left,
|
||||||
|
animationDelay: `${i * 4}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className="absolute inset-0 bg-white/50 dark:bg-black/55" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suchleiste: fest oben */}
|
||||||
|
<div className="sticky top-0 z-20 flex items-center gap-2 px-4 pb-3 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchOpen(true);
|
||||||
|
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||||
|
}}
|
||||||
|
className="flex flex-1 items-center gap-3 rounded-full border border-white/40 bg-white/70
|
||||||
|
px-4 py-3 text-left shadow-lg backdrop-blur-xl dark:border-white/10 dark:bg-neutral-900/70"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faMagnifyingGlass} className="text-black/40 dark:text-white/40" />
|
||||||
|
<span className="text-sm text-black/40 dark:text-white/40">Dienst oder Lesezeichen suchen …</span>
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
aria-label="Adminbereich öffnen"
|
||||||
|
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border
|
||||||
|
border-white/40 bg-white/70 text-black/60 shadow-lg backdrop-blur-xl
|
||||||
|
dark:border-white/10 dark:bg-neutral-900/70 dark:text-white/60"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faGear} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Haupt-Raster */}
|
||||||
|
<div data-drop-area="grid" className="min-h-[40vh] px-4 pb-32 pt-2">
|
||||||
|
{isReady ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-4 gap-x-3 gap-y-5">
|
||||||
|
{recentVisits.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenFolderId("__recent__")}
|
||||||
|
className="flex flex-col items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex h-14 w-14 items-center justify-center rounded-[20px] bg-white/60 text-black/50
|
||||||
|
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm
|
||||||
|
dark:bg-white/10 dark:text-white/50 dark:ring-white/10"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faClockRotateLeft} />
|
||||||
|
</span>
|
||||||
|
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
|
||||||
|
Zuletzt besucht
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button type="button" onClick={() => setOpenFolderId("__readlater__")} className="flex flex-col items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="flex h-14 w-14 items-center justify-center rounded-[20px] bg-white/60 text-black/50
|
||||||
|
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm
|
||||||
|
dark:bg-white/10 dark:text-white/50 dark:ring-white/10"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faBookmark} />
|
||||||
|
</span>
|
||||||
|
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
|
||||||
|
Später lesen
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{layout.grid.map((slot) => renderTile(slot, "grid"))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{layout.grid.length === 0 ? (
|
||||||
|
<p className="mt-8 text-center text-sm text-black/40 dark:text-white/40">
|
||||||
|
Markiere Dienste/Lesezeichen im Adminbereich als Favorit – sie erscheinen dann hier.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Platzhalter statt des Rasters, solange noch nicht ALLE nötigen
|
||||||
|
// Daten da sind (siehe isReady-Kommentar an der Prop weiter oben) -
|
||||||
|
// verhindert, dass z. B. "Zuletzt besucht" erst nachträglich
|
||||||
|
// auftaucht und alle Icons danach verschiebt (siehe Bugreport).
|
||||||
|
<div className="grid grid-cols-4 gap-x-3 gap-y-5" aria-hidden="true">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex flex-col items-center gap-1.5">
|
||||||
|
<div className="h-14 w-14 animate-pulse rounded-[20px] bg-black/10 dark:bg-white/10" />
|
||||||
|
<div className="h-2.5 w-10 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dock */}
|
||||||
|
<div
|
||||||
|
data-drop-area="dock"
|
||||||
|
className="fixed inset-x-0 bottom-0 z-20 flex justify-center px-4 pb-[calc(env(safe-area-inset-bottom)+0.75rem)]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex min-h-[4.5rem] min-w-[5rem] items-center gap-3 rounded-[28px] border border-white/40
|
||||||
|
bg-white/70 px-4 py-3 shadow-xl backdrop-blur-xl dark:border-white/10 dark:bg-neutral-900/70"
|
||||||
|
>
|
||||||
|
{!isReady ? (
|
||||||
|
<div className="h-9 w-9 animate-pulse rounded-xl bg-black/10 dark:bg-white/10" aria-hidden="true" />
|
||||||
|
) : layout.dock.length === 0 ? (
|
||||||
|
<span className="px-2 text-xs text-black/30 dark:text-white/30">Icon hierher ziehen zum Anheften</span>
|
||||||
|
) : (
|
||||||
|
layout.dock.map((slot) => renderTile(slot, "dock", "small"))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Verbindungsstatus */}
|
||||||
|
<div
|
||||||
|
title={isOnline ? "Backend verbunden" : "Backend nicht erreichbar"}
|
||||||
|
className={`fixed left-4 top-[calc(env(safe-area-inset-top)+0.1rem)] z-10 h-2 w-2 rounded-full ${
|
||||||
|
isOnline ? "bg-emerald-500" : "bg-red-500"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Schwebendes Icon während des Ziehens */}
|
||||||
|
{drag ? (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none fixed z-50 opacity-90"
|
||||||
|
style={{ left: drag.x - drag.offsetX, top: drag.y - drag.offsetY }}
|
||||||
|
>
|
||||||
|
{drag.slot.kind === "folder" ? (
|
||||||
|
<FolderTile
|
||||||
|
icons={drag.slot.items
|
||||||
|
.map((r) => resolveRef(r))
|
||||||
|
.filter((i): i is ResolvedItem => i !== null)
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((i) => ({ favicon: i.favicon, label: i.displayName }))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
(() => {
|
||||||
|
const item = resolveRef(drag.slot.ref);
|
||||||
|
return item ? <TileIcon favicon={item.favicon} label={item.displayName} /> : null;
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Ordner-Sheet: Zuletzt besucht / Später lesen / eigener Ordner */}
|
||||||
|
{openFolderId ? (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-30 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center"
|
||||||
|
onClick={() => {
|
||||||
|
setOpenFolderId(null);
|
||||||
|
setRenamingFolder(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="max-h-[75vh] w-full max-w-md overflow-y-auto rounded-t-3xl border border-white/40
|
||||||
|
bg-white/90 p-5 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-neutral-900/90
|
||||||
|
sm:rounded-3xl"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-2">
|
||||||
|
{openFolder && renamingFolder ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={folderNameDraft}
|
||||||
|
onChange={(e) => setFolderNameDraft(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
setRenamingFolder(false);
|
||||||
|
if (folderNameDraft.trim()) renameFolder(openFolder, folderNameDraft.trim());
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||||
|
}}
|
||||||
|
className="flex-1 rounded-lg border border-black/10 bg-white px-2 py-1 text-base font-semibold
|
||||||
|
text-black outline-none dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h2 className="flex items-center gap-2 text-base font-semibold text-black dark:text-white">
|
||||||
|
{openFolderId === "__recent__" ? "Zuletzt besucht" : openFolderId === "__readlater__" ? "Später lesen" : openFolder?.name}
|
||||||
|
{openFolder ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFolderNameDraft(openFolder.name);
|
||||||
|
setRenamingFolder(true);
|
||||||
|
}}
|
||||||
|
aria-label="Ordner umbenennen"
|
||||||
|
className="text-black/30 hover:text-black/60 dark:text-white/30 dark:hover:text-white/60"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPen} className="text-xs" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpenFolderId(null);
|
||||||
|
setRenamingFolder(false);
|
||||||
|
}}
|
||||||
|
aria-label="Schließen"
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-black/50
|
||||||
|
hover:bg-black/5 dark:text-white/50 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faXmark} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{openFolderId === "__readlater__" ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!readLaterUrl.trim()) return;
|
||||||
|
onAddReadLater(readLaterUrl.trim());
|
||||||
|
setReadLaterUrl("");
|
||||||
|
}}
|
||||||
|
className="flex gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={readLaterUrl}
|
||||||
|
onChange={(e) => setReadLaterUrl(e.target.value)}
|
||||||
|
placeholder="Link einfügen …"
|
||||||
|
className="flex-1 rounded-xl border border-black/10 bg-white/70 px-3 py-2 text-sm text-black
|
||||||
|
outline-none placeholder:text-black/30 focus:border-indigo-400/60 dark:border-white/10
|
||||||
|
dark:bg-white/5 dark:text-white dark:placeholder:text-white/30"
|
||||||
|
/>
|
||||||
|
<Button type="submit" variant="secondary" size="sm">
|
||||||
|
Merken
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{readLaterItems.length > 0 ? (
|
||||||
|
<div className="flex flex-col divide-y divide-black/5 dark:divide-white/10">
|
||||||
|
{[...readLaterItems]
|
||||||
|
.sort((a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime())
|
||||||
|
.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen({ url: item.url, id: item.id, kind: "bookmark" })}
|
||||||
|
className="flex items-center gap-3 py-2.5 text-left"
|
||||||
|
>
|
||||||
|
<TileIcon favicon={item.favicon} label={item.displayName} size="small" />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm text-black dark:text-white">{item.displayName}</span>
|
||||||
|
<span className="block text-[11px] text-black/40 dark:text-white/40">
|
||||||
|
{formatSavedAt(item.savedAt)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-black/40 dark:text-white/40">Noch nichts gemerkt.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : openFolderId === "__recent__" ? (
|
||||||
|
<div className="grid grid-cols-4 gap-x-3 gap-y-5">
|
||||||
|
{recentVisits.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen({ url: item.url, id: item.id, kind: item.kind ?? "service" })}
|
||||||
|
className="flex flex-col items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<TileIcon favicon={item.favicon} label={item.displayName} />
|
||||||
|
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
|
||||||
|
{item.displayName}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : openFolder ? (
|
||||||
|
<div className="grid grid-cols-4 gap-x-3 gap-y-5">
|
||||||
|
{openFolder.items.map((ref) => {
|
||||||
|
const item = resolveRef(ref);
|
||||||
|
if (!item) return null;
|
||||||
|
return (
|
||||||
|
<div key={`${ref.type}:${ref.id}`} className="relative flex flex-col items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeFromFolder(openFolder, ref)}
|
||||||
|
aria-label={`${item.displayName} aus Ordner entfernen`}
|
||||||
|
className="absolute -right-1 -top-1 z-10 flex h-5 w-5 items-center justify-center
|
||||||
|
rounded-full bg-black/60 text-[10px] text-white shadow hover:bg-black/80"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faXmark} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen({ url: item.url, id: item.id, kind: item.type })}
|
||||||
|
className="flex flex-col items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<TileIcon favicon={item.favicon} label={item.displayName} />
|
||||||
|
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
|
||||||
|
{item.displayName}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Such-Overlay */}
|
||||||
|
{searchOpen ? (
|
||||||
|
<div className="fixed inset-0 z-40 flex flex-col bg-white/95 backdrop-blur-xl dark:bg-neutral-950/95">
|
||||||
|
<div className="flex items-center gap-2 px-4 pb-3 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
|
||||||
|
<div className="flex flex-1 items-center gap-3 rounded-full border border-black/10 bg-white px-4 py-3 shadow-sm dark:border-white/10 dark:bg-neutral-900">
|
||||||
|
<FontAwesomeIcon icon={faMagnifyingGlass} className="text-black/40 dark:text-white/40" />
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}}
|
||||||
|
placeholder="Dienst oder Lesezeichen suchen …"
|
||||||
|
className="flex-1 bg-transparent text-sm text-black outline-none placeholder:text-black/30
|
||||||
|
dark:text-white dark:placeholder:text-white/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeSearch}
|
||||||
|
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-black/60
|
||||||
|
hover:bg-black/5 dark:text-white/60 dark:hover:bg-white/10"
|
||||||
|
aria-label="Suche schließen"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faXmark} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6">
|
||||||
|
<ResultsList
|
||||||
|
results={searchResults}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
categoryColors={categoryColors}
|
||||||
|
onHover={setSelectedIndex}
|
||||||
|
onOpen={(item) => {
|
||||||
|
onOpen(item as OpenTarget);
|
||||||
|
closeSearch();
|
||||||
|
}}
|
||||||
|
onToggleFavorite={onToggleFavorite}
|
||||||
|
emptyLabel={query.trim().length === 0 ? "Tippe, um zu suchen." : "Keine Treffer."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
apps/frontend/src/components/ScrollToTopButton.tsx
Normal file
37
apps/frontend/src/components/ScrollToTopButton.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faArrowUp } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schwebender Button unten rechts, der zum Seitenanfang zurückscrollt.
|
||||||
|
* Erscheint erst, sobald tatsächlich runtergescrollt wurde (kein Sinn, ihn
|
||||||
|
* direkt am Anfang der Seite schon anzuzeigen).
|
||||||
|
*/
|
||||||
|
export function ScrollToTopButton() {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onScroll() {
|
||||||
|
setVisible(window.scrollY > 300);
|
||||||
|
}
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||||
|
title="Nach oben scrollen"
|
||||||
|
aria-label="Nach oben scrollen"
|
||||||
|
className="fixed bottom-20 right-5 z-30 flex h-11 w-11 items-center justify-center rounded-full
|
||||||
|
border border-black/10 bg-white/90 text-lg text-black/70 shadow-lg backdrop-blur-md
|
||||||
|
transition-opacity hover:bg-white dark:border-white/10 dark:bg-neutral-900/90
|
||||||
|
dark:text-white/70 dark:hover:bg-neutral-900"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faArrowUp} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
apps/frontend/src/hooks/useBackendHealth.ts
Normal file
34
apps/frontend/src/hooks/useBackendHealth.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { HealthStatus } from "@launchpad/shared";
|
||||||
|
|
||||||
|
export function useBackendHealth() {
|
||||||
|
const [health, setHealth] = useState<HealthStatus | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/health");
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data: HealthStatus = await res.json();
|
||||||
|
if (!cancelled) {
|
||||||
|
setHealth(data);
|
||||||
|
setError(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check();
|
||||||
|
const interval = setInterval(check, 10_000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { health, error };
|
||||||
|
}
|
||||||
17
apps/frontend/src/hooks/useBookmarks.ts
Normal file
17
apps/frontend/src/hooks/useBookmarks.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { Bookmark } from "@launchpad/shared";
|
||||||
|
|
||||||
|
async function fetchBookmarks(): Promise<Bookmark[]> {
|
||||||
|
const res = await fetch("/api/bookmarks");
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Lesezeichen konnten nicht geladen werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBookmarks() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["bookmarks"],
|
||||||
|
queryFn: fetchBookmarks,
|
||||||
|
});
|
||||||
|
}
|
||||||
17
apps/frontend/src/hooks/useCategories.ts
Normal file
17
apps/frontend/src/hooks/useCategories.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { Category } from "@launchpad/shared";
|
||||||
|
|
||||||
|
async function fetchCategories(): Promise<Category[]> {
|
||||||
|
const res = await fetch("/api/categories");
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Kategorien konnten nicht geladen werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCategories() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["categories"],
|
||||||
|
queryFn: fetchCategories,
|
||||||
|
});
|
||||||
|
}
|
||||||
25
apps/frontend/src/hooks/useDetectedApis.ts
Normal file
25
apps/frontend/src/hooks/useDetectedApis.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export interface DetectedApiEntry {
|
||||||
|
id: string;
|
||||||
|
serviceId: string;
|
||||||
|
path: string;
|
||||||
|
type: string;
|
||||||
|
status: number;
|
||||||
|
detectedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDetectedApis(): Promise<DetectedApiEntry[]> {
|
||||||
|
const res = await fetch("/api/detected-apis");
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`APIs konnten nicht geladen werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDetectedApis() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["detected-apis"],
|
||||||
|
queryFn: fetchDetectedApis,
|
||||||
|
});
|
||||||
|
}
|
||||||
21
apps/frontend/src/hooks/useDevices.ts
Normal file
21
apps/frontend/src/hooks/useDevices.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { Device, Service } from "@launchpad/shared";
|
||||||
|
|
||||||
|
export interface DeviceWithServices extends Device {
|
||||||
|
services: Service[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDevices(): Promise<DeviceWithServices[]> {
|
||||||
|
const res = await fetch("/api/devices");
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Geräte konnten nicht geladen werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDevices() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["devices"],
|
||||||
|
queryFn: fetchDevices,
|
||||||
|
});
|
||||||
|
}
|
||||||
110
apps/frontend/src/hooks/useHomeLayout.ts
Normal file
110
apps/frontend/src/hooks/useHomeLayout.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useSettings } from "./useSettings.js";
|
||||||
|
|
||||||
|
export interface ItemRef {
|
||||||
|
id: string;
|
||||||
|
type: "service" | "bookmark";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HomeSlot =
|
||||||
|
| { id: string; kind: "item"; ref: ItemRef }
|
||||||
|
| { id: string; kind: "folder"; name: string; items: ItemRef[] };
|
||||||
|
|
||||||
|
export interface HomeLayout {
|
||||||
|
grid: HomeSlot[];
|
||||||
|
dock: HomeSlot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_DOCK_SLOTS = 5;
|
||||||
|
|
||||||
|
function refKey(ref: ItemRef): string {
|
||||||
|
return `${ref.type}:${ref.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotId(): string {
|
||||||
|
return `slot-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gleicht ein gespeichertes Layout mit der AKTUELLEN Favoriten-Liste ab:
|
||||||
|
* - Favoriten, die noch in keinem Slot vorkommen (neu als Favorit markiert,
|
||||||
|
* oder allererster Aufruf ohne gespeichertes Layout), werden hinten an den
|
||||||
|
* Hauptbereich angehängt.
|
||||||
|
* - Einträge, die nicht mehr in der Favoriten-Liste sind (Favorit entfernt
|
||||||
|
* oder Dienst/Lesezeichen gelöscht), werden aus Slots UND aus Ordnern
|
||||||
|
* entfernt. Dadurch leer gewordene Ordner werden mit entfernt.
|
||||||
|
* Rein bereinigend, verändert nie die vom Nutzer gewählte Reihenfolge
|
||||||
|
* bestehender Einträge.
|
||||||
|
*/
|
||||||
|
function reconcile(layout: HomeLayout, favorites: ItemRef[]): HomeLayout {
|
||||||
|
const favoriteKeys = new Set(favorites.map(refKey));
|
||||||
|
const placedKeys = new Set<string>();
|
||||||
|
|
||||||
|
const cleanSlots = (slots: HomeSlot[]): HomeSlot[] =>
|
||||||
|
slots
|
||||||
|
.map((slot): HomeSlot | null => {
|
||||||
|
if (slot.kind === "item") {
|
||||||
|
if (!favoriteKeys.has(refKey(slot.ref))) return null;
|
||||||
|
placedKeys.add(refKey(slot.ref));
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
const items = slot.items.filter((ref) => {
|
||||||
|
if (!favoriteKeys.has(refKey(ref))) return false;
|
||||||
|
placedKeys.add(refKey(ref));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return { ...slot, items };
|
||||||
|
})
|
||||||
|
.filter((s): s is HomeSlot => s !== null);
|
||||||
|
|
||||||
|
const grid = cleanSlots(layout.grid);
|
||||||
|
const dock = cleanSlots(layout.dock);
|
||||||
|
|
||||||
|
const missing = favorites.filter((ref) => !placedKeys.has(refKey(ref)));
|
||||||
|
const newSlots: HomeSlot[] = missing.map((ref) => ({ id: slotId(), kind: "item", ref }));
|
||||||
|
|
||||||
|
return { grid: [...grid, ...newSlots], dock };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHomeLayout(favorites: ItemRef[]) {
|
||||||
|
const { data: settings } = useSettings();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const layout = useMemo<HomeLayout>(() => {
|
||||||
|
let parsed: HomeLayout = { grid: [], dock: [] };
|
||||||
|
if (settings?.mobileHomeLayout) {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(settings.mobileHomeLayout) as Partial<HomeLayout>;
|
||||||
|
parsed = { grid: raw.grid ?? [], dock: raw.dock ?? [] };
|
||||||
|
} catch {
|
||||||
|
parsed = { grid: [], dock: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reconcile(parsed, favorites);
|
||||||
|
// favorites als JSON-String in der dep-Liste, damit sich das Memo nur bei
|
||||||
|
// TATSÄCHLICH geänderten Favoriten neu berechnet, nicht bei jedem Render
|
||||||
|
// (favorites wird von außen als neues Array-Objekt reincegeben).
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [settings?.mobileHomeLayout, JSON.stringify(favorites)]);
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async (next: HomeLayout) => {
|
||||||
|
const res = await fetch("/api/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mobileHomeLayout: next }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Layout konnte nicht gespeichert werden (HTTP ${res.status})`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
layout,
|
||||||
|
saveLayout: (next: HomeLayout) => saveMutation.mutate(next),
|
||||||
|
maxDockSlots: MAX_DOCK_SLOTS,
|
||||||
|
};
|
||||||
|
}
|
||||||
28
apps/frontend/src/hooks/useIsMobile.ts
Normal file
28
apps/frontend/src/hooks/useIsMobile.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT_QUERY = "(max-width: 640px)";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt eine mobile Bildschirmbreite (Tailwinds "sm"-Breakpoint, 640px) -
|
||||||
|
* genutzt, um das neue Android-Startbildschirm-Design (siehe
|
||||||
|
* MobileAndroidHome.tsx) NUR auf schmalen Bildschirmen zu zeigen, auch wenn
|
||||||
|
* es in den Einstellungen aktiviert ist. Auf Desktop-Breite bleibt immer das
|
||||||
|
* bisherige Design, unabhängig von der Einstellung - das neue Design ist
|
||||||
|
* ausdrücklich als mobile Alternative gedacht, nicht als Ersatz für die
|
||||||
|
* Desktop-Ansicht.
|
||||||
|
*/
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
const [isMobile, setIsMobile] = useState(
|
||||||
|
() => typeof window !== "undefined" && window.matchMedia(MOBILE_BREAKPOINT_QUERY).matches
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia(MOBILE_BREAKPOINT_QUERY);
|
||||||
|
const onChange = () => setIsMobile(mql.matches);
|
||||||
|
onChange();
|
||||||
|
mql.addEventListener("change", onChange);
|
||||||
|
return () => mql.removeEventListener("change", onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
18
apps/frontend/src/hooks/useLogs.ts
Normal file
18
apps/frontend/src/hooks/useLogs.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { ScanLogEntry } from "@launchpad/shared";
|
||||||
|
|
||||||
|
async function fetchLogs(): Promise<ScanLogEntry[]> {
|
||||||
|
const res = await fetch("/api/logs");
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Logs konnten nicht geladen werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogs() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["logs"],
|
||||||
|
queryFn: fetchLogs,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
40
apps/frontend/src/hooks/usePersistedState.ts
Normal file
40
apps/frontend/src/hooks/usePersistedState.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie useState, aber der Wert überlebt zusätzlich das Verlassen und erneute
|
||||||
|
* Betreten einer Seite (z. B. Scanner -> Geräte -> zurück zu Scanner), ohne
|
||||||
|
* dass ein laufender Scan erneut ausgeführt werden muss. Nutzt sessionStorage
|
||||||
|
* (pro Browser-Tab, weg nach Schließen des Tabs - bewusst kein dauerhafter
|
||||||
|
* Speicher, da es sich um vorübergehende "wartet auf Bestätigung"-Zustände
|
||||||
|
* handelt, keine echten Nutzdaten).
|
||||||
|
*/
|
||||||
|
export function usePersistedState<T>(key: string, initial: T): [T, (value: T | ((prev: T) => T)) => void] {
|
||||||
|
const storageKey = `launchpad:${key}`;
|
||||||
|
|
||||||
|
const [state, setState] = useState<T>(() => {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(storageKey);
|
||||||
|
return raw !== null ? (JSON.parse(raw) as T) : initial;
|
||||||
|
} catch {
|
||||||
|
return initial;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const setPersisted = useCallback(
|
||||||
|
(value: T | ((prev: T) => T)) => {
|
||||||
|
setState((prev) => {
|
||||||
|
const next = typeof value === "function" ? (value as (p: T) => T)(prev) : value;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(storageKey, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
// z. B. Speicher voll oder Privatmodus - dann bleibt der Zustand
|
||||||
|
// wenigstens für den Rest dieser Sitzung im Memory erhalten.
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[storageKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
return [state, setPersisted];
|
||||||
|
}
|
||||||
17
apps/frontend/src/hooks/usePlugins.ts
Normal file
17
apps/frontend/src/hooks/usePlugins.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { PluginInfo } from "@launchpad/shared";
|
||||||
|
|
||||||
|
async function fetchPlugins(): Promise<PluginInfo[]> {
|
||||||
|
const res = await fetch("/api/plugins");
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Plugins konnten nicht geladen werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlugins() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["plugins"],
|
||||||
|
queryFn: fetchPlugins,
|
||||||
|
});
|
||||||
|
}
|
||||||
24
apps/frontend/src/hooks/useReadLater.ts
Normal file
24
apps/frontend/src/hooks/useReadLater.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export interface ReadLaterItem {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
displayName: string;
|
||||||
|
favicon: string | null;
|
||||||
|
savedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchReadLater(): Promise<ReadLaterItem[]> {
|
||||||
|
const res = await fetch("/api/read-later");
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Später-lesen-Liste konnte nicht geladen werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReadLater() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["read-later"],
|
||||||
|
queryFn: fetchReadLater,
|
||||||
|
});
|
||||||
|
}
|
||||||
29
apps/frontend/src/hooks/useRecentVisits.ts
Normal file
29
apps/frontend/src/hooks/useRecentVisits.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { Bookmark, Service } from "@launchpad/shared";
|
||||||
|
|
||||||
|
export type RecentVisitItem =
|
||||||
|
| (Service & { kind: "service" })
|
||||||
|
| (Bookmark & { kind: "bookmark" });
|
||||||
|
|
||||||
|
async function fetchRecentVisits(): Promise<RecentVisitItem[]> {
|
||||||
|
const res = await fetch("/api/recent-visits");
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Zuletzt besucht konnte nicht geladen werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRecentVisits() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["recent-visits"],
|
||||||
|
queryFn: fetchRecentVisits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordVisit(itemType: "service" | "bookmark", itemId: string): Promise<void> {
|
||||||
|
await fetch("/api/recent-visits", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ itemType, itemId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
69
apps/frontend/src/hooks/useScanActivity.ts
Normal file
69
apps/frontend/src/hooks/useScanActivity.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface ScanActivity {
|
||||||
|
running: boolean;
|
||||||
|
pendingCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geräteübergreifender Hinweis, ob gerade ein Scan läuft oder es
|
||||||
|
* unbestätigte Scan-Ergebnisse gibt - für ein Abzeichen am
|
||||||
|
* "Scanner"-Navigationspunkt, sichtbar von jeder Admin-Seite UND jedem
|
||||||
|
* Gerät/Browser aus, da der Zustand jetzt vom BACKEND abgefragt wird
|
||||||
|
* (siehe apps/backend/src/scanJobs.ts), nicht mehr aus dem lokalen
|
||||||
|
* sessionStorage des jeweiligen Browsers gelesen wird - Ersteres kann
|
||||||
|
* naturgemäß nicht geräteübergreifend sein.
|
||||||
|
*/
|
||||||
|
export function useScanActivity(): ScanActivity {
|
||||||
|
const [activity, setActivity] = useState<ScanActivity>({ running: false, pendingCount: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const [devicesRes, apisRes, fritzboxRes, activeDevicesRes] = await Promise.all([
|
||||||
|
fetch("/api/scan/devices/all/status"),
|
||||||
|
fetch("/api/scan/apis/status"),
|
||||||
|
fetch("/api/scan/fritzbox/status"),
|
||||||
|
fetch("/api/scan/devices/active"),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const devicesStatus = devicesRes.ok ? await devicesRes.json() : null;
|
||||||
|
const apisStatus = apisRes.ok ? await apisRes.json() : null;
|
||||||
|
const fritzboxStatus = fritzboxRes.ok ? await fritzboxRes.json() : null;
|
||||||
|
// Manuelle Einzelgeräte-Scans (Button auf der Geräteseite) laufen
|
||||||
|
// NICHT über den Sammel-Scan-Job, sondern über eine einfache,
|
||||||
|
// blockierende Anfrage - ohne diesen zusätzlichen Abruf wäre so ein
|
||||||
|
// Scan für jeden anderen Browser/jedes andere Gerät unsichtbar
|
||||||
|
// gewesen (siehe Bugreport).
|
||||||
|
const activeDevices = activeDevicesRes.ok ? await activeDevicesRes.json() : null;
|
||||||
|
|
||||||
|
const running =
|
||||||
|
!!devicesStatus?.running ||
|
||||||
|
!!apisStatus?.running ||
|
||||||
|
!!fritzboxStatus?.running ||
|
||||||
|
(activeDevices?.deviceIds?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
const lastResult = devicesStatus?.lastResult;
|
||||||
|
const pendingCount = lastResult
|
||||||
|
? (lastResult.nameChanges?.length ?? 0) + (lastResult.staleServices?.length ?? 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
setActivity({ running, pendingCount });
|
||||||
|
} catch {
|
||||||
|
// Netzwerk-Hänger beim Poll ignorieren, nächster Versuch folgt.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
poll();
|
||||||
|
const interval = setInterval(poll, 2000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return activity;
|
||||||
|
}
|
||||||
17
apps/frontend/src/hooks/useServices.ts
Normal file
17
apps/frontend/src/hooks/useServices.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { Service } from "@launchpad/shared";
|
||||||
|
|
||||||
|
async function fetchServices(): Promise<Service[]> {
|
||||||
|
const res = await fetch("/api/services");
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Dienste konnten nicht geladen werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServices() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["services"],
|
||||||
|
queryFn: fetchServices,
|
||||||
|
});
|
||||||
|
}
|
||||||
27
apps/frontend/src/hooks/useSettings.ts
Normal file
27
apps/frontend/src/hooks/useSettings.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
recentVisitsLimit: number;
|
||||||
|
readLaterLimit: number;
|
||||||
|
staleDeviceThresholdDays: number;
|
||||||
|
liveStatusEnabled: boolean;
|
||||||
|
liveStatusIntervalMinutes: number;
|
||||||
|
liveStatusLastRunAt: string | null;
|
||||||
|
mobileHomeStyle: "classic" | "android";
|
||||||
|
mobileHomeLayout: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSettings(): Promise<AppSettings> {
|
||||||
|
const res = await fetch("/api/settings");
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Einstellungen konnten nicht geladen werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettings() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["settings"],
|
||||||
|
queryFn: fetchSettings,
|
||||||
|
});
|
||||||
|
}
|
||||||
22
apps/frontend/src/hooks/useTheme.ts
Normal file
22
apps/frontend/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export type Theme = "light" | "dark";
|
||||||
|
|
||||||
|
export function useTheme(): [Theme, () => void] {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
if (typeof window === "undefined") return "dark";
|
||||||
|
const stored = window.localStorage.getItem("launchpad-theme");
|
||||||
|
if (stored === "light" || stored === "dark") return stored;
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||||
|
window.localStorage.setItem("launchpad-theme", theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggle = () => setTheme((t) => (t === "dark" ? "light" : "dark"));
|
||||||
|
return [theme, toggle];
|
||||||
|
}
|
||||||
32
apps/frontend/src/index.css
Normal file
32
apps/frontend/src/index.css
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
"Inter",
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sagt dem Browser, dass native Formularelemente (select/option, Checkbox,
|
||||||
|
* Datepicker …) im jeweiligen Theme gerendert werden sollen. Ohne das
|
||||||
|
* bleiben z. B. <option>-Listen in <select> auf vielen Browsern immer hell
|
||||||
|
* (weißer Text auf weißem Grund im Dark Mode), weil Tailwind-Klassen auf
|
||||||
|
* das native Dropdown-Popup keinen Einfluss haben.
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
61
apps/frontend/src/main.tsx
Normal file
61
apps/frontend/src/main.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
|
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||||
|
import { RouterProvider } from "@tanstack/react-router";
|
||||||
|
import { router } from "./router.js";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
// Muss VOR dem ersten Render laufen und unabhängig davon, welche Route
|
||||||
|
// zuerst geladen wird (z. B. Direktlink auf /admin/...) – vorher hing das
|
||||||
|
// Anwenden der Dark-Mode-Klasse an useTheme(), das nur auf Home/Einstellungen
|
||||||
|
// aufgerufen wurde, wodurch andere Admin-Seiten beim Direktaufruf immer hell
|
||||||
|
// starteten, egal was gespeichert war.
|
||||||
|
function applyStoredTheme() {
|
||||||
|
const stored = window.localStorage.getItem("launchpad-theme");
|
||||||
|
const theme =
|
||||||
|
stored === "light" || stored === "dark"
|
||||||
|
? stored
|
||||||
|
: window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||||
|
}
|
||||||
|
applyStoredTheme();
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// NEU: Ohne das hier verlor ein ECHTER Seiten-Reload (z. B. "Runterziehen
|
||||||
|
// zum Aktualisieren" auf dem Handy) den kompletten Abfrage-Cache - alle
|
||||||
|
// Daten (Dienste, Lesezeichen, Kategorien, "Zuletzt besucht", Einstellungen)
|
||||||
|
// mussten neu vom Server geholt werden, bis dahin zeigte die Startseite
|
||||||
|
// zwangsläufig einen leeren Lade-Platzhalter (siehe Bugreport-Screenshot).
|
||||||
|
// Der Persister spiegelt den Cache zusätzlich in localStorage - beim
|
||||||
|
// nächsten Laden wird SOFORT der zuletzt bekannte Stand angezeigt (keine
|
||||||
|
// Lücke mehr), während im Hintergrund still neu geladen wird und die
|
||||||
|
// Ansicht bei Abweichungen automatisch aktualisiert. maxAge begrenzt, wie
|
||||||
|
// alt ein aus dem Speicher geladener Stand höchstens sein darf, bevor er
|
||||||
|
// verworfen wird (dann normaler Ladevorgang wie bisher).
|
||||||
|
const persister = createSyncStoragePersister({
|
||||||
|
storage: window.localStorage,
|
||||||
|
key: "launchpad-query-cache",
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{ persister, maxAge: 24 * 60 * 60 * 1000 }}
|
||||||
|
>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</PersistQueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
139
apps/frontend/src/router.tsx
Normal file
139
apps/frontend/src/router.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { Outlet, createRootRoute, createRoute, createRouter, redirect } from "@tanstack/react-router";
|
||||||
|
import { HomePage } from "./routes/HomePage.js";
|
||||||
|
import { SearchRedirectPage } from "./routes/SearchRedirectPage.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 { BookmarksPage } from "./routes/admin/BookmarksPage.js";
|
||||||
|
import { CategoriesPage } from "./routes/admin/CategoriesPage.js";
|
||||||
|
import { ScannerPage } from "./routes/admin/ScannerPage.js";
|
||||||
|
import { ApisPage } from "./routes/admin/ApisPage.js";
|
||||||
|
import { ReadLaterPage } from "./routes/admin/ReadLaterPage.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 searchRedirectRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/search",
|
||||||
|
component: SearchRedirectPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 adminBookmarksRoute = createRoute({
|
||||||
|
getParentRoute: () => adminRoute,
|
||||||
|
path: "/bookmarks",
|
||||||
|
component: BookmarksPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminCategoriesRoute = createRoute({
|
||||||
|
getParentRoute: () => adminRoute,
|
||||||
|
path: "/categories",
|
||||||
|
component: CategoriesPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminScannerRoute = createRoute({
|
||||||
|
getParentRoute: () => adminRoute,
|
||||||
|
path: "/scanner",
|
||||||
|
component: ScannerPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminApisRoute = createRoute({
|
||||||
|
getParentRoute: () => adminRoute,
|
||||||
|
path: "/apis",
|
||||||
|
component: ApisPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminReadLaterRoute = createRoute({
|
||||||
|
getParentRoute: () => adminRoute,
|
||||||
|
path: "/read-later",
|
||||||
|
component: ReadLaterPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
searchRedirectRoute,
|
||||||
|
adminRoute.addChildren([
|
||||||
|
adminIndexRoute,
|
||||||
|
adminDashboardRoute,
|
||||||
|
adminDevicesRoute,
|
||||||
|
adminServicesRoute,
|
||||||
|
adminBookmarksRoute,
|
||||||
|
adminCategoriesRoute,
|
||||||
|
adminScannerRoute,
|
||||||
|
adminApisRoute,
|
||||||
|
adminReadLaterRoute,
|
||||||
|
adminPluginsRoute,
|
||||||
|
adminSettingsRoute,
|
||||||
|
adminLogsRoute,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const router = createRouter({ routeTree });
|
||||||
|
|
||||||
|
declare module "@tanstack/react-router" {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router;
|
||||||
|
}
|
||||||
|
}
|
||||||
556
apps/frontend/src/routes/HomePage.tsx
Normal file
556
apps/frontend/src/routes/HomePage.tsx
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { SearchInput, StatusBadge, ResultsList, FavoritesBar, Favicon, Button } from "@launchpad/ui";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faGear, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { rankSearchResults, type SearchResult } from "@launchpad/shared";
|
||||||
|
import { useServices } from "../hooks/useServices.js";
|
||||||
|
import { useBookmarks } from "../hooks/useBookmarks.js";
|
||||||
|
import { useDevices } from "../hooks/useDevices.js";
|
||||||
|
import { useSettings } from "../hooks/useSettings.js";
|
||||||
|
import { useBackendHealth } from "../hooks/useBackendHealth.js";
|
||||||
|
import { useCategories } from "../hooks/useCategories.js";
|
||||||
|
import { useRecentVisits, recordVisit } from "../hooks/useRecentVisits.js";
|
||||||
|
import { useReadLater } from "../hooks/useReadLater.js";
|
||||||
|
import { useIsMobile } from "../hooks/useIsMobile.js";
|
||||||
|
import { MobileAndroidHome } from "../components/MobileAndroidHome.js";
|
||||||
|
|
||||||
|
async function toggleFavoriteRequest(item: SearchResult): Promise<void> {
|
||||||
|
const path = item.kind === "service" ? `/api/services/${item.id}` : `/api/bookmarks/${item.id}`;
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ favorite: !item.favorite }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Favorit konnte nicht aktualisiert werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reorderRequest(kind: "service" | "bookmark", orderedIds: string[]): Promise<void> {
|
||||||
|
const path = kind === "service" ? "/api/services/reorder" : "/api/bookmarks/reorder";
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(orderedIds.map((id, index) => ({ id, order: index }))),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Reihenfolge konnte nicht gespeichert werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveReadLaterRequest(url: string) {
|
||||||
|
const res = await fetch("/api/read-later", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error ?? `Konnte nicht gespeichert werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadLaterBox() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => saveReadLaterRequest(url.trim()),
|
||||||
|
onSuccess: () => {
|
||||||
|
setUrl("");
|
||||||
|
setOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["read-later"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!url.trim()) return;
|
||||||
|
mutation.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(true);
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-black/40 transition-colors
|
||||||
|
hover:text-black/70 dark:text-white/40 dark:hover:text-white/70"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPlus} className="text-[10px]" />
|
||||||
|
Link merken
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="flex w-full gap-2">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setUrl("");
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Link zum Später-Lesen hier einfügen …"
|
||||||
|
className="flex-1 rounded-xl border border-black/10 bg-white/70 px-3 py-1.5 text-sm
|
||||||
|
text-black outline-none placeholder:text-black/30 focus:border-indigo-400/60
|
||||||
|
dark:border-white/10 dark:bg-white/5 dark:text-white dark:placeholder:text-white/30
|
||||||
|
dark:focus:border-indigo-400/40"
|
||||||
|
/>
|
||||||
|
<Button type="submit" variant="secondary" size="sm" disabled={mutation.isPending || !url.trim()}>
|
||||||
|
{mutation.isPending ? "…" : "Merken"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
const [query, setQuery] = useState(
|
||||||
|
() => new URLSearchParams(window.location.search).get("q") ?? ""
|
||||||
|
);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const [resultsVisible, setResultsVisible] = useState(
|
||||||
|
() => new URLSearchParams(window.location.search).get("q") !== null
|
||||||
|
);
|
||||||
|
const { health, error: healthError } = useBackendHealth();
|
||||||
|
const { data: services, isLoading: servicesLoading, isError: servicesError } = useServices();
|
||||||
|
const { data: bookmarks, isLoading: bookmarksLoading } = useBookmarks();
|
||||||
|
const { data: devices } = useDevices();
|
||||||
|
const { data: settings, isLoading: settingsLoading } = useSettings();
|
||||||
|
const { data: categories, isLoading: categoriesLoading } = useCategories();
|
||||||
|
const { data: recentVisits, isLoading: recentVisitsLoading } = useRecentVisits();
|
||||||
|
const { data: readLaterItems } = useReadLater();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isSearching = query.trim().length > 0;
|
||||||
|
const isLoading = servicesLoading || bookmarksLoading;
|
||||||
|
|
||||||
|
function openItem(item: SearchResult | { url: string; id?: string; kind?: "service" | "bookmark" }) {
|
||||||
|
if ("kind" in item && item.kind === "device") return; // Geräte haben keine eigene URL, nicht anklickbar
|
||||||
|
window.open(item.url, "_blank", "noopener,noreferrer");
|
||||||
|
if ("kind" in item && (item.kind === "service" || item.kind === "bookmark") && item.id) {
|
||||||
|
recordVisit(item.kind, item.id).then(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["recent-visits"] });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryColors = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const c of categories ?? []) {
|
||||||
|
if (c.color) map[c.name] = c.color;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [categories]);
|
||||||
|
|
||||||
|
const toggleFavorite = useMutation({
|
||||||
|
mutationFn: toggleFavoriteRequest,
|
||||||
|
onSuccess: (_data, item) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [item.kind === "service" ? "services" : "bookmarks"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reorderServices = useMutation({
|
||||||
|
mutationFn: (ids: string[]) => reorderRequest("service", ids),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reorderBookmarks = useMutation({
|
||||||
|
mutationFn: (ids: string[]) => reorderRequest("bookmark", ids),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["bookmarks"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ausgeblendete Dienste (z. B. Fehlerseiten/nicht erreichbare Scan-Treffer,
|
||||||
|
// siehe Admin -> Dienste) tauchen in der Suche nicht auf. Geräte und
|
||||||
|
// Später-lesen-Einträge sind jetzt ebenfalls durchsuchbar (siehe
|
||||||
|
// rankSearchResults: Dienste stehen dabei immer zuerst, Geräte immer
|
||||||
|
// zuletzt - Geräte haben keine eigene URL zum Öffnen, siehe ResultsList).
|
||||||
|
const allItems: SearchResult[] = useMemo(() => {
|
||||||
|
const deviceHostnameById = new Map((devices ?? []).map((d) => [d.id, d.hostname]));
|
||||||
|
const deviceOnlineById = new Map((devices ?? []).map((d) => [d.id, d.online]));
|
||||||
|
const visibleServices: SearchResult[] = (services ?? [])
|
||||||
|
.filter((s) => s.visible)
|
||||||
|
.map((s) => ({
|
||||||
|
...s,
|
||||||
|
kind: "service" as const,
|
||||||
|
deviceHostname: deviceHostnameById.get(s.deviceId) ?? null,
|
||||||
|
deviceOnline: deviceOnlineById.get(s.deviceId) ?? false,
|
||||||
|
}));
|
||||||
|
const bookmarkItems: SearchResult[] = (bookmarks ?? []).map((b) => ({
|
||||||
|
...b,
|
||||||
|
kind: "bookmark" as const,
|
||||||
|
}));
|
||||||
|
const deviceItems: SearchResult[] = (devices ?? []).map((d) => ({
|
||||||
|
kind: "device" as const,
|
||||||
|
id: d.id,
|
||||||
|
displayName: d.hostname,
|
||||||
|
hostname: d.hostname,
|
||||||
|
ip: d.ip,
|
||||||
|
mac: d.mac,
|
||||||
|
online: d.online,
|
||||||
|
favicon: null,
|
||||||
|
category: null,
|
||||||
|
favorite: false,
|
||||||
|
order: 0,
|
||||||
|
// NUR die IP als Alias, NICHT die MAC-Adresse (siehe Bugreport): eine
|
||||||
|
// MAC wie "AA:BB:CC:DD:EE:55" enthält rein zufällig oft kurze
|
||||||
|
// Ziffernfolgen, die dann Geräte treffen ließen, ohne dass irgendwo
|
||||||
|
// in der Ergebniskarte ersichtlich war, WARUM ("55" fand z. B.
|
||||||
|
// "cam-garage", dessen Name/IP gar keine "55" enthielt - nur die MAC,
|
||||||
|
// die gar nicht angezeigt wird). Die IP-Suche ("47" findet
|
||||||
|
// "192.168.1.47") bleibt bewusst erhalten, das ist die eigentlich
|
||||||
|
// gewünschte/dokumentierte Funktion.
|
||||||
|
alias: d.ip ? [d.ip] : [],
|
||||||
|
description: d.ip,
|
||||||
|
}));
|
||||||
|
const readLaterSearchItems: SearchResult[] = (readLaterItems ?? []).map((r) => ({
|
||||||
|
kind: "readlater" as const,
|
||||||
|
id: r.id,
|
||||||
|
displayName: r.displayName,
|
||||||
|
hostname: "",
|
||||||
|
url: r.url,
|
||||||
|
favicon: r.favicon,
|
||||||
|
category: null,
|
||||||
|
favorite: false,
|
||||||
|
order: 0,
|
||||||
|
alias: [],
|
||||||
|
description: null,
|
||||||
|
}));
|
||||||
|
return [...visibleServices, ...bookmarkItems, ...deviceItems, ...readLaterSearchItems];
|
||||||
|
}, [services, bookmarks, devices, readLaterItems]);
|
||||||
|
|
||||||
|
const results = useMemo(() => rankSearchResults(allItems, query), [allItems, query]);
|
||||||
|
|
||||||
|
const favoriteServices = useMemo(
|
||||||
|
() =>
|
||||||
|
(services ?? [])
|
||||||
|
.filter((s) => s.visible && s.favorite)
|
||||||
|
.sort((a, b) => a.order - b.order),
|
||||||
|
[services]
|
||||||
|
);
|
||||||
|
|
||||||
|
const favoriteBookmarks = useMemo(
|
||||||
|
() => (bookmarks ?? []).filter((b) => b.favorite).sort((a, b) => a.order - b.order),
|
||||||
|
[bookmarks]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auswahl zurücksetzen, sobald sich die Trefferliste ändert
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [results.length, query]);
|
||||||
|
|
||||||
|
// Beim Tippen wieder einblenden (z. B. nachdem per Klick-außerhalb
|
||||||
|
// zugeklappt wurde und man weitertippt).
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSearching) setResultsVisible(true);
|
||||||
|
}, [isSearching]);
|
||||||
|
|
||||||
|
// Klick außerhalb von Suchfeld+Trefferliste klappt das Dropdown wieder ein
|
||||||
|
// (Desktop-Verhalten, Text bleibt erhalten). Bewusst gegen das tatsächlich
|
||||||
|
// gerenderte Listbox-Element geprüft (closest('[role="listbox"]')), nicht
|
||||||
|
// gegen dessen umgebende Scroll-Wrapper-Divs - die sind aus Layoutgründen
|
||||||
|
// auf volle Resthöhe gestreckt (siehe max-h-full-Trick in ResultsList),
|
||||||
|
// ein Ref darauf hätte fast den ganzen Bildschirm als "innerhalb" gezählt.
|
||||||
|
useEffect(() => {
|
||||||
|
function onPointerDown(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const insideSearch = searchContainerRef.current?.contains(target) ?? false;
|
||||||
|
const insideResults = target.closest('[role="listbox"]') !== null;
|
||||||
|
if (!insideSearch && !insideResults) {
|
||||||
|
setResultsVisible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onPointerDown);
|
||||||
|
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 (query.trim().length === 0) return; // keine sichtbare Liste, nichts zu steuern
|
||||||
|
|
||||||
|
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) openItem(target);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
inputRef.current?.blur();
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [results, selectedIndex, query]);
|
||||||
|
|
||||||
|
const isOnline = !healthError && health?.status === "ok";
|
||||||
|
const hasFavorites = favoriteServices.length > 0 || favoriteBookmarks.length > 0;
|
||||||
|
const readLaterLimit = settings?.readLaterLimit ?? 5;
|
||||||
|
const hasReadLaterChips = readLaterItems && readLaterItems.length > 0 && readLaterLimit > 0;
|
||||||
|
const showShelf = !isSearching || !resultsVisible;
|
||||||
|
const showResults = isSearching && resultsVisible;
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
// WICHTIG: Bei einem ECHTEN Seiten-Reload (z. B. "mit Runterziehen
|
||||||
|
// aktualisieren" auf dem Handy) ist settings.mobileHomeStyle beim
|
||||||
|
// allerersten Render noch nicht geladen (React-Query-Cache ist nach einem
|
||||||
|
// vollständigen Reload leer, die Server-Antwort kommt erst asynchron
|
||||||
|
// nach) - bis dahin wäre "classic" der Standard-Fallback gewesen und kurz
|
||||||
|
// sichtbar aufgeblitzt, bevor auf "android" umgeschaltet wird (siehe
|
||||||
|
// Bugreport). Die zuletzt bekannte Wahl wird daher zusätzlich in
|
||||||
|
// localStorage gespiegelt (überlebt einen Reload, im Gegensatz zum
|
||||||
|
// React-Query-Cache) und beim allerersten Render schon genutzt, bevor die
|
||||||
|
// eigentliche Serverantwort da ist - dieselbe Technik wie beim
|
||||||
|
// Dark-Mode-Flash (siehe index.html).
|
||||||
|
const [cachedMobileHomeStyle, setCachedMobileHomeStyle] = useState<string | null>(() =>
|
||||||
|
typeof window !== "undefined" ? window.localStorage.getItem("launchpad:mobileHomeStyle") : null
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings?.mobileHomeStyle && settings.mobileHomeStyle !== cachedMobileHomeStyle) {
|
||||||
|
window.localStorage.setItem("launchpad:mobileHomeStyle", settings.mobileHomeStyle);
|
||||||
|
setCachedMobileHomeStyle(settings.mobileHomeStyle);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [settings?.mobileHomeStyle]);
|
||||||
|
const effectiveMobileHomeStyle = settings?.mobileHomeStyle ?? cachedMobileHomeStyle ?? "classic";
|
||||||
|
const useAndroidHome = isMobile && effectiveMobileHomeStyle === "android";
|
||||||
|
// WICHTIG: Dienste/Lesezeichen/Kategorien/"Zuletzt besucht"/Einstellungen
|
||||||
|
// laden über getrennte, unabhängig voneinander abgeschlossene
|
||||||
|
// Netzwerk-Anfragen - kämen sie einzeln nacheinander im Raster an, würde
|
||||||
|
// z. B. die "Zuletzt besucht"-Kachel nachträglich auftauchen und alle
|
||||||
|
// Icons danach verschieben (genau das im Bugreport beschriebene
|
||||||
|
// "Verschieben, weil manche Icons schneller da sind als andere"). Daher
|
||||||
|
// wird das komplette Raster erst gerendert, wenn ALLE beteiligten
|
||||||
|
// Abfragen mindestens einmal durch sind - dann in einem Rutsch, ohne
|
||||||
|
// Nachrücken einzelner Kacheln.
|
||||||
|
const androidHomeDataReady =
|
||||||
|
!servicesLoading && !bookmarksLoading && !categoriesLoading && !recentVisitsLoading && !settingsLoading;
|
||||||
|
|
||||||
|
const addReadLaterMutation = useMutation({
|
||||||
|
mutationFn: (url: string) => saveReadLaterRequest(url),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["read-later"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (useAndroidHome) {
|
||||||
|
return (
|
||||||
|
<MobileAndroidHome
|
||||||
|
services={services ?? []}
|
||||||
|
bookmarks={bookmarks ?? []}
|
||||||
|
categories={categories ?? []}
|
||||||
|
categoryColors={categoryColors}
|
||||||
|
allSearchItems={allItems}
|
||||||
|
recentVisits={recentVisits ?? []}
|
||||||
|
readLaterItems={readLaterItems ?? []}
|
||||||
|
onOpen={openItem}
|
||||||
|
onToggleFavorite={(item) => toggleFavorite.mutate(item)}
|
||||||
|
onAddReadLater={(url) => addReadLaterMutation.mutate(url)}
|
||||||
|
isOnline={isOnline}
|
||||||
|
isReady={androidHomeDataReady}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col bg-gradient-to-b from-white to-neutral-100 dark:from-black
|
||||||
|
dark:to-neutral-950 ${showResults ? "h-dvh overflow-hidden" : "min-h-dvh"}`}
|
||||||
|
>
|
||||||
|
<div className="fixed right-6 top-6 z-10 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"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faGear} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kopfbereich: Titel, Suchfeld, Favoriten/Später-lesen/Zuletzt-besucht.
|
||||||
|
Nur im "aktiv suchend"-Zustand shrink-0 + scrollender Bereich
|
||||||
|
darunter - sonst normaler Dokumentfluss, damit der Footer direkt
|
||||||
|
nach dem Inhalt folgt statt mit einer riesigen Lücke ganz unten zu
|
||||||
|
kleben (siehe showResults-Fallunterscheidung oben). */}
|
||||||
|
<div className={`flex flex-col items-center gap-6 px-6 pb-6 pt-10 sm:pt-14 ${showResults ? "shrink-0" : ""}`}>
|
||||||
|
<div className="flex flex-col items-center gap-1 text-center">
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight text-black dark:text-white sm:text-2xl">
|
||||||
|
LaunchPad
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-black/40 dark:text-white/40">
|
||||||
|
Tippe, um deine Homelab-Dienste sofort zu öffnen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={searchContainerRef} className="w-full max-w-xl">
|
||||||
|
<SearchInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onClear={() => {
|
||||||
|
setQuery("");
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
onFocus={() => setResultsVisible(true)}
|
||||||
|
placeholder="Dienst oder Lesezeichen suchen … z. B. „frigate“"
|
||||||
|
hint="⌘K"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showShelf ? (
|
||||||
|
<div
|
||||||
|
className="w-full max-w-xl divide-y divide-black/[0.06] rounded-2xl border
|
||||||
|
border-black/[0.06] bg-black/[0.015] dark:divide-white/[0.06] dark:border-white/[0.06]
|
||||||
|
dark:bg-white/[0.02]"
|
||||||
|
>
|
||||||
|
{hasFavorites ? (
|
||||||
|
<div className="flex flex-col gap-2 p-2.5">
|
||||||
|
{favoriteServices.length > 0 ? (
|
||||||
|
<FavoritesBar
|
||||||
|
items={favoriteServices}
|
||||||
|
label="Favoriten · Dienste"
|
||||||
|
categoryColors={categoryColors}
|
||||||
|
onOpen={(item) => {
|
||||||
|
const service = favoriteServices.find((s) => s.id === item.id);
|
||||||
|
if (service) openItem({ ...service, kind: "service" });
|
||||||
|
}}
|
||||||
|
onReorder={(ids) => reorderServices.mutate(ids)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{favoriteBookmarks.length > 0 ? (
|
||||||
|
<FavoritesBar
|
||||||
|
items={favoriteBookmarks}
|
||||||
|
label="Favoriten · Lesezeichen"
|
||||||
|
categoryColors={categoryColors}
|
||||||
|
onOpen={(item) => {
|
||||||
|
const bookmark = favoriteBookmarks.find((b) => b.id === item.id);
|
||||||
|
if (bookmark) openItem({ ...bookmark, kind: "bookmark" });
|
||||||
|
}}
|
||||||
|
onReorder={(ids) => reorderBookmarks.mutate(ids)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{recentVisits && recentVisits.length > 0 ? (
|
||||||
|
<div className="p-2.5">
|
||||||
|
<FavoritesBar
|
||||||
|
items={recentVisits}
|
||||||
|
label="Zuletzt besucht"
|
||||||
|
categoryColors={categoryColors}
|
||||||
|
onOpen={(item) => {
|
||||||
|
const original = recentVisits.find((r) => r.id === item.id);
|
||||||
|
if (original) openItem(original);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="p-2.5">
|
||||||
|
<div className="mb-2.5 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wide text-black/35 dark:text-white/35">
|
||||||
|
Später lesen
|
||||||
|
</span>
|
||||||
|
<ReadLaterBox />
|
||||||
|
</div>
|
||||||
|
{hasReadLaterChips ? (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{readLaterItems!.slice(0, readLaterLimit).map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => window.open(item.url, "_blank", "noopener,noreferrer")}
|
||||||
|
title={item.displayName}
|
||||||
|
className="group flex w-12 flex-col items-center gap-0.5 rounded-lg p-1
|
||||||
|
transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.06]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg border
|
||||||
|
border-black/10 bg-white/80 shadow-sm transition-transform
|
||||||
|
group-hover:scale-105 dark:border-white/10 dark:bg-white/5"
|
||||||
|
>
|
||||||
|
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="md" />
|
||||||
|
</span>
|
||||||
|
<span className="line-clamp-2 w-full text-center text-[10px] leading-tight text-black/55 dark:text-white/55">
|
||||||
|
{item.displayName}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-black/30 dark:text-white/30">
|
||||||
|
Noch nichts gemerkt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollender Bereich: NUR die Trefferliste scrollt, nicht die ganze Seite */}
|
||||||
|
{showResults ? (
|
||||||
|
<div className="min-h-0 flex-1 px-6 pb-4">
|
||||||
|
<div className="mx-auto h-full max-w-xl">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-center text-sm text-black/40 dark:text-white/40">Lade …</p>
|
||||||
|
) : servicesError ? (
|
||||||
|
<p className="text-center text-sm text-red-500">
|
||||||
|
Dienste konnten nicht geladen werden.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ResultsList
|
||||||
|
results={results}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
categoryColors={categoryColors}
|
||||||
|
onHover={setSelectedIndex}
|
||||||
|
onOpen={openItem}
|
||||||
|
onToggleFavorite={(item) => toggleFavorite.mutate(item)}
|
||||||
|
emptyLabel={
|
||||||
|
allItems.length === 0
|
||||||
|
? "Noch nichts angelegt. Füge Dienste oder Lesezeichen im Adminbereich hinzu."
|
||||||
|
: "Keine Treffer für deine Suche."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={`flex flex-col items-center gap-1 pb-6 pt-2 ${showResults ? "shrink-0" : "mt-4"}`}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
apps/frontend/src/routes/SearchRedirectPage.tsx
Normal file
54
apps/frontend/src/routes/SearchRedirectPage.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { rankSearchResults, type SearchResult } from "@launchpad/shared";
|
||||||
|
import { useServices } from "../hooks/useServices.js";
|
||||||
|
import { useBookmarks } from "../hooks/useBookmarks.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ziel des OpenSearch-Eintrags (siehe /opensearch.xml): Browser mit
|
||||||
|
* zugewiesenem Kürzel (z. B. "lp") rufen "/search?q=<Suchbegriff>" auf.
|
||||||
|
* Gibt es einen eindeutigen Treffer, wird SOFORT dorthin weitergeleitet
|
||||||
|
* (window.location.replace, kein Eintrag in der Chronik). Gibt es keinen
|
||||||
|
* eindeutigen Treffer, landet man stattdessen auf der Startseite mit dem
|
||||||
|
* Suchbegriff bereits eingetragen - dort lässt sich die volle Trefferliste
|
||||||
|
* durchsehen.
|
||||||
|
*
|
||||||
|
* Bewusst rein client-seitig (kein eigener Backend-Redirect-Endpunkt nötig):
|
||||||
|
* nutzt exakt dieselben Daten/Rangfolge wie die normale Suche auf der
|
||||||
|
* Startseite (siehe rankSearchResults in @launchpad/shared).
|
||||||
|
*/
|
||||||
|
export function SearchRedirectPage() {
|
||||||
|
const { data: services, isLoading: servicesLoading } = useServices();
|
||||||
|
const { data: bookmarks, isLoading: bookmarksLoading } = useBookmarks();
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
const query = new URLSearchParams(window.location.search).get("q") ?? "";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (servicesLoading || bookmarksLoading) return;
|
||||||
|
if (!query.trim()) {
|
||||||
|
window.location.replace("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: SearchResult[] = [
|
||||||
|
...(services ?? []).filter((s) => s.visible).map((s) => ({ ...s, kind: "service" as const })),
|
||||||
|
...(bookmarks ?? []).map((b) => ({ ...b, kind: "bookmark" as const })),
|
||||||
|
];
|
||||||
|
const results = rankSearchResults(items, query);
|
||||||
|
|
||||||
|
if (results.length > 0 && results[0].kind !== "device") {
|
||||||
|
window.location.replace(results[0].url);
|
||||||
|
} else {
|
||||||
|
setFailed(true);
|
||||||
|
window.location.replace(`/?q=${encodeURIComponent(query)}`);
|
||||||
|
}
|
||||||
|
}, [servicesLoading, bookmarksLoading, services, bookmarks, query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-dvh flex-col items-center justify-center gap-2 bg-white text-black dark:bg-black dark:text-white">
|
||||||
|
<p className="text-sm text-black/50 dark:text-white/50">
|
||||||
|
{failed ? "Kein eindeutiger Treffer, leite zur Suche weiter …" : `Suche „${query}“ …`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
apps/frontend/src/routes/admin/AdminLayout.tsx
Normal file
179
apps/frontend/src/routes/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, Outlet, useRouterState } from "@tanstack/react-router";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||||
|
import {
|
||||||
|
faBars,
|
||||||
|
faXmark,
|
||||||
|
faHouse,
|
||||||
|
faChartLine,
|
||||||
|
faDesktop,
|
||||||
|
faLink,
|
||||||
|
faBookmark,
|
||||||
|
faThumbtack,
|
||||||
|
faMagnifyingGlass,
|
||||||
|
faPlug,
|
||||||
|
faTag,
|
||||||
|
faPuzzlePiece,
|
||||||
|
faGear,
|
||||||
|
faScroll,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { StatusBadge } from "@launchpad/ui";
|
||||||
|
import { useBackendHealth } from "../../hooks/useBackendHealth.js";
|
||||||
|
import { useScanActivity } from "../../hooks/useScanActivity.js";
|
||||||
|
import { ScrollToTopButton } from "../../components/ScrollToTopButton.js";
|
||||||
|
|
||||||
|
function ScanActivityBadge() {
|
||||||
|
const { running, pendingCount } = useScanActivity();
|
||||||
|
|
||||||
|
if (running) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title="Ein Scan läuft gerade"
|
||||||
|
className="ml-auto h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-blue-500"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pendingCount > 0) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={`${pendingCount} Scan-Ergebnis(se) warten auf Bestätigung`}
|
||||||
|
className="ml-auto flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full
|
||||||
|
bg-amber-500 px-1 text-[10px] font-medium text-white"
|
||||||
|
>
|
||||||
|
{pendingCount}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_ITEMS: { to: string; label: string; icon: IconDefinition }[] = [
|
||||||
|
{ to: "/", label: "Startseite", icon: faHouse },
|
||||||
|
{ to: "/admin/dashboard", label: "Dashboard", icon: faChartLine },
|
||||||
|
{ to: "/admin/devices", label: "Geräte", icon: faDesktop },
|
||||||
|
{ to: "/admin/services", label: "Dienste", icon: faLink },
|
||||||
|
{ to: "/admin/bookmarks", label: "Lesezeichen", icon: faBookmark },
|
||||||
|
{ to: "/admin/read-later", label: "Später lesen", icon: faThumbtack },
|
||||||
|
{ to: "/admin/scanner", label: "Scanner", icon: faMagnifyingGlass },
|
||||||
|
{ to: "/admin/apis", label: "APIs", icon: faPlug },
|
||||||
|
{ to: "/admin/categories", label: "Kategorien", icon: faTag },
|
||||||
|
{ to: "/admin/plugins", label: "Plugins", icon: faPuzzlePiece },
|
||||||
|
{ to: "/admin/settings", label: "Einstellungen", icon: faGear },
|
||||||
|
{ to: "/admin/logs", label: "Logs", icon: faScroll },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AdminLayout() {
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const { health, error: healthError } = useBackendHealth();
|
||||||
|
const isOnline = !healthError && health?.status === "ok";
|
||||||
|
|
||||||
|
// Drawer schließen, wenn per Navigation die Seite wechselt
|
||||||
|
useEffect(() => {
|
||||||
|
setMobileOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-950">
|
||||||
|
{/* Mobile-Kopfleiste mit Hamburger-Button, nur unterhalb des sm-Breakpoints
|
||||||
|
sichtbar. Fixiert am oberen Rand, damit sie bei langen Seiten (z. B.
|
||||||
|
Dienste-Tabelle) beim Scrollen sichtbar bleibt statt mit dem Inhalt
|
||||||
|
mitzuwandern - main hat dafür unten zusätzliches pt-16 auf Mobil. */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-x-0 top-0 z-20 flex items-center gap-3 border-b border-black/10
|
||||||
|
bg-white/80 px-4 py-3 backdrop-blur-md dark:border-white/10 dark:bg-neutral-950/80 sm:hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileOpen(true)}
|
||||||
|
aria-label="Menü öffnen"
|
||||||
|
className="rounded-lg p-1.5 text-black/70 hover:bg-black/5 dark:text-white/70 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<span className="block text-xl leading-none" aria-hidden>
|
||||||
|
<FontAwesomeIcon icon={faBars} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<Link to="/" className="text-base font-semibold text-black dark:text-white">
|
||||||
|
LaunchPad
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Abdunkelungs-Overlay hinter der aufgeklappten Drawer-Navigation (nur mobil) */}
|
||||||
|
{mobileOpen ? (
|
||||||
|
<div
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="fixed inset-0 z-30 bg-black/40 sm:hidden"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Immer "fixed" (nicht nur mobil) – sonst scrollt die Sidebar bei langen
|
||||||
|
Seiteninhalten mit dem restlichen Dokument mit und "Zurück zur Suche"
|
||||||
|
verschwindet erst nach dem Runterscrollen aus dem Blickfeld.
|
||||||
|
*/}
|
||||||
|
<aside
|
||||||
|
className={`fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r border-black/10
|
||||||
|
bg-white transition-transform duration-200 ease-out dark:border-white/10
|
||||||
|
dark:bg-neutral-950 sm:z-auto sm:w-56 sm:translate-x-0 sm:bg-white/70
|
||||||
|
sm:transition-none dark:sm:bg-white/5 ${
|
||||||
|
mobileOpen ? "translate-x-0" : "-translate-x-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between 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>
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
aria-label="Menü schließen"
|
||||||
|
className="rounded-lg p-1 text-black/50 hover:bg-black/5 dark:text-white/50 dark:hover:bg-white/10 sm:hidden"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faXmark} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-3">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const active = item.to === "/" ? pathname === "/" : 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 className="w-4 text-center" aria-hidden>
|
||||||
|
<FontAwesomeIcon icon={item.icon} />
|
||||||
|
</span>
|
||||||
|
{item.label}
|
||||||
|
{item.to === "/admin/scanner" ? <ScanActivityBadge /> : null}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="shrink-0 border-t border-black/10 p-3 dark:border-white/10">
|
||||||
|
<div className="px-3 py-1.5">
|
||||||
|
<StatusBadge
|
||||||
|
online={isOnline}
|
||||||
|
label={isOnline ? "Backend verbunden" : "Backend nicht erreichbar"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* pt-16 auf Mobil gleicht die jetzt fixierte Kopfleiste aus (sonst
|
||||||
|
würde der Seiteninhalt anfangs darunter verschwinden);
|
||||||
|
sm:ml-56 gleicht die fixierte Sidebar aus (statt Flexbox-Nebeneinander) */}
|
||||||
|
<main className="overflow-x-hidden p-4 pt-16 sm:ml-56 sm:p-8">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<ScrollToTopButton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/frontend/src/routes/admin/AdminPageHeader.tsx
Normal file
21
apps/frontend/src/routes/admin/AdminPageHeader.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface AdminPageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminPageHeader({ title, description, actions }: AdminPageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-black dark:text-white">{title}</h1>
|
||||||
|
{description ? (
|
||||||
|
<p className="mt-1 text-sm text-black/50 dark:text-white/50">{description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
apps/frontend/src/routes/admin/ApisPage.tsx
Normal file
78
apps/frontend/src/routes/admin/ApisPage.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useServices } from "../../hooks/useServices.js";
|
||||||
|
import { useDetectedApis, type DetectedApiEntry } from "../../hooks/useDetectedApis.js";
|
||||||
|
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" });
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApisPage() {
|
||||||
|
const { data: services } = useServices();
|
||||||
|
const { data: apis, isLoading, isError } = useDetectedApis();
|
||||||
|
|
||||||
|
const serviceById = new Map((services ?? []).map((s) => [s.id, s]));
|
||||||
|
const groups = new Map<string, DetectedApiEntry[]>();
|
||||||
|
for (const entry of apis ?? []) {
|
||||||
|
const list = groups.get(entry.serviceId) ?? [];
|
||||||
|
list.push(entry);
|
||||||
|
groups.set(entry.serviceId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AdminPageHeader
|
||||||
|
title="APIs"
|
||||||
|
description='Von einem eigenen, separaten Scan gefundene API-Endpunkte bekannter Dienste (siehe Admin -> Scanner -> "APIs jetzt scannen"). Läuft nie automatisch mit.'
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-black/40 dark:text-white/40">Lade …</p>
|
||||||
|
) : isError ? (
|
||||||
|
<p className="text-sm text-red-500">Konnte nicht geladen werden.</p>
|
||||||
|
) : groups.size === 0 ? (
|
||||||
|
<p className="text-sm text-black/40 dark:text-white/40">
|
||||||
|
Noch keine APIs gefunden. Auf der Scanner-Seite einmal „APIs jetzt scannen" ausführen.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from(groups.entries()).map(([serviceId, entries]) => {
|
||||||
|
const service = serviceById.get(serviceId);
|
||||||
|
return (
|
||||||
|
<div key={serviceId} className="rounded-2xl border border-black/10 p-4 dark:border-white/10">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h3 className="font-medium text-black dark:text-white">
|
||||||
|
{service?.displayName ?? "Unbekannter Dienst"}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-black/30 dark:text-white/30">
|
||||||
|
zuletzt geprüft: {formatDate(entries[0].detectedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-wrap gap-2">
|
||||||
|
{entries.map((e) => (
|
||||||
|
<li key={e.id}>
|
||||||
|
<a
|
||||||
|
href={`${service?.url ?? ""}${e.path}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={`HTTP ${e.status}`}
|
||||||
|
className="flex items-center gap-1.5 rounded-full bg-black/5 px-2.5 py-1 text-xs
|
||||||
|
text-black/70 hover:bg-black/10 dark:bg-white/10 dark:text-white/70
|
||||||
|
dark:hover:bg-white/20"
|
||||||
|
>
|
||||||
|
{e.type} · {e.path}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
615
apps/frontend/src/routes/admin/BookmarksPage.tsx
Normal file
615
apps/frontend/src/routes/admin/BookmarksPage.tsx
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
import { useMemo, useState, type DragEvent, type FormEvent } from "react";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faFloppyDisk, faPen, faTrash, faXmark, faSort, faSortUp, faSortDown, faStar as faStarSolid, faEye, faEyeSlash, faArrowLeft } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { faStar } from "@fortawesome/free-regular-svg-icons";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Button, Favicon, FaviconPicker } from "@launchpad/ui";
|
||||||
|
import type { Bookmark } from "@launchpad/shared";
|
||||||
|
import { suggestBookmarkCategory } from "@launchpad/shared";
|
||||||
|
import { useBookmarks } from "../../hooks/useBookmarks.js";
|
||||||
|
import { useServices } from "../../hooks/useServices.js";
|
||||||
|
import { useCategories } from "../../hooks/useCategories.js";
|
||||||
|
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||||
|
|
||||||
|
interface BookmarkPatch {
|
||||||
|
url?: string;
|
||||||
|
displayName?: string;
|
||||||
|
description?: string | null;
|
||||||
|
category?: string | null;
|
||||||
|
favorite?: boolean;
|
||||||
|
alias?: string[];
|
||||||
|
order?: number;
|
||||||
|
favicon?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBookmarkRequest(input: {
|
||||||
|
url: string;
|
||||||
|
category?: string;
|
||||||
|
description?: string;
|
||||||
|
}): Promise<Bookmark> {
|
||||||
|
const res = await fetch("/api/bookmarks", {
|
||||||
|
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 ?? `Lesezeichen konnte nicht angelegt werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchBookmark(id: string, patch: BookmarkPatch): Promise<Bookmark> {
|
||||||
|
const res = await fetch(`/api/bookmarks/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error ?? `Lesezeichen konnte nicht aktualisiert werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBookmarkRequest(id: string) {
|
||||||
|
const res = await fetch(`/api/bookmarks/${id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok && res.status !== 404) {
|
||||||
|
throw new Error(`Lesezeichen konnte nicht gelöscht werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reorderBookmarksRequest(entries: { id: string; order: number }[]) {
|
||||||
|
const res = await fetch("/api/bookmarks/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();
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEW_CATEGORY_VALUE = "__new__";
|
||||||
|
|
||||||
|
function CategorySelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
|
const { data: categories } = useCategories();
|
||||||
|
const isKnown = !value || categories?.some((c) => c.name === value);
|
||||||
|
const [isNew, setIsNew] = useState(!isKnown);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<select
|
||||||
|
value={isNew ? NEW_CATEGORY_VALUE : value}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value === NEW_CATEGORY_VALUE) {
|
||||||
|
setIsNew(true);
|
||||||
|
onChange("");
|
||||||
|
} else {
|
||||||
|
setIsNew(false);
|
||||||
|
onChange(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"
|
||||||
|
>
|
||||||
|
<option value="">– Keine –</option>
|
||||||
|
{categories?.map((c) => (
|
||||||
|
<option key={c.id} value={c.name}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value={NEW_CATEGORY_VALUE}>+ Neue Kategorie …</option>
|
||||||
|
</select>
|
||||||
|
{isNew ? (
|
||||||
|
<input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="Name der neuen Kategorie"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddBookmarkForm() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [category, setCategory] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createBookmarkRequest({
|
||||||
|
url: url.trim(),
|
||||||
|
category: category.trim() || undefined,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
setUrl("");
|
||||||
|
setCategory("");
|
||||||
|
setDescription("");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["bookmarks"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!url.trim()) return;
|
||||||
|
mutation.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kategorie-Vorschlag anhand der eingegebenen URL (rein heuristisch anhand
|
||||||
|
// des Hostnamens, siehe suggestBookmarkCategory in @launchpad/shared) - nur
|
||||||
|
// solange der Nutzer noch keine eigene Kategorie gewählt hat.
|
||||||
|
const suggestedCategory = category.trim() === "" ? suggestBookmarkCategory(url.trim()) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="mb-6 flex flex-wrap items-end gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">URL</label>
|
||||||
|
<input
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
className="w-64 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">
|
||||||
|
Kategorie
|
||||||
|
</label>
|
||||||
|
<CategorySelect value={category} onChange={setCategory} />
|
||||||
|
{suggestedCategory ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCategory(suggestedCategory)}
|
||||||
|
className="mt-1 block text-xs text-blue-600 hover:underline dark:text-blue-400"
|
||||||
|
>
|
||||||
|
Vorschlag: {suggestedCategory} übernehmen?
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
|
||||||
|
Beschreibung
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="optional"
|
||||||
|
className="w-48 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}>
|
||||||
|
{mutation.isPending ? "Lade Titel/Favicon …" : "Anlegen"}
|
||||||
|
</Button>
|
||||||
|
{mutation.isError ? (
|
||||||
|
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
|
||||||
|
) : null}
|
||||||
|
<span className="w-full text-xs text-black/40 dark:text-white/40">
|
||||||
|
Titel und Favicon werden automatisch von der Seite geladen, falls verfügbar.
|
||||||
|
</span>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDIT_FORM_COLSPAN = 7;
|
||||||
|
|
||||||
|
function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: allBookmarks } = useBookmarks();
|
||||||
|
const { data: allServices } = useServices();
|
||||||
|
const [displayName, setDisplayName] = useState(bookmark.displayName);
|
||||||
|
const [url, setUrl] = useState(bookmark.url);
|
||||||
|
const [category, setCategory] = useState(bookmark.category ?? "");
|
||||||
|
const [description, setDescription] = useState(bookmark.description ?? "");
|
||||||
|
const [alias, setAlias] = useState(bookmark.alias.join(", "));
|
||||||
|
|
||||||
|
const [favicon, setFavicon] = useState<string | null | undefined>(undefined);
|
||||||
|
const [faviconError, setFaviconError] = useState<string | null>(null);
|
||||||
|
const FAVICON_MAX_BYTES = 300 * 1024;
|
||||||
|
|
||||||
|
const existingFavicons = useMemo(() => {
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
for (const b of allBookmarks ?? []) {
|
||||||
|
if (b.favicon && b.id !== bookmark.id && !seen.has(b.favicon)) seen.set(b.favicon, b.displayName);
|
||||||
|
}
|
||||||
|
for (const s of allServices ?? []) {
|
||||||
|
if (s.favicon && !seen.has(s.favicon)) seen.set(s.favicon, s.displayName);
|
||||||
|
}
|
||||||
|
return Array.from(seen.entries());
|
||||||
|
}, [allBookmarks, allServices, bookmark.id]);
|
||||||
|
|
||||||
|
function handleFaviconFile(file: File | undefined) {
|
||||||
|
setFaviconError(null);
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size > FAVICON_MAX_BYTES) {
|
||||||
|
setFaviconError("Datei zu groß (max. 300 KB) – bitte ein kleineres Bild wählen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => setFavicon(reader.result as string);
|
||||||
|
reader.onerror = () => setFaviconError("Datei konnte nicht gelesen werden.");
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const faviconPreview = favicon === undefined ? bookmark.favicon : favicon;
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
patchBookmark(bookmark.id, {
|
||||||
|
displayName: displayName.trim(),
|
||||||
|
url: url.trim(),
|
||||||
|
category: category.trim() || null,
|
||||||
|
description: description.trim() || null,
|
||||||
|
alias: alias
|
||||||
|
.split(",")
|
||||||
|
.map((a) => a.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
...(favicon !== undefined ? { favicon } : {}),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["bookmarks"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||||
|
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={EDIT_FORM_COLSPAN} 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">Favicon</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Favicon src={faviconPreview} fallbackLetter={displayName} size="sm" />
|
||||||
|
<label className="cursor-pointer rounded-lg border border-black/10 px-2 py-1 text-xs
|
||||||
|
text-black/70 hover:bg-black/5 dark:border-white/10 dark:text-white/70 dark:hover:bg-white/10">
|
||||||
|
Hochladen
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => handleFaviconFile(e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<FaviconPicker existingFavicons={existingFavicons} onSelect={(url) => setFavicon(url)} />
|
||||||
|
{faviconPreview ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFavicon(null)}
|
||||||
|
className="text-xs text-black/40 underline dark:text-white/40"
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{faviconError ? <p className="mt-1 text-xs text-red-500">{faviconError}</p> : null}
|
||||||
|
</div>
|
||||||
|
<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">URL</label>
|
||||||
|
<input
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
className="w-56 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>
|
||||||
|
<CategorySelect value={category} onChange={setCategory} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">
|
||||||
|
Beschreibung
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(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">
|
||||||
|
Alias (kommagetrennt)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={alias}
|
||||||
|
onChange={(e) => setAlias(e.target.value)}
|
||||||
|
className="w-40 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 className="flex items-center gap-2">
|
||||||
|
<Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern"><FontAwesomeIcon icon={faFloppyDisk} /></Button>
|
||||||
|
<Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen"><FontAwesomeIcon icon={faXmark} /></Button>
|
||||||
|
{mutation.isError ? (
|
||||||
|
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookmarkRow({
|
||||||
|
bookmark,
|
||||||
|
draggable,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
isDragging,
|
||||||
|
}: {
|
||||||
|
bookmark: Bookmark;
|
||||||
|
draggable: boolean;
|
||||||
|
onDragStart: (e: DragEvent<HTMLTableRowElement>) => void;
|
||||||
|
onDragOver: (e: DragEvent<HTMLTableRowElement>) => void;
|
||||||
|
onDrop: (e: DragEvent<HTMLTableRowElement>) => void;
|
||||||
|
isDragging: boolean;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
const favoriteMutation = useMutation({
|
||||||
|
mutationFn: () => patchBookmark(bookmark.id, { favorite: !bookmark.favorite }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["bookmarks"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => deleteBookmarkRequest(bookmark.id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["bookmarks"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return <EditForm bookmark={bookmark} onDone={() => setEditing(false)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
draggable={draggable}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
className={`border-b border-black/5 last:border-0 dark:border-white/5 ${isDragging ? "opacity-40" : ""}`}
|
||||||
|
>
|
||||||
|
<td className="px-2 py-3 text-center">
|
||||||
|
<span
|
||||||
|
className={`select-none ${draggable ? "cursor-grab text-black/30 dark:text-white/30" : "text-black/10 dark:text-white/10"}`}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
⠿⠿
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => favoriteMutation.mutate()}
|
||||||
|
aria-label={bookmark.favorite ? "Favorit entfernen" : "Als Favorit markieren"}
|
||||||
|
className={`text-lg ${bookmark.favorite ? "text-amber-500" : "text-black/15 hover:text-amber-400 dark:text-white/15"}`}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={bookmark.favorite ? faStarSolid : faStar} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Favicon src={bookmark.favicon} fallbackLetter={bookmark.displayName} size="sm" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-black dark:text-white">{bookmark.displayName}</div>
|
||||||
|
<div className="text-xs text-black/40 dark:text-white/40">{bookmark.hostname}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-black/60 dark:text-white/60">{bookmark.category ?? "–"}</td>
|
||||||
|
<td className="px-4 py-3 text-black/60 dark:text-white/60">
|
||||||
|
{bookmark.description ?? "–"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<a
|
||||||
|
href={bookmark.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="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"><FontAwesomeIcon icon={faPen} /></Button>
|
||||||
|
<Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortColumn = "displayName" | "hostname" | "category" | null;
|
||||||
|
|
||||||
|
function SortableHeader({
|
||||||
|
label,
|
||||||
|
column,
|
||||||
|
activeColumn,
|
||||||
|
direction,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
column: SortColumn;
|
||||||
|
activeColumn: SortColumn;
|
||||||
|
direction: "asc" | "desc";
|
||||||
|
onClick: (column: SortColumn) => void;
|
||||||
|
}) {
|
||||||
|
const active = activeColumn === column;
|
||||||
|
return (
|
||||||
|
<th className="px-4 py-2 font-medium">
|
||||||
|
<button
|
||||||
|
onClick={() => onClick(column)}
|
||||||
|
className={`flex items-center gap-1 hover:text-black dark:hover:text-white ${
|
||||||
|
active ? "text-black dark:text-white" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<span className="text-[10px]"><FontAwesomeIcon icon={active ? (direction === "asc" ? faSortUp : faSortDown) : faSort} /></span>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BookmarksPage() {
|
||||||
|
const { data: bookmarks, isLoading, isError } = useBookmarks();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||||
|
const [localOrder, setLocalOrder] = useState<Bookmark[] | null>(null);
|
||||||
|
const [sortColumn, setSortColumn] = useState<SortColumn>(null);
|
||||||
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||||
|
|
||||||
|
const reorderMutation = useMutation({
|
||||||
|
mutationFn: reorderBookmarksRequest,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["bookmarks"] });
|
||||||
|
setLocalOrder(null);
|
||||||
|
},
|
||||||
|
onError: () => setLocalOrder(null),
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseList = localOrder ?? bookmarks ?? [];
|
||||||
|
|
||||||
|
const list = useMemo(() => {
|
||||||
|
if (!sortColumn) return baseList;
|
||||||
|
const sorted = [...baseList].sort((a, b) => {
|
||||||
|
let cmp = 0;
|
||||||
|
switch (sortColumn) {
|
||||||
|
case "displayName":
|
||||||
|
cmp = a.displayName.localeCompare(b.displayName);
|
||||||
|
break;
|
||||||
|
case "hostname":
|
||||||
|
cmp = a.hostname.localeCompare(b.hostname);
|
||||||
|
break;
|
||||||
|
case "category":
|
||||||
|
cmp = (a.category ?? "").localeCompare(b.category ?? "");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return sortDirection === "asc" ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}, [baseList, sortColumn, sortDirection]);
|
||||||
|
|
||||||
|
function handleHeaderClick(column: SortColumn) {
|
||||||
|
if (sortColumn === column) {
|
||||||
|
setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
|
||||||
|
} else {
|
||||||
|
setSortColumn(column);
|
||||||
|
setSortDirection("asc");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragEnabled = sortColumn === null;
|
||||||
|
|
||||||
|
function handleDragStart(id: string) {
|
||||||
|
return (_e: DragEvent<HTMLTableRowElement>) => setDraggedId(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(targetId: string) {
|
||||||
|
return (e: DragEvent<HTMLTableRowElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!dragEnabled || !draggedId || draggedId === targetId) return;
|
||||||
|
const current = localOrder ?? bookmarks ?? [];
|
||||||
|
const fromIndex = current.findIndex((b) => b.id === draggedId);
|
||||||
|
const toIndex = current.findIndex((b) => b.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<HTMLTableRowElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!dragEnabled) return;
|
||||||
|
setDraggedId(null);
|
||||||
|
const current = localOrder ?? bookmarks ?? [];
|
||||||
|
reorderMutation.mutate(current.map((b, index) => ({ id: b.id, order: index })));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AdminPageHeader
|
||||||
|
title="Lesezeichen"
|
||||||
|
description="Eigenständig von Diensten – erscheinen zusammen mit ihnen in der Suche, aber als eigene Favoriten-Gruppe auf der Startseite. Spaltenköpfe anklickbar zum Sortieren; Drag & Drop (⠿⠿) nur in der Standard-Reihenfolge."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddBookmarkForm />
|
||||||
|
|
||||||
|
{sortColumn ? (
|
||||||
|
<div className="mb-3">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setSortColumn(null)}>
|
||||||
|
<FontAwesomeIcon icon={faArrowLeft} /> Zur manuellen Reihenfolge (Drag & Drop) zurück
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-black/40 dark:text-white/40">Lade Lesezeichen …</p>
|
||||||
|
) : isError ? (
|
||||||
|
<p className="text-sm text-red-500">Lesezeichen konnten nicht geladen werden.</p>
|
||||||
|
) : list.length > 0 ? (
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[720px] 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-2 py-2" />
|
||||||
|
<th className="px-2 py-2" />
|
||||||
|
<SortableHeader label="Lesezeichen" column="displayName" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||||||
|
<SortableHeader label="Kategorie" column="category" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||||||
|
<th className="px-4 py-2 font-medium">Beschreibung</th>
|
||||||
|
<SortableHeader label="URL" column="hostname" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||||||
|
<th className="px-4 py-2" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{list.map((bookmark) => (
|
||||||
|
<BookmarkRow
|
||||||
|
key={bookmark.id}
|
||||||
|
bookmark={bookmark}
|
||||||
|
draggable={dragEnabled}
|
||||||
|
isDragging={draggedId === bookmark.id}
|
||||||
|
onDragStart={handleDragStart(bookmark.id)}
|
||||||
|
onDragOver={handleDragOver(bookmark.id)}
|
||||||
|
onDrop={handleDrop()}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-black/40 dark:text-white/40">
|
||||||
|
Noch keine Lesezeichen angelegt. Füge oben eine URL hinzu.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user