Compare commits

..

76 Commits

Author SHA1 Message Date
50cab7ff0c round72: MAC-Adresse nicht mehr in der Startseiten-Suche durchsuchbar - fuehrte zu unerklaerlichen Treffern bei kurzen Zahlen-Suchen 2026-07-27 01:22:23 +02:00
ee7bd9fcbf round71: Abfrage-Cache in localStorage gespiegelt - kein leerer Ladezustand mehr bei echtem Seiten-Reload 2026-07-26 23:43:52 +02:00
1e90eb2905 round70: Icons ruckeln nicht mehr nach - Raster wartet auf alle Datenquellen bevor es rendert 2026-07-26 23:15:37 +02:00
0a1052f217 round69: Kurzes Aufblitzen des Classic-Designs beim Reload behoben (Design-Wahl jetzt zusaetzlich lokal zwischengespeichert) 2026-07-26 22:51:05 +02:00
2ed39d6865 round68: Spaeter-lesen als Liste mit Datum/Uhrzeit statt Icon-Raster, Suche im Android-Design nutzt jetzt dieselbe Rangierfunktion wie das klassische Design 2026-07-26 22:20:04 +02:00
3e27c54199 round67: touch-action von manipulation auf none korrigiert - Browser hat Zieh-Geste vorher fuer natives Scrollen beansprucht 2026-07-26 20:38:00 +02:00
8046120548 round66: Natives Kontextmenue beim Lang-Druecken auf Icons unterdrueckt (verhinderte bisher das Drag-and-Drop) 2026-07-26 20:06:26 +02:00
ff87f9812c round65: Android-Startbildschirm nur mit Favoriten, echtes Drag-and-Drop per Pointer-Events, selbst erstellbare Ordner 2026-07-26 18:34:46 +02:00
538fba5fc3 round64: Neues Android-Startbildschirm-artiges Design fuer die mobile Ansicht, umschaltbar in den Einstellungen 2026-07-26 16:55:00 +02:00
c05562c4cd round63: Geraeteuebergreifende Sichtbarkeit fuer manuelle Einzelgeraete-Scans, automatische Logs-Aktualisierung danach 2026-07-26 16:34:49 +02:00
9abfdba707 round62: Ueberfluessigen Delta-Satz unterdruecken, wenn er nichts Neues gegenueber der Standard-Zusammenfassung aussagt 2026-07-26 16:28:01 +02:00
fb5874d7d0 round61: Delta-Vergleich gegen vollstaendige DB-Abfrage statt lueckenhafter Lauf-Teilliste, Formulierung 'geloescht' statt irrefuehrendem 'fehlen jetzt' 2026-07-26 15:34:00 +02:00
eef4ddc775 round60: Delta-Vergleich zum vorherigen Scan (FritzBox + Sammel-Scan) statt statischer Zahlen 2026-07-26 14:09:11 +02:00
a7036849b1 round59: Serverseitige, geraeteuebergreifende Bestaetigung von Scan-Vorschlaegen statt nur lokalem sessionStorage 2026-07-26 14:00:10 +02:00
403a052a0f round58: Router-IP vom vollstaendigen Portscan im Sammel-Scan ausnehmen (FritzOS-Angriffserkennung reagierte auf vollen masscan-Scan gegen sich selbst) 2026-07-26 13:26:38 +02:00
0a1f3143d7 round57: Scroll-to-top-Button hoeher positioniert, Laufzeit-Anzeige in Einstellungen lesbar formatiert 2026-07-26 13:23:44 +02:00
75bc5725e4 round56: Kritischer Bugfix - Geraete mit 0 von masscan gefundenen Ports loesten faelschlich vollen 65535-Port-Fallback-Scan aus 2026-07-26 12:50:41 +02:00
ca8c8c1d3b round55: DNS-Abfragen mit Timeout absichern, Thread-Pool-Groesse erhoehen (behebt Haenger nach masscan/httpx-Vorabscan), Live-Haekchen pro fertigem Geraet im Batch 2026-07-26 12:27:24 +02:00
c7398ce2ba round54: Live-Fortschritt (Prozent, Restzeit, gefundene Ports/bestaetigte Dienste) waehrend masscan-/httpx-Vorabscan 2026-07-26 11:50:21 +02:00
edc4acaf3b round53: Timeout-Berechnung fuer masscan/httpx auf ganze Zahl runden (execFile lehnte Kommawerte ab) 2026-07-26 11:36:03 +02:00
2bc5d9550c round52: Fehler im Sammel-Scan werden jetzt in der UI angezeigt und persistent geloggt statt verschluckt 2026-07-26 11:29:21 +02:00
8dd3115353 round51: Fortschrittsanzeige und Timing-Logs fuer den masscan-/httpx-Vorabscan im Sammel-Scan 2026-07-26 11:04:09 +02:00
0287a7cc5c round50: masscan/httpx laufen im Sammel-Scan nur noch einmal fuer alle Geraete statt pro Geraet, Rate deutlich erhoeht 2026-07-26 10:49:35 +02:00
cf31ea4402 round49: masscan/httpx-Timeout-Erkennung und Nebenlaeufigkeits-Begrenzung, httpx verwirft offene Ports nicht mehr, FritzBox-Namens-Downgrade auf Platzhalter verhindert 2026-07-26 09:17:57 +02:00
28fedb2572 round48: masscan/httpx-Fehlermeldungen im Scan-Log sichtbar machen, masscan-Status nur bei vollstaendigem Portscan anzeigen 2026-07-26 08:29:11 +02:00
2e15669098 round47: httpx-Integration nach masscan (nur bestaetigte Webinterfaces werden weiterverarbeitet), Sichtbarkeit ob masscan/httpx greifen ueber Admin-Logs 2026-07-25 18:54:23 +02:00
eed5089a56 round46: Prozessweite Verbindungs-Obergrenze statt zwei sich multiplizierender Parallelitaets-Werte (behebt verlorene Ports wie FritzBox 443 bei mehreren gleichzeitigen Geraete-Scans) 2026-07-25 13:56:48 +02:00
6792f5b7ea round45: Sammel-Scan bearbeitet jetzt bis zu 10 Geraete gleichzeitig statt nacheinander (behebt sehr lange Gesamtdauer bei vielen Geraeten) 2026-07-25 13:17:44 +02:00
59bb4a30b8 round44: Optionale masscan-Beschleunigung fuer den vollstaendigen Portscan mit automatischem Fallback 2026-07-25 13:05:41 +02:00
d4ff497c8c round43: Timeout wieder etwas grosszuegiger, Ports 80/443 bekommen immer einen zweiten Versuch (behebt verpasste Ports bei WLAN-angebundenen Geraeten wie Mesh-Repeatern) 2026-07-25 13:04:33 +02:00
6457475e26 round42: AVM/FritzOS TR-064-Verwaltungsports (49000/49443) werden nicht mehr als Dienst angezeigt 2026-07-25 12:49:46 +02:00
301c25571a round41: Portscan-Timeout und Batch-Groesse fuers Heimnetz nachgeschaerft, letzten sequentiellen Rest parallelisiert 2026-07-25 09:49:35 +02:00
505353cd26 round40: HTTP-Pruefung offener Ports parallelisiert (behebt sehr lange Scan-Zeiten seit vollstaendigem Portscan) 2026-07-25 09:11:02 +02:00
5deeeaec1d round39: Nur noch echte HTTP-Dienste werden als Dienst angelegt, reine TCP-Ports (SSH, Datenbanken etc.) werden uebersprungen 2026-07-25 08:43:45 +02:00
38f09e862e round38: Alle Geraete scannen nutzt jetzt ebenfalls den vollstaendigen Portscan (Konsistenz mit dem Einzelgeraete-Scan) 2026-07-25 01:26:05 +02:00
45a01bd32e round37: API-Scanner Kontrollprobe entschaerft (weniger Fehlalarme false negatives), Alle-bestaetigen-Buttons, Scan-Buttons bei Geraeten zusammengelegt 2026-07-24 23:32:48 +02:00
b32a60be64 round36: Bestaetigte Scan-Ergebnisse erscheinen nach Neuladen der Seite nicht mehr erneut (betraf alle drei Scanner) 2026-07-24 22:16:12 +02:00
645dcf3af8 round35: Vollstaendiger Portscan (1-65535) pro Geraet als Option, fuer Geraete mit vielen Diensten auf unueblichen Ports 2026-07-24 21:21:07 +02:00
fedb3e9127 round34: Emby-Port ergaenzt, API-Scanner erkennt jetzt auch Namespace-Catch-alls wie bei FritzBox/Hue Bridge 2026-07-24 21:18:04 +02:00
70da526921 round33: API-Scanner Kontrollprobe gegen Geraete, die jede Anfrage einheitlich beantworten (false positives behoben) 2026-07-24 17:26:37 +02:00
ba057e7911 round32: Bestaetigen-Button fuer Scan-Ergebnisse, API-Scan auf wirklich nutzbare Endpunkte eingegrenzt, Firefox-Android-Hinweis mit Alternativen 2026-07-24 16:16:10 +02:00
e4cc6987be round31: Infrastruktur-Praefixe (lxc/vm/docker) aus Namen und Suche entfernt, Icon-Datenbank-Abgleich auch ohne erkannte Software, Sichtbarkeit bei eindeutigem Icon-Treffer 2026-07-24 15:30:03 +02:00
87fed21348 round30: alle drei Scanner als Hintergrund-Job, Dienst-Duplikate strukturell verhindert (Migration+Constraint+Geraete-Sperre), zweiter Erkennungsversuch bei leerem Ersttreffer 2026-07-24 15:14:00 +02:00
2106c17301 round29: Scanner laufen jetzt als Hintergrund-Job im Backend (behebt API-Scan-504-Timeout und macht Scan-Status/-Fortschritt geraeteuebergreifend sichtbar) 2026-07-24 13:29:01 +02:00
f5845ec762 round28: Portscan-Retry fuer bekannte Dienste (Echo/Samsung-Falschmeldungen behoben), 7-Tage-Schwelle jetzt auch fuer einzelne Dienste, geraeteuebergreifender Scan-Status in der Navigation 2026-07-24 12:49:26 +02:00
430b79763c round27: Ping-Regression rueckgaengig gemacht (Echo/Samsung-Geraete ignorieren ICMP), Aktualisiert-Text entfernt, Suchmaschinen-Hinweis+README, API-Scanner stark erweitert mit eigenem Reiter 2026-07-24 09:24:57 +02:00
0fc063b2a2 round26: Ping vor Portscan, Liste neuer Dienste, Suchmaschinen-Integration (OpenSearch), API-Scanner 2026-07-24 02:21:58 +02:00
458f58af6f round25: Scanner-Haenger behoben (Portpruefung parallelisiert statt sequentiell), Abbrechen-Button fuer beide Scanner 2026-07-24 01:59:18 +02:00
6a2bf24498 round24: Favicon-Groesse und Wort-Erkennung verbessert, ausgeblendet-Badge entfernt, Proxmox-Port-Bug gefixt, Suche um Geraete und Spaeter-lesen erweitert 2026-07-24 00:58:28 +02:00
d87885f16f round23: Startseite kompakter, Theme-Button entfernt, echte Bildverifikation fuer Favicon-Auswahl, Tooltip-Portal, wortbasierte interaktive Favicon-Vorschlaege 2026-07-24 00:26:08 +02:00
b098007742 round22: Startseite kompakter, Ping-Zeitstempel-Bug behoben, echte Favicon-Deduplizierung per Bildinhalt, automatischer Favicon-Abgleich aus Icon-Datenbank + Nachruest-Button 2026-07-23 20:58:58 +02:00
6a66128172 round21: Startseiten-Bereiche in eine Karte zusammengefasst, Favicon-Auswahl filtert tote Icons und zeigt groessere Vorschau, Icon fuer manuelle Anpassung getauscht, Zeitstempel-Inkonsistenz Scan/Ping behoben 2026-07-23 20:03:34 +02:00
68ca9ec5e1 round20: Startseite ueberarbeitet, Favicon-Auswahl bereinigt und Icon-Datenbank integriert, Ping-Schalter repariert 2026-07-23 19:34:09 +02:00
ec49534b60 round19: Startseite aufgeraeumt, Favicon-Fallback ueber Ports, Favicon-Picker, Live-Status per Ping 2026-07-23 18:10:03 +02:00
bae9e48fc1 round17+18: Emoji durch Font Awesome ersetzt, Lesezeichen-Kategorie-Vorschlaege, Favicon-Upload 2026-07-23 14:56:40 +02:00
050616a5cf round16: Geraete-Erkennung per ID statt IP fixen, Schwelle konfigurierbar, Icons auf Font Awesome umgestellt 2026-07-23 13:23:20 +02:00
7035d4ebda round15: manuelle Aenderungen nicht mehr nachfragen, 7-Tage-Schwelle fuer verschwundene Geraete, Icon-Buttons mit Tooltip, fixierte mobile Kopfleiste, Scroll-to-top-Button 2026-07-23 12:32:19 +02:00
70edeeed38 round14: Uebernehmen-Bug und Suggestion-Debounce fixen, Read-Later-Verwaltungsseite, konfigurierbares Read-Later-Limit 2026-07-23 09:17:50 +02:00
2772c29036 round13: manuelle vs. echte Aenderungen trennen, Scan-Uebersicht ueber Navigation persistieren, ausgeblendete Dienste als Tab, Suche ueber Geraetenamen 2026-07-23 01:21:31 +02:00
1f8218dff1 round12: FritzBox-Merge-Bug vor Doppel-Ausloesung fixen, Scan-Meldungen entschaerfen, Dienstname-Fallback aus Hostname statt IP 2026-07-22 23:01:01 +02:00
54f224b650 Geraete-Aenderungen (Name/IP) jetzt auch nur als Vorschlag statt automatisch, klarere FritzBox-Zaehlung 2026-07-22 16:53:38 +02:00
5f150119b2 Fix: FritzBox faelschlich als Home Assistant erkannt + Reverse-DNS + Namens-Aenderungsvorschlaege beim Scan 2026-07-22 15:58:10 +02:00
7cf64404c5 Fix: Dropdown schloss nicht bei Klick ausserhalb (Liste war kuenstlich auf volle Bildschirmhoehe gestreckt) 2026-07-20 23:24:48 +02:00
724f076c8c Dashboard-Zahlen (Kategorien, Read Later), Host/IP getrennte Spalten bei Diensten, Scanner-Uebersicht beim Massen-Scan 2026-07-20 17:51:58 +02:00
0580d46000 Hersteller-Auto-Erkennung ueber MAC (verifiziert), Geraete-Tabelle Hersteller/Modell-Spalten, Dropdown-Klick-Bug, Kategorie-Farbe als Hintergrund 2026-07-20 14:02:19 +02:00
cc4b285aea Kritische Bugfixes: Export/CA-Download (Service-Worker), Speichern-Bug bei Diensten/Lesezeichen, Dark-Mode-Direktlink, Favicon-Mixed-Content-Proxy, Live-Update Zuletzt-besucht, Kategorie-Farb-Picker, Startseite-Nav 2026-07-20 13:02:58 +02:00
96cf29fef4 Bugfixes (Dark Mode, Dropdown-Kontrast, IP-Suche), Lesezeichen-Ausbau (Farbe, Beschreibung, Favicon), Zuletzt-besucht, Spaeter-lesen, kombinierter Import-Export, Scanner-Reconciliation, Admin-Ueberarbeitung 2026-07-20 07:52:38 +02:00
dc91a9aba9 Lesezeichen, Import/Export, Favoriten-Sortierung im Frontend, Favicon-Fix, sortierbare Spalten, Kategorie-Dropdown 2026-07-19 21:56:25 +02:00
31e17ff77b HTTPS: eigene lokale Root-CA statt externem DuckDNS/Let's Encrypt 2026-07-19 17:19:03 +02:00
a695159b1d HTTPS im lokalen Netz (Port 8443, selbstsigniertes Zertifikat, optional mkcert) 2026-07-19 16:03:29 +02:00
fc1ea4fa70 Reset-Button, Service-Reorder, Port/HTTPS-Spalten, Healthcheck in Sidebar, Kategorien-Sync 2026-07-19 15:14:44 +02:00
20ea4d382d Fix: visible-Flag für Scan-Müll, editierbare Hostname/Port/HTTPS, Sidebar fixiert, Suche nur bei Eingabe 2026-07-19 12:27:45 +02:00
d65897b8e1 Fix: Adminbereich responsiv (Mobile-Drawer-Navigation, scrollbare Tabellen) 2026-07-19 11:20:33 +02:00
986de50963 Commit 8: Plugin-System (Scanner erweitern, Geräte importieren, Icons) 2026-07-19 10:14:43 +02:00
6a7eb3dfe5 Commit 7: PWA (Manifest, Service Worker, Icons, Offline-Grundfunktion) 2026-07-19 03:13:55 +02:00
63af09f43a Commit 6: Adminbereich (TanStack Router, 8 Menüpunkte, Scan-Logs) 2026-07-19 03:13:23 +02:00
100 changed files with 12458 additions and 734 deletions

View File

@@ -39,3 +39,14 @@ FRITZBOX_PASSWORD=
# ─── Frontend (nur für lokale Entwicklung, `pnpm dev:frontend`) ───── # ─── Frontend (nur für lokale Entwicklung, `pnpm dev:frontend`) ─────
# Ziel für den Vite-Dev-Proxy von /api -> Backend # Ziel für den Vite-Dev-Proxy von /api -> Backend
BACKEND_URL=http://localhost:3001 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

209
README.md
View File

@@ -14,9 +14,74 @@ cp .env.example .env # optional anpassen, siehe unten
docker compose up -d --build docker compose up -d --build
``` ```
- Frontend: http://localhost:8080 - Frontend: http://localhost:8080 (HTTPS: https://localhost:8443, siehe unten)
- Backend / Health-API: http://localhost:3001/api/health - 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) ## Quickstart (lokale Entwicklung)
Voraussetzungen: Node.js ≥ 20, pnpm ≥ 9. Voraussetzungen: Node.js ≥ 20, pnpm ≥ 9.
@@ -80,11 +145,15 @@ Dieser erste Commit liefert ein lauffähiges Grundgerüst:
- ✅ PWA: installierbar (Manifest + Icons für Android/iOS/Desktop), Service - ✅ PWA: installierbar (Manifest + Icons für Android/iOS/Desktop), Service
Worker mit App-Shell-Precaching, `/api/*` läuft offline über den letzten Worker mit App-Shell-Precaching, `/api/*` läuft offline über den letzten
Cache-Stand (NetworkFirst, 3s-Timeout) 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 (folgt in den nächsten Commits): Noch **nicht** enthalten:
- Plugin-System (Scanner registrieren, Geräte importieren, Menüs erweitern, Icons)
- shadcn/ui, React Hook Form (aktuell einfache kontrollierte Formulare) - shadcn/ui, React Hook Form (aktuell einfache kontrollierte Formulare)
- Eigene Admin-Routen/Menüpunkte pro Plugin (aktuell gesammelt auf einer Seite)
### Umgebungsvariablen (.env) ### Umgebungsvariablen (.env)
@@ -127,22 +196,66 @@ DELETE /api/devices/:id (löscht zugehörige Dienste per Cascade)
GET /api/services optional ?deviceId=&category=&favorite=true GET /api/services optional ?deviceId=&category=&favorite=true
GET /api/services/:id GET /api/services/:id
POST /api/services erfordert existierende deviceId POST /api/services erfordert existierende deviceId
PATCH /api/services/reorder Body: [{ id, order }, ...]
PATCH /api/services/:id PATCH /api/services/:id
DELETE /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 GET /api/categories
POST /api/categories POST /api/categories optional color (Hex, z. B. "#3b82f6")
PATCH /api/categories/reorder Body: [{ id, order }, ...] PATCH /api/categories/reorder Body: [{ id, order }, ...]
PATCH /api/categories/:id Umbenennen PATCH /api/categories/:id Umbenennen (aktualisiert automatisch alle Dienste mit altem Namen)
DELETE /api/categories/:id Dienste behalten ihre category nicht mehr (null), DELETE /api/categories/:id Dienste behalten ihre category nicht mehr (null),
werden aber nicht gelöscht werden aber nicht gelöscht
POST /api/scan/devices/:id Netzwerk-Scan für ein Gerät (DNS, Ports, Titel, POST /api/scan/devices/:id Netzwerk-Scan für ein Gerät; Antwort enthält
Favicon, Softwareerkennung); legt/aktualisiert Dienste zusätzlich staleServices (nicht mehr gefundene
POST /api/scan/fritzbox Liest Geräteliste der FritzBox per TR-064 Dienste), nameChanges (abweichender Name/
(erfordert FRITZBOX_HOST/USERNAME/PASSWORD) 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/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 ## Frontend-Routen
@@ -153,6 +266,7 @@ GET /api/logs optional ?limit= (Default 100, Max 500)
/admin/dashboard /admin/dashboard
/admin/devices /admin/devices
/admin/services /admin/services
/admin/bookmarks
/admin/categories /admin/categories
/admin/scanner /admin/scanner
/admin/plugins Hinweis: Plugin-System noch nicht gebaut /admin/plugins Hinweis: Plugin-System noch nicht gebaut
@@ -160,6 +274,83 @@ GET /api/logs optional ?limit= (Default 100, Max 500)
/admin/logs /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) ## Deployment auf dem Server (xlc-launchpad)
```bash ```bash

View File

@@ -31,6 +31,53 @@ ENV NODE_ENV=production
ENV DATABASE_PATH=/data/launchpad.db ENV DATABASE_PATH=/data/launchpad.db
ENV PORT=3001 ENV PORT=3001
ENV HOST=0.0.0.0 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/deploy/ ./
COPY --from=build /app/apps/backend/dist ./dist COPY --from=build /app/apps/backend/dist ./dist

View File

@@ -17,7 +17,8 @@
"better-sqlite3": "^11.3.0", "better-sqlite3": "^11.3.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"drizzle-orm": "^0.33.0", "drizzle-orm": "^0.33.0",
"fastify": "^4.28.1" "fastify": "^4.28.1",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.11", "@types/better-sqlite3": "^7.6.11",

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

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

View File

@@ -0,0 +1,9 @@
[
{
"hostname": "nas",
"ip": "192.168.1.20",
"mac": "AA:BB:CC:DD:EE:FF",
"manufacturer": "Synology",
"model": "DS920+"
}
]

View File

@@ -0,0 +1 @@
[]

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

View File

@@ -36,6 +36,7 @@ export function ensureSchema(): void {
online INTEGER NOT NULL DEFAULT 0, online INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL, source TEXT NOT NULL,
last_scan TEXT, last_scan TEXT,
last_ping TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -52,6 +53,11 @@ export function ensureSchema(): void {
hostname TEXT NOT NULL, hostname TEXT NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
https INTEGER NOT NULL DEFAULT 0, 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, port INTEGER NOT NULL,
favicon TEXT, favicon TEXT,
description TEXT, description TEXT,
@@ -63,6 +69,7 @@ export function ensureSchema(): void {
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
"order" REAL NOT NULL DEFAULT 0, "order" REAL NOT NULL DEFAULT 0,
color TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -75,5 +82,143 @@ export function ensureSchema(): void {
message TEXT NOT NULL, message TEXT NOT NULL,
created_at 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}`);
}
} }

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

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

View File

@@ -14,7 +14,7 @@ function nowIso(): string {
} }
function mapRow(row: typeof categories.$inferSelect): Category { function mapRow(row: typeof categories.$inferSelect): Category {
return { id: row.id, name: row.name, order: row.order }; return { id: row.id, name: row.name, order: row.order, color: row.color };
} }
export function listCategories(): Category[] { export function listCategories(): Category[] {
@@ -42,6 +42,7 @@ export function createCategory(input: CategoryCreateInput): Category {
id, id,
name: input.name, name: input.name,
order: nextOrder, order: nextOrder,
color: input.color ?? null,
createdAt: timestamp, createdAt: timestamp,
updatedAt: timestamp, updatedAt: timestamp,
}) })
@@ -50,14 +51,29 @@ export function createCategory(input: CategoryCreateInput): Category {
return getCategory(id)!; 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 { export function updateCategory(id: string, input: CategoryUpdateInput): Category | null {
const existing = getCategory(id); const existing = getCategory(id);
if (!existing) return null; 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) db.update(categories)
.set({ .set({
...(input.name !== undefined && { name: input.name }), ...(input.name !== undefined && { name: input.name }),
updatedAt: nowIso(), ...(input.color !== undefined && { color: input.color }),
updatedAt: timestamp,
}) })
.where(eq(categories.id, id)) .where(eq(categories.id, id))
.run(); .run();
@@ -99,3 +115,41 @@ export function deleteCategory(id: string): boolean {
const result = db.delete(categories).where(eq(categories.id, id)).run(); const result = db.delete(categories).where(eq(categories.id, id)).run();
return result.changes > 0; 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;
}

View File

@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import type { Device, DeviceCreateInput, DeviceUpdateInput } from "@launchpad/shared"; import type { Device, DeviceCreateInput, DeviceUpdateInput } from "@launchpad/shared";
import { db } from "../client.js"; import { db } from "../client.js";
import { devices } from "../schema.js"; import { devices } from "../schema.js";
import { lookupVendorFromMac } from "../../scanner/macVendors.js";
function nowIso(): string { function nowIso(): string {
return new Date().toISOString(); return new Date().toISOString();
@@ -19,9 +20,26 @@ function mapRow(row: typeof devices.$inferSelect): Device {
online: row.online, online: row.online,
source: row.source as Device["source"], source: row.source as Device["source"],
lastScan: row.lastScan, 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[] { export function listDevices(): Device[] {
return db.select().from(devices).all().map(mapRow); return db.select().from(devices).all().map(mapRow);
} }
@@ -34,6 +52,8 @@ export function getDevice(id: string): Device | null {
export function createDevice(input: DeviceCreateInput): Device { export function createDevice(input: DeviceCreateInput): Device {
const id = randomUUID(); const id = randomUUID();
const timestamp = nowIso(); const timestamp = nowIso();
const manufacturer =
input.manufacturer ?? (input.mac ? lookupVendorFromMac(input.mac) : null);
db.insert(devices) db.insert(devices)
.values({ .values({
@@ -41,7 +61,7 @@ export function createDevice(input: DeviceCreateInput): Device {
hostname: input.hostname, hostname: input.hostname,
ip: input.ip, ip: input.ip,
mac: input.mac ?? null, mac: input.mac ?? null,
manufacturer: input.manufacturer ?? null, manufacturer,
model: input.model ?? null, model: input.model ?? null,
online: input.online ?? false, online: input.online ?? false,
source: input.source ?? "manual", source: input.source ?? "manual",
@@ -62,12 +82,22 @@ export function updateDevice(id: string, input: DeviceUpdateInput): Device | nul
const existing = getDevice(id); const existing = getDevice(id);
if (!existing) return null; 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) db.update(devices)
.set({ .set({
...(input.hostname !== undefined && { hostname: input.hostname }), ...(input.hostname !== undefined && { hostname: input.hostname }),
...(input.ip !== undefined && { ip: input.ip }), ...(input.ip !== undefined && { ip: input.ip }),
...(input.mac !== undefined && { mac: input.mac }), ...(input.mac !== undefined && { mac: input.mac }),
...(input.manufacturer !== undefined && { manufacturer: input.manufacturer }), ...(input.manufacturer !== undefined && { manufacturer: input.manufacturer }),
...(autoManufacturer && { manufacturer: autoManufacturer }),
...(input.model !== undefined && { model: input.model }), ...(input.model !== undefined && { model: input.model }),
...(input.online !== undefined && { online: input.online }), ...(input.online !== undefined && { online: input.online }),
...(input.source !== undefined && { source: input.source }), ...(input.source !== undefined && { source: input.source }),
@@ -84,6 +114,12 @@ export function deleteDevice(id: string): boolean {
return result.changes > 0; 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 { export interface DeviceScanInput {
hostname: string; hostname: string;
ip: string; ip: string;
@@ -94,32 +130,75 @@ export interface DeviceScanInput {
source: Device["source"]; 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 { function findByMacOrIp(mac: string | null | undefined, ip: string): Device | null {
const all = listDevices(); const all = listDevices();
if (mac) { if (mac) {
const byMac = all.find((d) => d.mac && d.mac.toLowerCase() === mac.toLowerCase()); const byMac = all.find((d) => d.mac && d.mac.toLowerCase() === mac.toLowerCase());
if (byMac) return byMac; 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; return all.find((d) => d.ip === ip) ?? null;
} }
/** /**
* Legt ein per Scan gefundenes Gerät an oder aktualisiert ein bestehendes * Legt ein per Scan gefundenes Gerät an oder aktualisiert ein bestehendes
* (abgeglichen über MAC, sonst IP). Geräte haben keine "Benutzerfelder" im * (abgeglichen über MAC, sonst IP).
* Sinne der Spezifikation (die betrifft nur Dienste) Scans dürfen hier *
* alle Felder aktualisieren. * 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): Device { export function upsertDeviceFromScan(input: DeviceScanInput): DeviceScanUpsertResult {
const existing = findByMacOrIp(input.mac, input.ip); const existing = findByMacOrIp(input.mac, input.ip);
const timestamp = nowIso(); const timestamp = nowIso();
const mac = input.mac ?? existing?.mac ?? null;
const manufacturer =
input.manufacturer ??
existing?.manufacturer ??
(mac ? lookupVendorFromMac(mac) : null);
if (existing) { if (existing) {
db.update(devices) db.update(devices)
.set({ .set({
hostname: input.hostname, mac,
ip: input.ip, manufacturer,
mac: input.mac ?? existing.mac,
manufacturer: input.manufacturer ?? existing.manufacturer,
model: input.model ?? existing.model, model: input.model ?? existing.model,
online: input.online ?? existing.online, online: input.online ?? existing.online,
source: input.source, source: input.source,
@@ -128,7 +207,16 @@ export function upsertDeviceFromScan(input: DeviceScanInput): Device {
}) })
.where(eq(devices.id, existing.id)) .where(eq(devices.id, existing.id))
.run(); .run();
return getDevice(existing.id)!;
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(); const id = randomUUID();
@@ -137,8 +225,8 @@ export function upsertDeviceFromScan(input: DeviceScanInput): Device {
id, id,
hostname: input.hostname, hostname: input.hostname,
ip: input.ip, ip: input.ip,
mac: input.mac ?? null, mac,
manufacturer: input.manufacturer ?? null, manufacturer,
model: input.model ?? null, model: input.model ?? null,
online: input.online ?? false, online: input.online ?? false,
source: input.source, source: input.source,
@@ -148,5 +236,5 @@ export function upsertDeviceFromScan(input: DeviceScanInput): Device {
}) })
.run(); .run();
return getDevice(id)!; return { device: getDevice(id)!, changes: [] };
} }

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

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

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

View File

@@ -15,6 +15,7 @@ function mapRow(row: typeof services.$inferSelect): Service {
displayName: row.displayName, displayName: row.displayName,
hostname: row.hostname, hostname: row.hostname,
url: row.url, url: row.url,
updatedAt: row.updatedAt,
https: row.https, https: row.https,
port: row.port, port: row.port,
category: row.category, category: row.category,
@@ -24,6 +25,9 @@ function mapRow(row: typeof services.$inferSelect): Service {
favorite: row.favorite, favorite: row.favorite,
alias: JSON.parse(row.alias) as string[], alias: JSON.parse(row.alias) as string[],
order: row.order, order: row.order,
visible: row.visible,
displayNameEditedManually: row.displayNameEditedManually,
categoryEditedManually: row.categoryEditedManually,
}; };
} }
@@ -76,6 +80,7 @@ export function createService(input: ServiceCreateInput): Service {
favorite: input.favorite ?? false, favorite: input.favorite ?? false,
alias: JSON.stringify(input.alias ?? []), alias: JSON.stringify(input.alias ?? []),
order: input.order ?? 0, order: input.order ?? 0,
visible: input.visible ?? true,
createdAt: timestamp, createdAt: timestamp,
updatedAt: timestamp, updatedAt: timestamp,
}) })
@@ -87,7 +92,16 @@ export function createService(input: ServiceCreateInput): Service {
/** /**
* Aktualisiert einen Dienst anhand von Benutzereingaben (z. B. über die Admin-UI). * Aktualisiert einen Dienst anhand von Benutzereingaben (z. B. über die Admin-UI).
* Für automatische Scan-Ergebnisse siehe upsertServiceFromScan() unten die * Für automatische Scan-Ergebnisse siehe upsertServiceFromScan() unten die
* NIEMALS displayName, category, favorite, order, alias oder icon überschreibt. * 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 { export function updateService(id: string, input: ServiceUpdateInput): Service | null {
const existing = getService(id); const existing = getService(id);
@@ -95,18 +109,25 @@ export function updateService(id: string, input: ServiceUpdateInput): Service |
db.update(services) db.update(services)
.set({ .set({
...(input.displayName !== undefined && { displayName: input.displayName }), ...(input.displayName !== undefined && {
displayName: input.displayName,
displayNameEditedManually: input.displayNameEditedManually ?? true,
}),
...(input.hostname !== undefined && { hostname: input.hostname }), ...(input.hostname !== undefined && { hostname: input.hostname }),
...(input.url !== undefined && { url: input.url }), ...(input.url !== undefined && { url: input.url }),
...(input.https !== undefined && { https: input.https }), ...(input.https !== undefined && { https: input.https }),
...(input.port !== undefined && { port: input.port }), ...(input.port !== undefined && { port: input.port }),
...(input.category !== undefined && { category: input.category }), ...(input.category !== undefined && {
category: input.category,
categoryEditedManually: input.categoryEditedManually ?? true,
}),
...(input.icon !== undefined && { icon: input.icon }), ...(input.icon !== undefined && { icon: input.icon }),
...(input.favicon !== undefined && { favicon: input.favicon }), ...(input.favicon !== undefined && { favicon: input.favicon }),
...(input.description !== undefined && { description: input.description }), ...(input.description !== undefined && { description: input.description }),
...(input.favorite !== undefined && { favorite: input.favorite }), ...(input.favorite !== undefined && { favorite: input.favorite }),
...(input.alias !== undefined && { alias: JSON.stringify(input.alias) }), ...(input.alias !== undefined && { alias: JSON.stringify(input.alias) }),
...(input.order !== undefined && { order: input.order }), ...(input.order !== undefined && { order: input.order }),
...(input.visible !== undefined && { visible: input.visible }),
updatedAt: nowIso(), updatedAt: nowIso(),
}) })
.where(eq(services.id, id)) .where(eq(services.id, id))
@@ -120,6 +141,23 @@ export function deleteService(id: string): boolean {
return result.changes > 0; 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 { export interface ServiceScanInput {
deviceId: string; deviceId: string;
hostname: string; hostname: string;
@@ -132,11 +170,36 @@ export interface ServiceScanInput {
suggestedDisplayName: string; suggestedDisplayName: string;
/** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird. */ /** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird. */
suggestedCategory?: string | null; 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 { export interface ScanUpsertResult {
service: Service; service: Service;
created: boolean; 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 { function findByDeviceAndPort(deviceId: string, port: number): Service | null {
@@ -144,33 +207,87 @@ function findByDeviceAndPort(deviceId: string, port: number): Service | null {
} }
/** /**
* Legt einen per Scan gefundenen Dienst an oder aktualisiert die scan-eigenen * Legt einen per Scan gefundenen Dienst an oder aktualisiert einen bereits
* Felder eines bereits bekannten Dienstes (abgeglichen über deviceId + port). * bekannten Dienst (abgeglichen über deviceId + port).
* *
* displayName, category, favorite, order, alias und icon werden bei einem * Bei einem BESTEHENDEN Dienst werden nur favicon/description aufgefrischt.
* bestehenden Dienst NIEMALS verändert nur beim erstmaligen Anlegen dienen * Alles andere auch hostname/url/https, die früher bei jedem Scan
* suggestedDisplayName/suggestedCategory als sinnvoller Startwert. * ü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 { export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult {
const existing = findByDeviceAndPort(input.deviceId, input.port); const existing = findByDeviceAndPort(input.deviceId, input.port);
const timestamp = nowIso(); const timestamp = nowIso();
if (existing) { 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) db.update(services)
.set({ .set({
hostname: input.hostname,
url: input.url,
https: input.https,
favicon: input.favicon ?? existing.favicon, favicon: input.favicon ?? existing.favicon,
description: input.description ?? existing.description, description: input.description ?? existing.description,
lastSuggestedDisplayName: input.suggestedDisplayName ?? existingRow.lastSuggestedDisplayName,
lastSuggestedCategory: input.suggestedCategory ?? existingRow.lastSuggestedCategory,
updatedAt: timestamp, updatedAt: timestamp,
}) })
.where(eq(services.id, existing.id)) .where(eq(services.id, existing.id))
.run(); .run();
return { service: getService(existing.id)!, created: false };
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(); const id = randomUUID();
try {
db.insert(services) db.insert(services)
.values({ .values({
id, id,
@@ -180,17 +297,32 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
favorite: false, favorite: false,
order: 0, order: 0,
alias: "[]", alias: "[]",
icon: null, icon: input.suggestedIcon ?? null,
hostname: input.hostname, hostname: input.hostname,
url: input.url, url: input.url,
https: input.https, https: input.https,
visible: input.suggestedVisible ?? true,
port: input.port, port: input.port,
favicon: input.favicon ?? null, favicon: input.favicon ?? null,
description: input.description ?? null, description: input.description ?? null,
lastSuggestedDisplayName: input.suggestedDisplayName,
lastSuggestedCategory: input.suggestedCategory ?? null,
createdAt: timestamp, createdAt: timestamp,
updatedAt: timestamp, updatedAt: timestamp,
}) })
.run(); .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 }; return { service: getService(id)!, created: true, nameChanges: [] };
} }

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

View File

@@ -13,14 +13,21 @@ export const devices = sqliteTable("devices", {
online: integer("online", { mode: "boolean" }).notNull().default(false), online: integer("online", { mode: "boolean" }).notNull().default(false),
source: text("source").notNull(), // fritzbox | dns | http | https | portscan | manual source: text("source").notNull(), // fritzbox | dns | http | https | portscan | manual
lastScan: text("last_scan"), // ISO-8601 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(), createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(), updatedAt: text("updated_at").notNull(),
}); });
/** /**
* Ein Dienst, der auf einem Gerät läuft (z. B. Frigate, Portainer, Gitea ...). * Ein Dienst, der auf einem Gerät läuft (z. B. Frigate, Portainer, Gitea ...).
* Benutzeränderungen (displayName, category, favorite, order, alias, icon) * Benutzeränderungen (displayName, category, favorite, order, alias, icon,
* dürfen von Scans NIEMALS überschrieben werden siehe README/Spezifikation. * 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", { export const services = sqliteTable("services", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
@@ -28,18 +35,39 @@ export const services = sqliteTable("services", {
.notNull() .notNull()
.references(() => devices.id, { onDelete: "cascade" }), .references(() => devices.id, { onDelete: "cascade" }),
// vom Benutzer gepflegt, wird von Scans nicht überschrieben // vom Benutzer gepflegt bzw. nur beim erstmaligen Anlegen durch einen Scan gesetzt
displayName: text("display_name").notNull(), displayName: text("display_name").notNull(),
category: text("category"), category: text("category"),
favorite: integer("favorite", { mode: "boolean" }).notNull().default(false), favorite: integer("favorite", { mode: "boolean" }).notNull().default(false),
order: real("order").notNull().default(0), order: real("order").notNull().default(0),
alias: text("alias").notNull().default("[]"), // JSON-Array als String alias: text("alias").notNull().default("[]"), // JSON-Array als String
icon: text("icon"), icon: text("icon"),
// von Scans aktualisierbar
hostname: text("hostname").notNull(), hostname: text("hostname").notNull(),
url: text("url").notNull(), url: text("url").notNull(),
https: integer("https", { mode: "boolean" }).notNull().default(false), 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(), port: integer("port").notNull(),
favicon: text("favicon"), favicon: text("favicon"),
description: text("description"), description: text("description"),
@@ -55,6 +83,7 @@ export const categories = sqliteTable("categories", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
name: text("name").notNull(), name: text("name").notNull(),
order: real("order").notNull().default(0), order: real("order").notNull().default(0),
color: text("color"),
createdAt: text("created_at").notNull(), createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(), updatedAt: text("updated_at").notNull(),
}); });
@@ -71,3 +100,94 @@ export const scanLogs = sqliteTable("scan_logs", {
message: text("message").notNull(), message: text("message").notNull(),
createdAt: text("created_at").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(),
});

View File

@@ -8,6 +8,21 @@ import { serviceRoutes } from "./routes/services.js";
import { categoryRoutes } from "./routes/categories.js"; import { categoryRoutes } from "./routes/categories.js";
import { scanRoutes } from "./routes/scan.js"; import { scanRoutes } from "./routes/scan.js";
import { logRoutes } from "./routes/logs.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 PORT = Number(process.env.PORT ?? 3001);
const HOST = process.env.HOST ?? "0.0.0.0"; const HOST = process.env.HOST ?? "0.0.0.0";
@@ -29,12 +44,42 @@ async function main() {
ensureSchema(); 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(healthRoutes);
await app.register(deviceRoutes); await app.register(deviceRoutes);
await app.register(serviceRoutes); await app.register(serviceRoutes);
await app.register(categoryRoutes); await app.register(categoryRoutes);
await app.register(scanRoutes); await app.register(scanRoutes);
await app.register(logRoutes); 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 () => { app.get("/", async () => {
return { name: "LaunchPad API", status: "running" }; return { name: "LaunchPad API", status: "running" };
@@ -43,6 +88,7 @@ async function main() {
try { try {
await app.listen({ port: PORT, host: HOST }); await app.listen({ port: PORT, host: HOST });
app.log.info(`LaunchPad backend läuft auf http://${HOST}:${PORT}`); app.log.info(`LaunchPad backend läuft auf http://${HOST}:${PORT}`);
startLiveStatusHeartbeat();
} catch (err) { } catch (err) {
app.log.error(err); app.log.error(err);
process.exit(1); process.exit(1);

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -1,26 +1,103 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import * as deviceRepo from "../db/repositories/devices.js"; import * as deviceRepo from "../db/repositories/devices.js";
import * as serviceRepo from "../db/repositories/services.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 logRepo from "../db/repositories/logs.js";
import { scanDeviceServices } from "../scanner/networkScanner.js"; import * as settingsRepo from "../db/repositories/settings.js";
import { fetchFritzBoxHosts } from "../scanner/fritzbox.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";
/** /**
* Scan-Endpunkte. Werden ausschließlich manuell per Knopfdruck ("Jetzt * Formatiert den masscan/httpx-Status für die Scan-Log-Zeile. Zeigt bei
* scannen") aus der Admin-UI ausgelöst es gibt keinerlei automatischen/ * masscan "nicht relevant" statt "nicht genutzt", wenn es bei diesem Scan
* zeitgesteuerten Scan. Jeder Versuch (Erfolg oder Fehler) wird in scan_logs * gar nicht versucht wurde (masscan läuft nur beim vollständigen Portscan,
* protokolliert und ist unter Admin -> Logs einsehbar. * siehe networkScanner.ts) - und hängt bei echten Fehlschlägen die konkrete
* Fehlermeldung an, statt nur "nicht genutzt/verfügbar" ohne Erklärung.
*/ */
export async function scanRoutes(app: FastifyInstance): Promise<void> { function formatToolsUsed(toolsUsed: {
app.post("/api/scan/devices/:id", async (request, reply) => { masscan: boolean;
const { id } = request.params as { id: string }; masscanAttempted: boolean;
const device = deviceRepo.getDevice(id); masscanError: string | null;
if (!device) { httpx: boolean;
return reply.code(404).send({ error: "Gerät nicht gefunden" }); 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 { try {
const discovered = await scanDeviceServices(device); 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) => const results = discovered.map((found) =>
serviceRepo.upsertServiceFromScan({ serviceRepo.upsertServiceFromScan({
@@ -33,6 +110,8 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
description: found.description, description: found.description,
suggestedDisplayName: found.suggestedDisplayName, suggestedDisplayName: found.suggestedDisplayName,
suggestedCategory: found.category, suggestedCategory: found.category,
suggestedIcon: found.icon,
suggestedVisible: found.visible,
}) })
); );
@@ -48,21 +127,174 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
const created = results.filter((r) => r.created).length; const created = results.filter((r) => r.created).length;
const updated = 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({ logRepo.logScan({
type: "device", type: "device",
targetId: device.id, targetId: device.id,
level: "info", level: "info",
message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden, ${created} neu, ${updated} aktualisiert`, 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 { return {
deviceId: device.id, deviceId: device.id,
scannedPorts: discovered.length, scannedPorts: discovered.length,
ports,
created, created,
updated, updated,
services: results.map((r) => r.service), 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) { } catch (err) {
const detail = err instanceof Error ? err.message : String(err); const detail = err instanceof Error ? err.message : String(err);
logRepo.logScan({ logRepo.logScan({
@@ -73,13 +305,468 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
}); });
request.log.error(err); request.log.error(err);
return reply.code(500).send({ error: "Geräte-Scan fehlgeschlagen", detail }); 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. // FritzBox-Scan: liest die Geräteliste per TR-064 und legt/aktualisiert Geräte.
// Erfordert FRITZBOX_HOST / FRITZBOX_USERNAME / FRITZBOX_PASSWORD (optional // Erfordert FRITZBOX_HOST / FRITZBOX_USERNAME / FRITZBOX_PASSWORD (optional
// FRITZBOX_PORT, Default 49000) als Umgebungsvariablen. // FRITZBOX_PORT, Default 49000) als Umgebungsvariablen.
app.post("/api/scan/fritzbox", async (request, reply) => { 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 host = process.env.FRITZBOX_HOST;
const username = process.env.FRITZBOX_USERNAME; const username = process.env.FRITZBOX_USERNAME;
const password = process.env.FRITZBOX_PASSWORD; const password = process.env.FRITZBOX_PASSWORD;
@@ -91,11 +778,27 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
}); });
} }
const port = process.env.FRITZBOX_PORT ? Number(process.env.FRITZBOX_PORT) : 49000; 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 { try {
const devicesBeforeScan = deviceRepo.listDevices().filter((d) => d.source === "fritzbox");
const hosts = await fetchFritzBoxHosts({ host, port, username, password }); const hosts = await fetchFritzBoxHosts({ host, port, username, password });
const devices = hosts.map((h) => // 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({ deviceRepo.upsertDeviceFromScan({
hostname: h.hostname, hostname: h.hostname,
ip: h.ip, ip: h.ip,
@@ -105,13 +808,121 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
}) })
); );
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({ logRepo.logScan({
type: "fritzbox", type: "fritzbox",
level: "info", level: "info",
message: `FritzBox-Scan: ${hosts.length} Gerät(e) gefunden`, 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}`,
}); });
return { found: hosts.length, devices }; finishJob("fritzbox", {
found: hosts.length,
uniqueDevices: devices.length,
staleDevices,
deviceChanges,
changesSinceLastScan,
});
} catch (err) { } catch (err) {
const detail = err instanceof Error ? err.message : String(err); const detail = err instanceof Error ? err.message : String(err);
logRepo.logScan({ logRepo.logScan({
@@ -119,8 +930,10 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
level: "error", level: "error",
message: `FritzBox-Scan fehlgeschlagen ${detail}`, message: `FritzBox-Scan fehlgeschlagen ${detail}`,
}); });
request.log.error(err); failJob("fritzbox", detail);
return reply.code(502).send({ error: "FritzBox-Scan fehlgeschlagen", detail });
} }
})();
return reply.code(202).send({ started: true });
}); });
} }

View File

@@ -1,7 +1,8 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { ServiceCreateSchema, ServiceUpdateSchema } from "@launchpad/shared"; import { ServiceCreateSchema, ServiceReorderSchema, ServiceUpdateSchema } from "@launchpad/shared";
import * as deviceRepo from "../db/repositories/devices.js"; import * as deviceRepo from "../db/repositories/devices.js";
import * as serviceRepo from "../db/repositories/services.js"; import * as serviceRepo from "../db/repositories/services.js";
import { suggestIconsForServiceName } from "../scanner/iconDb.js";
interface ServiceListQuery { interface ServiceListQuery {
deviceId?: string; deviceId?: string;
@@ -43,6 +44,15 @@ export async function serviceRoutes(app: FastifyInstance): Promise<void> {
return reply.code(201).send(service); 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) => { app.patch("/api/services/:id", async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const parsed = ServiceUpdateSchema.safeParse(request.body); const parsed = ServiceUpdateSchema.safeParse(request.body);
@@ -64,4 +74,20 @@ export async function serviceRoutes(app: FastifyInstance): Promise<void> {
} }
return reply.code(204).send(); 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 };
});
} }

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

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

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

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

View File

@@ -1,25 +1,80 @@
import { lookup } from "node:dns/promises"; import { lookup, reverse } from "node:dns/promises";
export interface DnsResolution { export interface DnsResolution {
hostname: string; hostname: string;
ip: 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. * Versucht der Spezifikation folgend: hostname, hostname.home, hostname.local.
* Gibt die erste erfolgreich aufgelöste Variante zurück, sonst null. * 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> { export async function resolveHostname(shortName: string): Promise<DnsResolution | null> {
const candidates = [shortName, `${shortName}.home`, `${shortName}.local`]; const candidates = [shortName, `${shortName}.home`, `${shortName}.local`];
for (const hostname of candidates) { for (const hostname of candidates) {
try { try {
const { address } = await lookup(hostname); const { address } = await withTimeout(lookup(hostname), DNS_TIMEOUT_MS);
return { hostname, ip: address }; return { hostname, ip: address };
} catch { } catch {
// nächste Variante versuchen // nächste Variante versuchen (Fehler ODER Timeout)
} }
} }
return null; 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;
}
}

View File

@@ -6,6 +6,7 @@ export interface HttpProbeResult {
status?: number; status?: number;
title?: string; title?: string;
faviconUrl?: string; faviconUrl?: string;
description?: string;
server?: string; server?: string;
bodySnippet?: string; bodySnippet?: string;
} }
@@ -46,6 +47,7 @@ export function probeHttp(baseUrl: string, timeoutMs = 2000): Promise<HttpProbeR
status: res.statusCode, status: res.statusCode,
title: extractTitle(body), title: extractTitle(body),
faviconUrl: extractFaviconUrl(body, baseUrl), faviconUrl: extractFaviconUrl(body, baseUrl),
description: extractDescription(body),
server: Array.isArray(serverHeader) ? serverHeader[0] : serverHeader, server: Array.isArray(serverHeader) ? serverHeader[0] : serverHeader,
bodySnippet: body, bodySnippet: body,
}); });
@@ -75,10 +77,25 @@ function extractFaviconUrl(html: string, baseUrl: string): string | undefined {
/<link[^>]+rel=["'](?:shortcut icon|icon)["'][^>]*href=["']([^"']+)["']/i /<link[^>]+rel=["'](?:shortcut icon|icon)["'][^>]*href=["']([^"']+)["']/i
); );
const href = match?.[1]; 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 { try {
return new URL(href ?? "/favicon.ico", baseUrl).toString(); return new URL(href, baseUrl).toString();
} catch { } catch {
return undefined; 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;
}

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

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

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

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

View File

@@ -1,7 +1,10 @@
import { resolveHostname } from "./dns.js"; import { resolveHostname, reverseLookup } from "./dns.js";
import { isPortOpen, TYPICAL_PORTS } from "./ports.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 { probeHttp } from "./http.js";
import { detectSoftware } from "./softwareDetection.js"; import { detectSoftware } from "./softwareDetection.js";
import { findBestIconMatch } from "./iconDb.js";
export interface ScanTarget { export interface ScanTarget {
hostname: string; hostname: string;
@@ -17,6 +20,103 @@ export interface DiscoveredService {
description?: string; description?: string;
suggestedDisplayName: string; suggestedDisplayName: string;
category?: 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;
} }
/** /**
@@ -27,40 +127,298 @@ export interface DiscoveredService {
*/ */
export async function scanDeviceServices( export async function scanDeviceServices(
device: ScanTarget, device: ScanTarget,
extraPorts: number[] = TYPICAL_PORTS extraPorts: number[] = TYPICAL_PORTS,
): Promise<DiscoveredService[]> { priorityPorts: number[] = [],
preFetched?: PreFetchedScanData
): Promise<DeviceScanResult> {
const dnsResult = await resolveHostname(device.hostname); const dnsResult = await resolveHostname(device.hostname);
// Fällt auf die IP zurück, falls keine DNS-Variante auflösbar ist. // Fällt auf die IP zurück, falls keine DNS-Variante auflösbar ist.
const address = dnsResult?.hostname ?? device.ip; const address = dnsResult?.hostname ?? device.ip;
const candidatePorts = Array.from(new Set([80, 443, ...extraPorts])); // 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[] = []; const found: DiscoveredService[] = [];
for (const port of candidatePorts) { // Die HTTP-Abfrage je offenem Port läuft PARALLEL, nicht mehr
const open = await isPortOpen(device.ip, port); // nacheinander - bei bis zu 3 HTTP-Versuchen (Protokoll-Fallback +
if (!open) continue; // 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);
const isHttps = port === 443 || port === 9443; // probe.status === undefined heißt: die Verbindung ist auf HTTP-Ebene
const baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`; // gescheitert (z. B. TLS-Handshake-Fehler bei einem HTTPS-only-Dienst wie
const probe = await probeHttp(baseUrl); // 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({ const software = detectSoftware({
server: probe.server, server: probe.server,
body: probe.bodySnippet, body: probe.bodySnippet,
title: probe.title,
port, port,
}); });
found.push({ 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, hostname: address,
url: baseUrl, url: baseUrl,
https: isHttps, https: isHttps,
port, port,
favicon: probe.faviconUrl, favicon: probe.faviconUrl,
description: software ? `${software.name} (automatisch erkannt)` : probe.title, description: softwareResolved ? `${softwareResolved.name} (automatisch erkannt)` : probe.title,
suggestedDisplayName: software?.name ?? probe.title ?? `${address}:${port}`, suggestedDisplayName:
category: software?.category, softwareResolved?.name ?? (titleIsJunkResolved ? fallbackName : probe.title ?? fallbackName),
}); category: softwareResolved?.category,
icon: softwareResolved?.icon,
visible,
};
} }
return found; 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,
},
};
} }

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

View File

@@ -3,14 +3,72 @@ import { connect } from "node:net";
/** /**
* Typische Ports für den optionalen Portscanner, gemäß Spezifikation. * Typische Ports für den optionalen Portscanner, gemäß Spezifikation.
*/ */
export const TYPICAL_PORTS = [80, 443, 3000, 3001, 5000, 5001, 8080, 8123, 9000, 9443]; 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, * Prüft per TCP-Connect, ob ein Port offen ist. Kein Protokoll-Handshake,
* nur "kann eine Verbindung aufgebaut werden" schnell und protokollunabhängig. * 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 function isPortOpen(host: string, port: number, timeoutMs = 800): Promise<boolean> { export async function isPortOpen(host: string, port: number, timeoutMs = 800): Promise<boolean> {
return new Promise((resolve) => { await globalScanSemaphore.acquire();
try {
return await new Promise((resolve) => {
const socket = connect({ host, port, timeout: timeoutMs }); const socket = connect({ host, port, timeout: timeoutMs });
const finish = (result: boolean) => { const finish = (result: boolean) => {
@@ -23,4 +81,42 @@ export function isPortOpen(host: string, port: number, timeoutMs = 800): Promise
socket.once("timeout", () => finish(false)); socket.once("timeout", () => finish(false));
socket.once("error", () => 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;
}

View File

@@ -1,6 +1,7 @@
export interface SoftwareSignatureInput { export interface SoftwareSignatureInput {
server?: string; server?: string;
body?: string; body?: string;
title?: string;
port: number; port: number;
} }
@@ -8,18 +9,37 @@ export interface SoftwareSignature {
name: string; name: string;
category: string; category: string;
matches: (input: SoftwareSignatureInput) => boolean; 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 { function bodyContains(input: SoftwareSignatureInput, pattern: RegExp): boolean {
return !!input.body && pattern.test(input.body); 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 * Automatische Softwareerkennung anhand von HTTP-Response-Merkmalen
* (Server-Header, HTML-Inhalt). Liste gemäß Spezifikation. * (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[] = [ export const SOFTWARE_SIGNATURES: SoftwareSignature[] = [
{ name: "Home Assistant", category: "Smart Home", matches: (i) => bodyContains(i, /home\s*assistant/i) }, {
name: "Home Assistant",
category: "Smart Home",
matches: (i) =>
titleMatches(i, /^home\s*assistant$/i) || bodyContains(i, /frontend_latest\//i),
},
{ {
name: "Synology DSM", name: "Synology DSM",
category: "NAS", category: "NAS",
@@ -40,10 +60,29 @@ export const SOFTWARE_SIGNATURES: SoftwareSignature[] = [
{ name: "Jellyfin", category: "Medien", matches: (i) => bodyContains(i, /jellyfin/i) }, { name: "Jellyfin", category: "Medien", matches: (i) => bodyContains(i, /jellyfin/i) },
{ name: "Nextcloud", category: "Cloud", matches: (i) => bodyContains(i, /nextcloud/i) }, { name: "Nextcloud", category: "Cloud", matches: (i) => bodyContains(i, /nextcloud/i) },
{ name: "Pi-hole", category: "DNS", matches: (i) => bodyContains(i, /pi-?hole/i) }, { name: "Pi-hole", category: "DNS", matches: (i) => bodyContains(i, /pi-?hole/i) },
{ name: "AdGuard Home", category: "DNS", matches: (i) => bodyContains(i, /adguard/i) }, {
name: "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) }, { name: "UniFi Network", category: "Netzwerk", matches: (i) => bodyContains(i, /unifi/i) },
]; ];
export function detectSoftware(input: SoftwareSignatureInput): SoftwareSignature | null { /**
return SOFTWARE_SIGNATURES.find((signature) => signature.matches(input)) ?? null; * 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
);
} }

View File

@@ -24,7 +24,12 @@ RUN pnpm --filter @launchpad/frontend build
# ---- runtime: nginx -------------------------------------------------------- # ---- runtime: nginx --------------------------------------------------------
FROM nginx:1.27-alpine AS runtime 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.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 COPY --from=build /app/apps/frontend/dist /usr/share/nginx/html
EXPOSE 80 EXPOSE 80 443
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View 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 "$@"

View File

@@ -7,7 +7,26 @@
<meta name="description" content="Schneller, minimalistischer Homelab-Launcher" /> <meta name="description" content="Schneller, minimalistischer Homelab-Launcher" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <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> <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> </head>
<body class="bg-white dark:bg-black"> <body class="bg-white dark:bg-black">
<div id="root"></div> <div id="root"></div>

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

View File

@@ -1,39 +1,26 @@
# HTTP bleibt zusätzlich zu HTTPS verfügbar (kein erzwungener Redirect,
# damit bestehende http://-Zugriffe nicht plötzlich brechen).
server { server {
listen 80; listen 80;
server_name _; server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# PWA / SPA: alle unbekannten Routen auf index.html zurückführen include /etc/nginx/launchpad-locations.conf;
location / { }
try_files $uri $uri/ /index.html;
} # HTTPS Zertifikat wird vom Entrypoint-Skript automatisch erzeugt
# (selbstsigniert) oder ist vom Nutzer eingebunden, siehe README.
# API-Anfragen an den Backend-Container weiterleiten server {
location /api/ { listen 443 ssl;
proxy_pass http://backend:3001/api/; http2 on;
proxy_http_version 1.1; server_name _;
proxy_set_header Host $host; root /usr/share/nginx/html;
proxy_set_header X-Real-IP $remote_addr; index index.html;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; ssl_certificate /etc/nginx/certs/fullchain.pem;
} ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Service Worker und Manifest dürfen nicht langfristig gecacht werden,
# sonst bekommen installierte PWA-Nutzer nie ein Update mit. include /etc/nginx/launchpad-locations.conf;
location = /sw.js {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location = /manifest.webmanifest {
add_header Cache-Control "no-cache";
default_type application/manifest+json;
try_files $uri =404;
}
location ~* \.(?:css|js|svg|png|jpg|jpeg|gif|ico|woff2?)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800, immutable";
}
} }

View File

@@ -10,9 +10,15 @@
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "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/shared": "workspace:*",
"@launchpad/ui": "workspace:*", "@launchpad/ui": "workspace:*",
"@tanstack/react-query": "^5.51.23", "@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", "@tanstack/react-router": "^1.58.3",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -16,3 +16,17 @@ body {
"Segoe UI", "Segoe UI",
sans-serif; 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;
}

View File

@@ -1,10 +1,29 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 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 { RouterProvider } from "@tanstack/react-router";
import { router } from "./router.js"; import { router } from "./router.js";
import "./index.css"; 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({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
@@ -14,10 +33,29 @@ const queryClient = new QueryClient({
}, },
}); });
// 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( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister, maxAge: 24 * 60 * 60 * 1000 }}
>
<RouterProvider router={router} /> <RouterProvider router={router} />
</QueryClientProvider> </PersistQueryClientProvider>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -1,11 +1,15 @@
import { Outlet, createRootRoute, createRoute, createRouter, redirect } from "@tanstack/react-router"; import { Outlet, createRootRoute, createRoute, createRouter, redirect } from "@tanstack/react-router";
import { HomePage } from "./routes/HomePage.js"; import { HomePage } from "./routes/HomePage.js";
import { SearchRedirectPage } from "./routes/SearchRedirectPage.js";
import { AdminLayout } from "./routes/admin/AdminLayout.js"; import { AdminLayout } from "./routes/admin/AdminLayout.js";
import { DashboardPage } from "./routes/admin/DashboardPage.js"; import { DashboardPage } from "./routes/admin/DashboardPage.js";
import { DevicesPage } from "./routes/admin/DevicesPage.js"; import { DevicesPage } from "./routes/admin/DevicesPage.js";
import { ServicesPage } from "./routes/admin/ServicesPage.js"; import { ServicesPage } from "./routes/admin/ServicesPage.js";
import { BookmarksPage } from "./routes/admin/BookmarksPage.js";
import { CategoriesPage } from "./routes/admin/CategoriesPage.js"; import { CategoriesPage } from "./routes/admin/CategoriesPage.js";
import { ScannerPage } from "./routes/admin/ScannerPage.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 { PluginsPage } from "./routes/admin/PluginsPage.js";
import { SettingsPage } from "./routes/admin/SettingsPage.js"; import { SettingsPage } from "./routes/admin/SettingsPage.js";
import { LogsPage } from "./routes/admin/LogsPage.js"; import { LogsPage } from "./routes/admin/LogsPage.js";
@@ -20,6 +24,12 @@ const indexRoute = createRoute({
component: HomePage, component: HomePage,
}); });
const searchRedirectRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/search",
component: SearchRedirectPage,
});
const adminRoute = createRoute({ const adminRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: "/admin", path: "/admin",
@@ -53,6 +63,12 @@ const adminServicesRoute = createRoute({
component: ServicesPage, component: ServicesPage,
}); });
const adminBookmarksRoute = createRoute({
getParentRoute: () => adminRoute,
path: "/bookmarks",
component: BookmarksPage,
});
const adminCategoriesRoute = createRoute({ const adminCategoriesRoute = createRoute({
getParentRoute: () => adminRoute, getParentRoute: () => adminRoute,
path: "/categories", path: "/categories",
@@ -65,6 +81,18 @@ const adminScannerRoute = createRoute({
component: ScannerPage, component: ScannerPage,
}); });
const adminApisRoute = createRoute({
getParentRoute: () => adminRoute,
path: "/apis",
component: ApisPage,
});
const adminReadLaterRoute = createRoute({
getParentRoute: () => adminRoute,
path: "/read-later",
component: ReadLaterPage,
});
const adminPluginsRoute = createRoute({ const adminPluginsRoute = createRoute({
getParentRoute: () => adminRoute, getParentRoute: () => adminRoute,
path: "/plugins", path: "/plugins",
@@ -85,13 +113,17 @@ const adminLogsRoute = createRoute({
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
indexRoute, indexRoute,
searchRedirectRoute,
adminRoute.addChildren([ adminRoute.addChildren([
adminIndexRoute, adminIndexRoute,
adminDashboardRoute, adminDashboardRoute,
adminDevicesRoute, adminDevicesRoute,
adminServicesRoute, adminServicesRoute,
adminBookmarksRoute,
adminCategoriesRoute, adminCategoriesRoute,
adminScannerRoute, adminScannerRoute,
adminApisRoute,
adminReadLaterRoute,
adminPluginsRoute, adminPluginsRoute,
adminSettingsRoute, adminSettingsRoute,
adminLogsRoute, adminLogsRoute,

View File

@@ -1,47 +1,250 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { SearchInput, StatusBadge, ResultsList } from "@launchpad/ui"; import { SearchInput, StatusBadge, ResultsList, FavoritesBar, Favicon, Button } from "@launchpad/ui";
import { rankServices, type Service } from "@launchpad/shared"; 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 { 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 { useBackendHealth } from "../hooks/useBackendHealth.js";
import { useTheme } from "../hooks/useTheme.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";
function openService(service: Service) { async function toggleFavoriteRequest(item: SearchResult): Promise<void> {
window.open(service.url, "_blank", "noopener,noreferrer"); const path = item.kind === "service" ? `/api/services/${item.id}` : `/api/bookmarks/${item.id}`;
} const res = await fetch(path, {
async function toggleServiceFavorite(service: Service): Promise<Service> {
const res = await fetch(`/api/services/${service.id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ favorite: !service.favorite }), body: JSON.stringify({ favorite: !item.favorite }),
}); });
if (!res.ok) { if (!res.ok) {
throw new Error(`Favorit konnte nicht aktualisiert werden (HTTP ${res.status})`); 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(); return res.json();
} }
export function HomePage() { function ReadLaterBox() {
const [theme, toggleTheme] = useTheme();
const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const { health, error: healthError } = useBackendHealth();
const { data: services, isLoading, isError } = useServices();
const inputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [url, setUrl] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const toggleFavorite = useMutation({ const mutation = useMutation({
mutationFn: toggleServiceFavorite, mutationFn: () => saveReadLaterRequest(url.trim()),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["services"] }); setUrl("");
setOpen(false);
queryClient.invalidateQueries({ queryKey: ["read-later"] });
}, },
}); });
const results = useMemo( function handleSubmit(e: FormEvent) {
() => rankServices(services ?? [], query), e.preventDefault();
[services, query] 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 // Auswahl zurücksetzen, sobald sich die Trefferliste ändert
@@ -49,6 +252,31 @@ export function HomePage() {
setSelectedIndex(0); setSelectedIndex(0);
}, [results.length, query]); }, [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(() => { useEffect(() => {
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
const isSlash = e.key === "/" && document.activeElement !== inputRef.current; const isSlash = e.key === "/" && document.activeElement !== inputRef.current;
@@ -61,6 +289,7 @@ export function HomePage() {
} }
if (document.activeElement !== inputRef.current) return; if (document.activeElement !== inputRef.current) return;
if (query.trim().length === 0) return; // keine sichtbare Liste, nichts zu steuern
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
@@ -71,7 +300,7 @@ export function HomePage() {
} else if (e.key === "Enter") { } else if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
const target = results[selectedIndex]; const target = results[selectedIndex];
if (target) openService(target); if (target) openItem(target);
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
inputRef.current?.blur(); inputRef.current?.blur();
setQuery(""); setQuery("");
@@ -80,75 +309,241 @@ export function HomePage() {
window.addEventListener("keydown", onKeyDown); window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown);
}, [results, selectedIndex]); }, [results, selectedIndex, query]);
const isOnline = !healthError && health?.status === "ok"; 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 ( return (
<div className="flex min-h-screen flex-col items-center justify-start gap-8 bg-gradient-to-b from-white to-neutral-100 px-6 pt-[15vh] dark:from-black dark:to-neutral-950"> <div
<div className="fixed right-6 top-6 flex items-center gap-2"> 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 <Link
to="/admin" to="/admin"
aria-label="Adminbereich öffnen" aria-label="Adminbereich öffnen"
className="rounded-full border border-black/10 p-2 text-black/60 transition-colors 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" hover:bg-black/5 dark:border-white/10 dark:text-white/60 dark:hover:bg-white/5"
> >
<FontAwesomeIcon icon={faGear} />
</Link> </Link>
<button
onClick={toggleTheme}
aria-label="Theme wechseln"
className="rounded-full border border-black/10 p-2 text-black/60 transition-colors
hover:bg-black/5 dark:border-white/10 dark:text-white/60 dark:hover:bg-white/5"
>
{theme === "dark" ? "☀️" : "🌙"}
</button>
</div> </div>
<div className="flex flex-col items-center gap-2 text-center"> {/* Kopfbereich: Titel, Suchfeld, Favoriten/Später-lesen/Zuletzt-besucht.
<h1 className="text-4xl font-semibold tracking-tight text-black dark:text-white"> 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 LaunchPad
</h1> </h1>
<p className="text-black/50 dark:text-white/50"> <p className="text-sm text-black/40 dark:text-white/40">
Tippe, um deine Homelab-Dienste sofort zu öffnen. Tippe, um deine Homelab-Dienste sofort zu öffnen.
</p> </p>
</div> </div>
<div className="w-full max-w-xl"> <div ref={searchContainerRef} className="w-full max-w-xl">
<SearchInput <SearchInput
ref={inputRef} ref={inputRef}
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Dienst suchen … z. B. „frigate“" onClear={() => {
setQuery("");
inputRef.current?.focus();
}}
onFocus={() => setResultsVisible(true)}
placeholder="Dienst oder Lesezeichen suchen … z. B. „frigate“"
hint="⌘K" hint="⌘K"
autoFocus autoFocus
/> />
</div>
{isLoading ? ( {showShelf ? (
<p className="mt-4 text-center text-sm text-black/40 dark:text-white/40"> <div
Lade Dienste 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> </p>
) : isError ? ( )}
<p className="mt-4 text-center text-sm text-red-500"> </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. Dienste konnten nicht geladen werden.
</p> </p>
) : ( ) : (
<ResultsList <ResultsList
services={results} results={results}
selectedIndex={selectedIndex} selectedIndex={selectedIndex}
categoryColors={categoryColors}
onHover={setSelectedIndex} onHover={setSelectedIndex}
onOpen={openService} onOpen={openItem}
onToggleFavorite={(service) => toggleFavorite.mutate(service)} onToggleFavorite={(item) => toggleFavorite.mutate(item)}
emptyLabel={ emptyLabel={
(services?.length ?? 0) === 0 allItems.length === 0
? "Noch keine Dienste angelegt. Füge welche im Adminbereich hinzu." ? "Noch nichts angelegt. Füge Dienste oder Lesezeichen im Adminbereich hinzu."
: "Keine Treffer für deine Suche." : "Keine Treffer für deine Suche."
} }
/> />
)} )}
</div> </div>
</div>
) : null}
<div className="flex flex-col items-center gap-1 pb-8"> <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"} /> <StatusBadge online={isOnline} label={isOnline ? "Backend verbunden" : "Backend nicht erreichbar"} />
{health ? ( {health ? (
<span className="text-xs text-black/30 dark:text-white/30"> <span className="text-xs text-black/30 dark:text-white/30">

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

View File

@@ -1,31 +1,141 @@
import { useEffect, useState } from "react";
import { Link, Outlet, useRouterState } from "@tanstack/react-router"; 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";
const NAV_ITEMS = [ function ScanActivityBadge() {
{ to: "/admin/dashboard", label: "Dashboard", icon: "📊" }, const { running, pendingCount } = useScanActivity();
{ to: "/admin/devices", label: "Geräte", icon: "🖥️" },
{ to: "/admin/services", label: "Dienste", icon: "🔗" }, if (running) {
{ to: "/admin/scanner", label: "Scanner", icon: "🔍" }, return (
{ to: "/admin/categories", label: "Kategorien", icon: "🏷️" }, <span
{ to: "/admin/plugins", label: "Plugins", icon: "🧩" }, title="Ein Scan läuft gerade"
{ to: "/admin/settings", label: "Einstellungen", icon: "⚙️" }, className="ml-auto h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-blue-500"
{ to: "/admin/logs", label: "Logs", icon: "📜" }, />
] as const; );
}
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() { export function AdminLayout() {
const pathname = useRouterState({ select: (s) => s.location.pathname }); 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 ( return (
<div className="flex min-h-screen bg-neutral-50 dark:bg-neutral-950"> <div className="min-h-screen bg-neutral-50 dark:bg-neutral-950">
<aside className="flex w-56 shrink-0 flex-col border-r border-black/10 bg-white/70 dark:border-white/10 dark:bg-white/5"> {/* Mobile-Kopfleiste mit Hamburger-Button, nur unterhalb des sm-Breakpoints
<div className="flex items-center gap-2 border-b border-black/10 px-5 py-4 dark:border-white/10"> sichtbar. Fixiert am oberen Rand, damit sie bei langen Seiten (z. B.
<Link to="/" className="text-lg font-semibold text-black dark:text-white"> 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 LaunchPad
</Link> </Link>
</div> </div>
<nav className="flex flex-1 flex-col gap-0.5 p-3"> {/* 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) => { {NAV_ITEMS.map((item) => {
const active = pathname.startsWith(item.to); const active = item.to === "/" ? pathname === "/" : pathname.startsWith(item.to);
return ( return (
<Link <Link
key={item.to} key={item.to}
@@ -36,29 +146,34 @@ export function AdminLayout() {
: "text-black/60 hover:bg-black/5 hover:text-black dark:text-white/60 dark:hover:bg-white/5 dark:hover:text-white" : "text-black/60 hover:bg-black/5 hover:text-black dark:text-white/60 dark:hover:bg-white/5 dark:hover:text-white"
}`} }`}
> >
<span aria-hidden>{item.icon}</span> <span className="w-4 text-center" aria-hidden>
<FontAwesomeIcon icon={item.icon} />
</span>
{item.label} {item.label}
{item.to === "/admin/scanner" ? <ScanActivityBadge /> : null}
</Link> </Link>
); );
})} })}
</nav> </nav>
<div className="border-t border-black/10 p-3 dark:border-white/10"> <div className="shrink-0 border-t border-black/10 p-3 dark:border-white/10">
<Link <div className="px-3 py-1.5">
to="/" <StatusBadge
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium online={isOnline}
text-black/60 transition-colors hover:bg-black/5 hover:text-black label={isOnline ? "Backend verbunden" : "Backend nicht erreichbar"}
dark:text-white/60 dark:hover:bg-white/5 dark:hover:text-white" />
> </div>
<span aria-hidden></span>
Zurück zur Suche
</Link>
</div> </div>
</aside> </aside>
<main className="flex-1 overflow-y-auto p-8"> {/* 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 /> <Outlet />
</main> </main>
<ScrollToTopButton />
</div> </div>
); );
} }

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

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

View File

@@ -1,15 +1,17 @@
import { useState, type DragEvent, type FormEvent } from "react"; import { useState, type DragEvent, type FormEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFloppyDisk, faPen, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@launchpad/ui"; import { Button } from "@launchpad/ui";
import type { Category } from "@launchpad/shared"; import type { Category } from "@launchpad/shared";
import { useCategories } from "../../hooks/useCategories.js"; import { useCategories } from "../../hooks/useCategories.js";
import { AdminPageHeader } from "./AdminPageHeader.js"; import { AdminPageHeader } from "./AdminPageHeader.js";
async function createCategory(name: string): Promise<Category> { async function createCategory(name: string, color?: string): Promise<Category> {
const res = await fetch("/api/categories", { const res = await fetch("/api/categories", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }), body: JSON.stringify({ name, color }),
}); });
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
@@ -18,14 +20,15 @@ async function createCategory(name: string): Promise<Category> {
return res.json(); return res.json();
} }
async function renameCategory(id: string, name: string): Promise<Category> { async function patchCategory(id: string, patch: { name?: string; color?: string }): Promise<Category> {
const res = await fetch(`/api/categories/${id}`, { const res = await fetch(`/api/categories/${id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }), body: JSON.stringify(patch),
}); });
if (!res.ok) { if (!res.ok) {
throw new Error(`Kategorie konnte nicht umbenannt werden (HTTP ${res.status})`); const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `Kategorie konnte nicht aktualisiert werden (HTTP ${res.status})`);
} }
return res.json(); return res.json();
} }
@@ -67,13 +70,18 @@ function CategoryRow({
const [name, setName] = useState(category.name); const [name, setName] = useState(category.name);
const renameMutation = useMutation({ const renameMutation = useMutation({
mutationFn: () => renameCategory(category.id, name.trim()), mutationFn: () => patchCategory(category.id, { name: name.trim() }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] }); queryClient.invalidateQueries({ queryKey: ["categories"] });
setEditing(false); setEditing(false);
}, },
}); });
const colorMutation = useMutation({
mutationFn: (color: string) => patchCategory(category.id, { color }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["categories"] }),
});
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: () => deleteCategoryRequest(category.id), mutationFn: () => deleteCategoryRequest(category.id),
onSuccess: () => { onSuccess: () => {
@@ -96,6 +104,20 @@ function CategoryRow({
</span> </span>
<label
title="Farbe wählen"
className="relative h-6 w-6 shrink-0 cursor-pointer overflow-hidden rounded-full border
border-black/10 dark:border-white/10"
style={{ backgroundColor: category.color ?? "transparent" }}
>
<input
type="color"
value={category.color ?? "#94a3b8"}
onChange={(e) => colorMutation.mutate(e.target.value)}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</label>
{editing ? ( {editing ? (
<> <>
<input <input
@@ -105,22 +127,14 @@ function CategoryRow({
className="flex-1 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm className="flex-1 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
dark:border-white/10 dark:bg-white/10 dark:text-white" dark:border-white/10 dark:bg-white/10 dark:text-white"
/> />
<Button size="sm" variant="primary" onClick={() => renameMutation.mutate()} disabled={renameMutation.isPending}> <Button size="icon" variant="primary" onClick={() => renameMutation.mutate()} disabled={renameMutation.isPending} title="Speichern" aria-label="Speichern"><FontAwesomeIcon icon={faFloppyDisk} /></Button>
Speichern <Button size="icon" variant="ghost" onClick={() => setEditing(false)} title="Abbrechen" aria-label="Abbrechen"><FontAwesomeIcon icon={faXmark} /></Button>
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
Abbrechen
</Button>
</> </>
) : ( ) : (
<> <>
<span className="flex-1 text-sm font-medium text-black dark:text-white">{category.name}</span> <span className="flex-1 text-sm font-medium text-black dark:text-white">{category.name}</span>
<Button size="sm" onClick={() => setEditing(true)}> <Button size="icon" onClick={() => setEditing(true)} title="Umbenennen" aria-label="Umbenennen"><FontAwesomeIcon icon={faPen} /></Button>
Umbenennen <Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
</Button>
<Button size="sm" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending}>
Löschen
</Button>
</> </>
)} )}
</li> </li>
@@ -131,13 +145,15 @@ export function CategoriesPage() {
const { data: categories, isLoading, isError } = useCategories(); const { data: categories, isLoading, isError } = useCategories();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [newColor, setNewColor] = useState("#94a3b8");
const [draggedId, setDraggedId] = useState<string | null>(null); const [draggedId, setDraggedId] = useState<string | null>(null);
const [localOrder, setLocalOrder] = useState<Category[] | null>(null); const [localOrder, setLocalOrder] = useState<Category[] | null>(null);
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: () => createCategory(newName.trim()), mutationFn: () => createCategory(newName.trim(), newColor),
onSuccess: () => { onSuccess: () => {
setNewName(""); setNewName("");
setNewColor("#94a3b8");
queryClient.invalidateQueries({ queryKey: ["categories"] }); queryClient.invalidateQueries({ queryKey: ["categories"] });
}, },
}); });
@@ -193,10 +209,22 @@ export function CategoriesPage() {
<div> <div>
<AdminPageHeader <AdminPageHeader
title="Kategorien" title="Kategorien"
description="Per Drag & Drop sortieren. Favoriten erscheinen in der Suche trotzdem immer zuerst." description="Per Drag & Drop sortieren. Farbe erscheint als Punkt in der Suche und als Ring bei Favoriten. Favoriten erscheinen in der Suche trotzdem immer zuerst."
/> />
<form onSubmit={handleSubmit} className="mb-6 flex items-end gap-2"> <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">
Farbe
</label>
<input
type="color"
value={newColor}
onChange={(e) => setNewColor(e.target.value)}
className="h-9 w-12 cursor-pointer rounded-lg border border-black/10 bg-white
dark:border-white/10 dark:bg-white/5"
/>
</div>
<div> <div>
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50"> <label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
Neue Kategorie Neue Kategorie

View File

@@ -1,6 +1,11 @@
import { Link } from "@tanstack/react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faHouse } from "@fortawesome/free-solid-svg-icons";
import { useServices } from "../../hooks/useServices.js"; import { useServices } from "../../hooks/useServices.js";
import { useDevices } from "../../hooks/useDevices.js"; import { useDevices } from "../../hooks/useDevices.js";
import { useCategories } from "../../hooks/useCategories.js"; import { useCategories } from "../../hooks/useCategories.js";
import { useBookmarks } from "../../hooks/useBookmarks.js";
import { useReadLater } from "../../hooks/useReadLater.js";
import { AdminPageHeader } from "./AdminPageHeader.js"; import { AdminPageHeader } from "./AdminPageHeader.js";
function StatCard({ label, value }: { label: string; value: number | string }) { function StatCard({ label, value }: { label: string; value: number | string }) {
@@ -16,9 +21,12 @@ export function DashboardPage() {
const { data: services } = useServices(); const { data: services } = useServices();
const { data: devices } = useDevices(); const { data: devices } = useDevices();
const { data: categories } = useCategories(); const { data: categories } = useCategories();
const { data: bookmarks } = useBookmarks();
const { data: readLaterItems } = useReadLater();
const onlineDevices = devices?.filter((d) => d.online).length ?? 0; const onlineDevices = devices?.filter((d) => d.online).length ?? 0;
const favoriteServices = services?.filter((s) => s.favorite).length ?? 0; const favoriteServices = services?.filter((s) => s.favorite).length ?? 0;
const favoriteBookmarks = bookmarks?.filter((b) => b.favorite).length ?? 0;
const recentlyScanned = [...(devices ?? [])] const recentlyScanned = [...(devices ?? [])]
.filter((d) => d.lastScan) .filter((d) => d.lastScan)
@@ -30,13 +38,27 @@ export function DashboardPage() {
<AdminPageHeader <AdminPageHeader
title="Dashboard" title="Dashboard"
description="Überblick über dein Homelab." description="Überblick über dein Homelab."
actions={
<Link
to="/"
className="flex items-center gap-1.5 rounded-lg border border-black/10 px-3 py-1.5
text-sm text-black/70 transition-colors hover:bg-black/5 dark:border-white/10
dark:text-white/70 dark:hover:bg-white/10"
>
<span aria-hidden><FontAwesomeIcon icon={faHouse} /></span> Startseite
</Link>
}
/> />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-8">
<StatCard label="Geräte" value={devices?.length ?? 0} /> <StatCard label="Geräte" value={devices?.length ?? 0} />
<StatCard label="davon online" value={onlineDevices} /> <StatCard label="davon online" value={onlineDevices} />
<StatCard label="Dienste" value={services?.length ?? 0} /> <StatCard label="Dienste" value={services?.length ?? 0} />
<StatCard label="Favoriten" value={favoriteServices} /> <StatCard label="Lesezeichen" value={bookmarks?.length ?? 0} />
<StatCard label="Kategorien" value={categories?.length ?? 0} />
<StatCard label="Später lesen" value={readLaterItems?.length ?? 0} />
<StatCard label="Favoriten (Dienste)" value={favoriteServices} />
<StatCard label="Favoriten (Lesezeichen)" value={favoriteBookmarks} />
</div> </div>
<div className="mt-8"> <div className="mt-8">
@@ -49,6 +71,7 @@ export function DashboardPage() {
</p> </p>
) : ( ) : (
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10"> <div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<tbody> <tbody>
{recentlyScanned.map((device) => ( {recentlyScanned.map((device) => (
@@ -56,11 +79,11 @@ export function DashboardPage() {
key={device.id} key={device.id}
className="border-b border-black/5 last:border-0 dark:border-white/5" className="border-b border-black/5 last:border-0 dark:border-white/5"
> >
<td className="px-4 py-3 font-medium text-black dark:text-white"> <td className="whitespace-nowrap px-4 py-3 font-medium text-black dark:text-white">
{device.hostname} {device.hostname}
</td> </td>
<td className="px-4 py-3 text-black/40 dark:text-white/40">{device.ip}</td> <td className="whitespace-nowrap px-4 py-3 text-black/40 dark:text-white/40">{device.ip}</td>
<td className="px-4 py-3 text-black/40 dark:text-white/40"> <td className="whitespace-nowrap px-4 py-3 text-black/40 dark:text-white/40">
{device.lastScan ? new Date(device.lastScan).toLocaleString("de-DE") : ""} {device.lastScan ? new Date(device.lastScan).toLocaleString("de-DE") : ""}
</td> </td>
</tr> </tr>
@@ -68,16 +91,8 @@ export function DashboardPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
)}
</div> </div>
)}
<div className="mt-8">
<h2 className="mb-3 text-sm font-semibold text-black/60 dark:text-white/60">
Kategorien
</h2>
<p className="text-sm text-black/40 dark:text-white/40">
{categories?.length ?? 0} Kategorie(n) angelegt.
</p>
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,9 @@
import { useState, type FormEvent } from "react"; import { useMemo, useState, type FormEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBan, faCheck, faFloppyDisk, faPen, faTrash, faXmark, faSort, faSortUp, faSortDown } from "@fortawesome/free-solid-svg-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@launchpad/ui"; import { Button, Favicon } from "@launchpad/ui";
import type { Service } from "@launchpad/shared";
import { useDevices, type DeviceWithServices } from "../../hooks/useDevices.js"; import { useDevices, type DeviceWithServices } from "../../hooks/useDevices.js";
import { AdminPageHeader } from "./AdminPageHeader.js"; import { AdminPageHeader } from "./AdminPageHeader.js";
@@ -17,6 +20,18 @@ async function createDevice(input: { hostname: string; ip: string }) {
return res.json(); return res.json();
} }
async function patchDevice(id: string, patch: Record<string, unknown>) {
const res = await fetch(`/api/devices/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
if (!res.ok) {
throw new Error(`Gerät konnte nicht aktualisiert werden (HTTP ${res.status})`);
}
return res.json();
}
async function deleteDevice(id: string) { async function deleteDevice(id: string) {
const res = await fetch(`/api/devices/${id}`, { method: "DELETE" }); const res = await fetch(`/api/devices/${id}`, { method: "DELETE" });
if (!res.ok && res.status !== 404) { if (!res.ok && res.status !== 404) {
@@ -24,14 +39,48 @@ async function deleteDevice(id: string) {
} }
} }
interface ScanResult { async function deleteServiceRequest(id: string) {
scannedPorts: number; const res = await fetch(`/api/services/${id}`, { method: "DELETE" });
created: number; if (!res.ok && res.status !== 404) {
updated: number; throw new Error(`Dienst konnte nicht gelöscht werden (HTTP ${res.status})`);
}
} }
async function scanDevice(id: string): Promise<ScanResult> { async function patchService(id: string, patch: Record<string, unknown>) {
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST" }); const res = await fetch(`/api/services/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
if (!res.ok) {
throw new Error(`Dienst konnte nicht aktualisiert werden (HTTP ${res.status})`);
}
return res.json();
}
interface ScanNameChange {
serviceId: string;
serviceHostname: string;
servicePort: number;
field: "displayName" | "category";
current: string | null;
suggested: string;
/** true = weicht nur ab, weil der Wert zuvor manuell bearbeitet wurde. */
manualOverride: boolean;
}
interface ScanResult {
scannedPorts: number;
ports: number[];
created: number;
updated: number;
staleServices: Service[];
nameChanges: ScanNameChange[];
deviceNameSuggestion: string | null;
}
async function scanDevice(id: string, signal?: AbortSignal, full = false): Promise<ScanResult> {
const res = await fetch(`/api/scan/devices/${id}${full ? "?full=true" : ""}`, { method: "POST", signal });
const body = await res.json(); const body = await res.json();
if (!res.ok) { if (!res.ok) {
throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`); throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`);
@@ -39,20 +88,273 @@ async function scanDevice(id: string): Promise<ScanResult> {
return body; return body;
} }
function EditDeviceForm({ device, onDone }: { device: DeviceWithServices; onDone: () => void }) {
const queryClient = useQueryClient();
const [hostname, setHostname] = useState(device.hostname);
const [ip, setIp] = useState(device.ip);
const [mac, setMac] = useState(device.mac ?? "");
const [manufacturer, setManufacturer] = useState(device.manufacturer ?? "");
const [model, setModel] = useState(device.model ?? "");
const mutation = useMutation({
mutationFn: () =>
patchDevice(device.id, {
hostname: hostname.trim(),
ip: ip.trim(),
mac: mac.trim() || undefined,
manufacturer: manufacturer.trim() || undefined,
model: model.trim() || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["devices"] });
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={8} 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">Hostname</label>
<input
value={hostname}
onChange={(e) => setHostname(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">IP</label>
<input
value={ip}
onChange={(e) => setIp(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">
MAC-Adresse
</label>
<input
value={mac}
onChange={(e) => setMac(e.target.value)}
placeholder="AA:BB:CC:DD:EE:FF"
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>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Hersteller</label>
<input
value={manufacturer}
onChange={(e) => setManufacturer(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">Modell</label>
<input
value={model}
onChange={(e) => setModel(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 className="flex 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>
</div>
</div>
</td>
</tr>
);
}
function NameChangesReview({
nameChanges,
onDone,
}: {
nameChanges: ScanNameChange[];
onDone: () => void;
}) {
const queryClient = useQueryClient();
const [handled, setHandled] = useState<Set<string>>(new Set());
const applyMutation = useMutation({
mutationFn: (change: ScanNameChange) => {
const resetFlag = change.field === "displayName" ? "displayNameEditedManually" : "categoryEditedManually";
return patchService(change.serviceId, { [change.field]: change.suggested, [resetFlag]: false });
},
onSuccess: (_data, change) => {
setHandled((prev) => new Set(prev).add(`${change.serviceId}:${change.field}`));
queryClient.invalidateQueries({ queryKey: ["services"] });
},
});
const remaining = nameChanges.filter(
(c) => !handled.has(`${c.serviceId}:${c.field}`)
);
if (remaining.length === 0) return null;
const fieldLabel = (field: ScanNameChange["field"]) =>
field === "displayName" ? "Name" : "Kategorie";
return (
<div className="mt-2 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
<p className="mb-2 font-medium text-blue-700 dark:text-blue-400">
{remaining.length} erkannte Änderung(en) weichen vom gespeicherten Wert ab:
</p>
<ul className="space-y-1">
{remaining.map((c) => (
<li key={`${c.serviceId}:${c.field}`} className="flex items-center justify-between gap-2">
<span className="text-black/70 dark:text-white/70">
{c.serviceHostname}:{c.servicePort} {fieldLabel(c.field)}: {c.current ?? ""}" → „{c.suggested}"
</span>
<div className="flex gap-1">
<Button
size="icon"
variant="ghost"
onClick={() => setHandled((prev) => new Set(prev).add(`${c.serviceId}:${c.field}`))}
title="Behalten (Vorschlag verwerfen)"
aria-label="Behalten (Vorschlag verwerfen)"
><FontAwesomeIcon icon={faBan} /></Button>
<Button
size="icon"
variant="primary"
onClick={() => applyMutation.mutate(c)}
disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"><FontAwesomeIcon icon={faCheck} /></Button>
</div>
</li>
))}
</ul>
<button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40">
Hinweis schließen
</button>
</div>
);
}
function DeviceNameSuggestionBanner({
deviceId,
suggestion,
onDone,
}: {
deviceId: string;
suggestion: string;
onDone: () => void;
}) {
const queryClient = useQueryClient();
const applyMutation = useMutation({
mutationFn: () => patchDevice(deviceId, { hostname: suggestion }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["devices"] });
onDone();
},
});
return (
<div className="mt-2 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
<div className="flex items-center justify-between gap-2">
<span className="text-black/70 dark:text-white/70">
Per Reverse-DNS gefunden: {suggestion}" als Gerätename?
</span>
<div className="flex gap-1">
<Button size="icon" variant="ghost" onClick={onDone} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
<Button size="icon" variant="primary" onClick={() => applyMutation.mutate()} disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"><FontAwesomeIcon icon={faCheck} /></Button>
</div>
</div>
</div>
);
}
function StaleServicesReview({ staleServices, onDone }: { staleServices: Service[]; onDone: () => void }) {
const queryClient = useQueryClient();
const [handled, setHandled] = useState<Set<string>>(new Set());
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteServiceRequest(id),
onSuccess: (_data, id) => {
setHandled((prev) => new Set(prev).add(id));
queryClient.invalidateQueries({ queryKey: ["services"] });
},
});
const remaining = staleServices.filter((s) => !handled.has(s.id));
if (remaining.length === 0) return null;
return (
<div className="mt-2 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
<p className="mb-2 font-medium text-amber-700 dark:text-amber-400">
{remaining.length} Dienst(e) beim letzten Scan nicht mehr gefunden (Port nicht mehr offen):
</p>
<ul className="space-y-1">
{remaining.map((s) => (
<li key={s.id} className="flex items-center justify-between gap-2">
<span className="text-black/70 dark:text-white/70">
{s.displayName} ({s.hostname}:{s.port})
</span>
<div className="flex gap-1">
<Button size="icon" variant="ghost" onClick={() => setHandled((prev) => new Set(prev).add(s.id))} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
<Button
size="icon"
variant="danger"
onClick={() => deleteMutation.mutate(s.id)}
disabled={deleteMutation.isPending} title="Entfernen" aria-label="Entfernen"><FontAwesomeIcon icon={faTrash} /></Button>
</div>
</li>
))}
</ul>
<button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40">
Hinweis schließen
</button>
</div>
);
}
function DeviceRow({ device }: { device: DeviceWithServices }) { function DeviceRow({ device }: { device: DeviceWithServices }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [scanMessage, setScanMessage] = useState<string | null>(null); const [scanMessage, setScanMessage] = useState<string | null>(null);
const [staleServices, setStaleServices] = useState<Service[]>([]);
const [nameChanges, setNameChanges] = useState<ScanNameChange[]>([]);
const [deviceNameSuggestion, setDeviceNameSuggestion] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
const [editing, setEditing] = useState(false);
const [scanAbort, setScanAbort] = useState<AbortController | null>(null);
const scanMutation = useMutation({ const scanMutation = useMutation({
mutationFn: () => scanDevice(device.id), mutationFn: (full: boolean) => {
const controller = new AbortController();
setScanAbort(controller);
return scanDevice(device.id, controller.signal, full);
},
onSuccess: (result) => { onSuccess: (result) => {
const portsText = result.ports.length > 0 ? result.ports.join(", ") : "keine";
const pending = result.nameChanges.length + result.staleServices.length;
setScanMessage( setScanMessage(
`${result.scannedPorts} Port(s) offen · ${result.created} neu · ${result.updated} aktualisiert` `Ports offen: ${portsText} · ${result.created} neu` +
(pending > 0 ? ` · ${pending} Änderung(en) warten auf Bestätigung` : "")
); );
setStaleServices(result.staleServices);
setNameChanges(result.nameChanges);
setDeviceNameSuggestion(result.deviceNameSuggestion);
queryClient.invalidateQueries({ queryKey: ["devices"] }); queryClient.invalidateQueries({ queryKey: ["devices"] });
queryClient.invalidateQueries({ queryKey: ["services"] }); queryClient.invalidateQueries({ queryKey: ["services"] });
// War bisher vergessen: der Scan selbst schreibt zwar schon einen
// Log-Eintrag (siehe performDeviceScan in routes/scan.ts, gilt für
// Einzelgerät- UND Sammel-Scan gleichermaßen), aber ohne diese
// Invalidierung blieb eine bereits geöffnete Admin -> Logs-Seite auf
// dem alten Stand, bis man sie manuell neu lädt (siehe Bugreport).
queryClient.invalidateQueries({ queryKey: ["logs"] });
}, },
onError: (err: Error) => setScanMessage(err.message), onError: (err: Error) => {
setScanMessage(err.name === "AbortError" ? "Scan abgebrochen." : err.message);
queryClient.invalidateQueries({ queryKey: ["logs"] });
},
onSettled: () => setScanAbort(null),
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
@@ -60,12 +362,29 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["devices"] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["devices"] }),
}); });
if (editing) {
return <EditDeviceForm device={device} onDone={() => setEditing(false)} />;
}
return ( return (
<>
<tr className="border-b border-black/5 last:border-0 dark:border-white/5"> <tr className="border-b border-black/5 last:border-0 dark:border-white/5">
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="font-medium text-black dark:text-white">{device.hostname}</div> <button
<div className="text-xs text-black/40 dark:text-white/40">{device.ip}</div> onClick={() => setExpanded((e) => !e)}
className="flex items-center gap-1.5 text-left"
title={expanded ? "Dienste ausblenden" : "Dienste anzeigen"}
>
<span className="text-black/30 dark:text-white/30">{expanded ? "" : ""}</span>
<span className="font-medium text-black dark:text-white">{device.hostname}</span>
</button>
</td> </td>
<td className="px-4 py-3 font-mono text-black/60 dark:text-white/60">{device.ip}</td>
<td className="px-4 py-3 font-mono text-xs text-black/40 dark:text-white/40">
{device.mac ?? ""}
</td>
<td className="px-4 py-3 text-black/60 dark:text-white/60">{device.manufacturer ?? ""}</td>
<td className="px-4 py-3 text-black/60 dark:text-white/60">{device.model ?? ""}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span <span
className={`inline-flex items-center gap-1.5 text-xs ${ className={`inline-flex items-center gap-1.5 text-xs ${
@@ -77,39 +396,109 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
/> />
{device.online ? "Online" : "Offline"} {device.online ? "Online" : "Offline"}
</span> </span>
<div className="text-[10px] text-black/30 dark:text-white/30">
{(() => {
// "Zuletzt gesehen" soll den neueren der beiden Zeitpunkte
// zeigen - ein echter Scan (lastScan) oder ein reiner
// Ping-Live-Status-Check (lastPing, siehe Einstellungen) -
// sonst wirkt es inkonsistent, wenn der Online-Punkt frisch
// vom Ping aktualisiert wurde, der Zeitstempel aber noch den
// alten Scan zeigt.
const scanTime = device.lastScan ? new Date(device.lastScan).getTime() : 0;
const pingTime = device.lastPing ? new Date(device.lastPing).getTime() : 0;
const latest = Math.max(scanTime, pingTime);
if (latest === 0) return "nie gescannt";
const label = pingTime > scanTime ? "zuletzt gesehen (Ping)" : "zuletzt gesehen";
return `${label}: ${new Date(latest).toLocaleString("de-DE")}`;
})()}
</div>
</td> </td>
<td className="px-4 py-3 text-black/60 dark:text-white/60">{device.services.length}</td> <td className="px-4 py-3 text-black/60 dark:text-white/60">{device.services.length}</td>
<td className="px-4 py-3 text-xs text-black/40 dark:text-white/40">
{device.lastScan ? new Date(device.lastScan).toLocaleString("de-DE") : "nie gescannt"}
</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
{scanMessage ? ( <Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"><FontAwesomeIcon icon={faPen} /></Button>
<span className="max-w-[16rem] truncate text-xs text-black/40 dark:text-white/40" title={scanMessage}>
{scanMessage}
</span>
) : null}
<Button <Button
size="sm" size="sm"
onClick={() => { onClick={() => {
setScanMessage(null); setScanMessage("Portscan läuft (alle 65535 Ports) ");
scanMutation.mutate(); scanMutation.mutate(true);
}} }}
disabled={scanMutation.isPending} disabled={scanMutation.isPending}
title="Prüft alle 65535 Ports (nicht nur die üblichen) - dauert dadurch ein paar Sekunden länger, findet dafür auch Dienste auf unüblichen Ports"
> >
{scanMutation.isPending ? "Scanne " : "Jetzt scannen"} {scanMutation.isPending ? "Scanne " : "Jetzt scannen"}
</Button> </Button>
{scanMutation.isPending && scanAbort ? (
<Button <Button
size="sm" size="icon"
variant="ghost"
onClick={() => scanAbort.abort()}
title="Scan abbrechen"
aria-label="Scan abbrechen"
>
<FontAwesomeIcon icon={faXmark} />
</Button>
) : null}
<Button
size="icon"
variant="danger" variant="danger"
onClick={() => deleteMutation.mutate()} onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
>
Löschen
</Button>
</div> </div>
</td> </td>
</tr> </tr>
{(scanMessage || staleServices.length > 0 || nameChanges.length > 0 || deviceNameSuggestion) && (
<tr className="border-b border-black/5 dark:border-white/5">
<td colSpan={8} className="px-4 pb-3">
{scanMessage ? (
<p className="text-xs text-black/40 dark:text-white/40">{scanMessage}</p>
) : null}
{deviceNameSuggestion ? (
<DeviceNameSuggestionBanner
deviceId={device.id}
suggestion={deviceNameSuggestion}
onDone={() => setDeviceNameSuggestion(null)}
/>
) : null}
{nameChanges.length > 0 ? (
<NameChangesReview nameChanges={nameChanges} onDone={() => setNameChanges([])} />
) : null}
{staleServices.length > 0 ? (
<StaleServicesReview staleServices={staleServices} onDone={() => setStaleServices([])} />
) : null}
</td>
</tr>
)}
{expanded && (
<tr className="border-b border-black/5 bg-black/[0.015] dark:border-white/5 dark:bg-white/[0.02]">
<td colSpan={8} className="px-4 py-3">
{device.services.length === 0 ? (
<p className="text-xs text-black/40 dark:text-white/40">Keine Dienste auf diesem Gerät.</p>
) : (
<div className="flex flex-wrap gap-2">
{device.services.map((s) => (
<a
key={s.id}
href={s.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-full border border-black/10 bg-white/60
px-2.5 py-1 text-xs text-black/70 hover:bg-black/5 dark:border-white/10
dark:bg-white/5 dark:text-white/70 dark:hover:bg-white/10"
>
<Favicon src={s.favicon} fallbackLetter={s.displayName} size="sm" />
{s.displayName}
<span className="text-black/30 dark:text-white/30">:{s.port}</span>
</a>
))}
</div>
)}
</td>
</tr>
)}
</>
); );
} }
@@ -134,7 +523,7 @@ function AddDeviceForm() {
} }
return ( return (
<form onSubmit={handleSubmit} className="flex items-end gap-2"> <form onSubmit={handleSubmit} className="flex flex-wrap items-end gap-2">
<div> <div>
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50"> <label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
Hostname Hostname
@@ -171,14 +560,89 @@ function AddDeviceForm() {
); );
} }
type SortColumn = "hostname" | "ip" | "mac" | "manufacturer" | "model" | "online" | "services" | 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 DevicesPage() { export function DevicesPage() {
const { data: devices, isLoading, isError } = useDevices(); const { data: devices, isLoading, isError } = useDevices();
const [sortColumn, setSortColumn] = useState<SortColumn>(null);
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const list = useMemo(() => {
const base = devices ?? [];
if (!sortColumn) return base;
const sorted = [...base].sort((a, b) => {
let cmp = 0;
switch (sortColumn) {
case "hostname":
cmp = a.hostname.localeCompare(b.hostname);
break;
case "ip":
cmp = a.ip.localeCompare(b.ip, undefined, { numeric: true });
break;
case "mac":
cmp = (a.mac ?? "").localeCompare(b.mac ?? "");
break;
case "manufacturer":
cmp = (a.manufacturer ?? "").localeCompare(b.manufacturer ?? "");
break;
case "model":
cmp = (a.model ?? "").localeCompare(b.model ?? "");
break;
case "online":
cmp = Number(a.online) - Number(b.online);
break;
case "services":
cmp = a.services.length - b.services.length;
break;
}
return sortDirection === "asc" ? cmp : -cmp;
});
return sorted;
}, [devices, sortColumn, sortDirection]);
function handleHeaderClick(column: SortColumn) {
if (sortColumn === column) {
setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortColumn(column);
setSortDirection("asc");
}
}
return ( return (
<div> <div>
<AdminPageHeader <AdminPageHeader
title="Geräte" title="Geräte"
description="Alle bekannten Geräte in deinem Netzwerk. Scans laufen nur auf Knopfdruck." description="Alle bekannten Geräte in deinem Netzwerk. Scans laufen nur auf Knopfdruck. Klick auf den Gerätenamen zeigt die zugehörigen Dienste."
/> />
<div className="mb-6"> <div className="mb-6">
@@ -189,26 +653,31 @@ export function DevicesPage() {
<p className="text-sm text-black/40 dark:text-white/40">Lade Geräte …</p> <p className="text-sm text-black/40 dark:text-white/40">Lade Geräte …</p>
) : isError ? ( ) : isError ? (
<p className="text-sm text-red-500">Geräte konnten nicht geladen werden.</p> <p className="text-sm text-red-500">Geräte konnten nicht geladen werden.</p>
) : devices && devices.length > 0 ? ( ) : list.length > 0 ? (
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10"> <div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
<table className="w-full text-sm"> <div className="overflow-x-auto">
<table className="w-full min-w-[980px] text-sm">
<thead> <thead>
<tr className="border-b border-black/10 bg-black/[0.02] text-left text-xs <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"> uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
<th className="px-4 py-2 font-medium">Gerät</th> <SortableHeader label="Gerät" column="hostname" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<th className="px-4 py-2 font-medium">Status</th> <SortableHeader label="IP" column="ip" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<th className="px-4 py-2 font-medium">Dienste</th> <SortableHeader label="MAC" column="mac" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<th className="px-4 py-2 font-medium">Letzter Scan</th> <SortableHeader label="Hersteller" column="manufacturer" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<SortableHeader label="Modell" column="model" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<SortableHeader label="Status" column="online" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<SortableHeader label="Dienste" column="services" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<th className="px-4 py-2" /> <th className="px-4 py-2" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{devices.map((device) => ( {list.map((device) => (
<DeviceRow key={device.id} device={device} /> <DeviceRow key={device.id} device={device} />
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
) : ( ) : (
<p className="text-sm text-black/40 dark:text-white/40"> <p className="text-sm text-black/40 dark:text-white/40">
Noch keine Geräte angelegt. Füge oben ein Gerät hinzu oder nutze den FritzBox-Scan Noch keine Geräte angelegt. Füge oben ein Gerät hinzu oder nutze den FritzBox-Scan

View File

@@ -17,7 +17,8 @@ export function LogsPage() {
<p className="text-sm text-red-500">Logs konnten nicht geladen werden.</p> <p className="text-sm text-red-500">Logs konnten nicht geladen werden.</p>
) : logs && logs.length > 0 ? ( ) : logs && logs.length > 0 ? (
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10"> <div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
<table className="w-full text-sm"> <div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-sm">
<thead> <thead>
<tr className="border-b border-black/10 bg-black/[0.02] text-left text-xs <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"> uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
@@ -32,7 +33,7 @@ export function LogsPage() {
<td className="whitespace-nowrap px-4 py-3 text-xs text-black/40 dark:text-white/40"> <td className="whitespace-nowrap px-4 py-3 text-xs text-black/40 dark:text-white/40">
{new Date(log.createdAt).toLocaleString("de-DE")} {new Date(log.createdAt).toLocaleString("de-DE")}
</td> </td>
<td className="px-4 py-3"> <td className="whitespace-nowrap px-4 py-3">
<span <span
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium ${ className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium ${
log.level === "error" log.level === "error"
@@ -49,6 +50,7 @@ export function LogsPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
) : ( ) : (
<p className="text-sm text-black/40 dark:text-white/40"> <p className="text-sm text-black/40 dark:text-white/40">
Noch keine Scans durchgeführt. Starte einen Scan unter Geräte" oder „Scanner". Noch keine Scans durchgeführt. Starte einen Scan unter Geräte" oder „Scanner".

View File

@@ -1,18 +1,109 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@launchpad/ui";
import type { PluginInfo } from "@launchpad/shared";
import { usePlugins } from "../../hooks/usePlugins.js";
import { AdminPageHeader } from "./AdminPageHeader.js"; import { AdminPageHeader } from "./AdminPageHeader.js";
export function PluginsPage() { const CAPABILITY_LABELS: Record<string, string> = {
scanner: "Erweitert die Softwareerkennung",
"device-import": "Kann Geräte importieren",
};
async function importFromPlugin(name: string) {
const res = await fetch(`/api/plugins/${name}/import`, { method: "POST" });
const body = await res.json();
if (!res.ok) {
throw new Error(body.detail ?? body.error ?? `Import fehlgeschlagen (HTTP ${res.status})`);
}
return body as { imported: number };
}
function PluginCard({ plugin }: { plugin: PluginInfo }) {
const queryClient = useQueryClient();
const [message, setMessage] = useState<string | null>(null);
const importMutation = useMutation({
mutationFn: () => importFromPlugin(plugin.name),
onSuccess: (result) => {
setMessage(`${result.imported} Gerät(e) importiert.`);
queryClient.invalidateQueries({ queryKey: ["devices"] });
queryClient.invalidateQueries({ queryKey: ["logs"] });
},
onError: (err: Error) => setMessage(err.message),
});
return ( return (
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<div className="flex items-start justify-between gap-3">
<div> <div>
<AdminPageHeader title="Plugins" /> <h2 className="font-medium text-black dark:text-white">
<div className="rounded-2xl border border-dashed border-black/15 p-8 text-center dark:border-white/15"> {plugin.name} <span className="text-black/40 dark:text-white/40">v{plugin.version}</span>
<p className="text-black/60 dark:text-white/60"> </h2>
Das Plugin-System (Scanner registrieren, Geräte importieren, Menüs erweitern, {plugin.description ? (
Icons bereitstellen) ist noch nicht gebaut. <p className="mt-1 text-sm text-black/50 dark:text-white/50">{plugin.description}</p>
</p> ) : null}
<p className="mt-2 text-sm text-black/40 dark:text-white/40">
Geplant als eigener Commit siehe <code className="rounded bg-black/5 px-1 dark:bg-white/10">docs/ROADMAP.md</code>.
</p>
</div> </div>
</div> </div>
<div className="mt-3 flex flex-wrap gap-2">
{plugin.capabilities.length === 0 ? (
<span className="text-xs text-black/30 dark:text-white/30">Keine Capabilities</span>
) : (
plugin.capabilities.map((cap) => (
<span
key={cap}
className="rounded-full bg-black/5 px-2.5 py-1 text-xs text-black/60 dark:bg-white/10 dark:text-white/60"
>
{CAPABILITY_LABELS[cap] ?? cap}
</span>
))
)}
</div>
{plugin.capabilities.includes("device-import") ? (
<div className="mt-4">
<Button size="sm" variant="primary" onClick={() => importMutation.mutate()} disabled={importMutation.isPending}>
{importMutation.isPending ? "Importiere …" : "Jetzt importieren"}
</Button>
{message ? (
<p className="mt-2 text-xs text-black/50 dark:text-white/50">{message}</p>
) : null}
</div>
) : null}
</div>
);
}
export function PluginsPage() {
const { data: plugins, isLoading, isError } = usePlugins();
return (
<div>
<AdminPageHeader
title="Plugins"
description="Plugins liegen als Ordner unter apps/backend/plugins/ und werden beim Start des Backends geladen kein Rebuild nötig, nur ein Container-Neustart."
/>
{isLoading ? (
<p className="text-sm text-black/40 dark:text-white/40">Lade Plugins </p>
) : isError ? (
<p className="text-sm text-red-500">Plugins konnten nicht geladen werden.</p>
) : plugins && plugins.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{plugins.map((plugin) => (
<PluginCard key={plugin.name} plugin={plugin} />
))}
</div>
) : (
<div className="rounded-2xl border border-dashed border-black/15 p-8 text-center dark:border-white/15">
<p className="text-black/60 dark:text-white/60">Keine Plugins geladen.</p>
<p className="mt-2 text-sm text-black/40 dark:text-white/40">
Siehe <code className="rounded bg-black/5 px-1 dark:bg-white/10">apps/backend/plugins/README.md</code>{" "}
für eine Anleitung, wie du ein eigenes Plugin schreibst.
</p>
</div>
)}
</div>
); );
} }

View File

@@ -0,0 +1,208 @@
import { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBookmark, faPen, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Favicon } from "@launchpad/ui";
import { useReadLater, type ReadLaterItem } from "../../hooks/useReadLater.js";
import { AdminPageHeader } from "./AdminPageHeader.js";
async function patchReadLaterItem(id: string, patch: { url?: string; displayName?: string }) {
const res = await fetch(`/api/read-later/${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 ?? `Eintrag konnte nicht aktualisiert werden (HTTP ${res.status})`);
}
return res.json();
}
async function deleteReadLaterItem(id: string) {
const res = await fetch(`/api/read-later/${id}`, { method: "DELETE" });
if (!res.ok && res.status !== 404) {
throw new Error(`Eintrag konnte nicht gelöscht werden (HTTP ${res.status})`);
}
}
async function promoteReadLaterItem(id: string) {
const res = await fetch(`/api/read-later/${id}/promote`, { method: "POST" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `Konnte nicht zu Lesezeichen gemacht werden (HTTP ${res.status})`);
}
return res.json();
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" });
} catch {
return iso;
}
}
function EditRow({ item, onDone }: { item: ReadLaterItem; onDone: () => void }) {
const queryClient = useQueryClient();
const [displayName, setDisplayName] = useState(item.displayName);
const [url, setUrl] = useState(item.url);
const mutation = useMutation({
mutationFn: () => patchReadLaterItem(item.id, { displayName: displayName.trim(), url: url.trim() }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["read-later"] });
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={4} className="px-4 py-3">
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
dark:border-white/10 dark:bg-white/10 dark:text-white"
/>
</div>
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">URL</label>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
className="w-80 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 gap-2">
<Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen"><FontAwesomeIcon icon={faXmark} /></Button>
<Button
size="icon"
variant="primary"
onClick={() => mutation.mutate()}
disabled={mutation.isPending || !displayName.trim() || !url.trim()}
><FontAwesomeIcon icon={faXmark} /></Button>
</div>
</div>
{mutation.isError ? (
<p className="mt-2 text-xs text-red-500">{(mutation.error as Error).message}</p>
) : null}
</td>
</tr>
);
}
function ReadLaterRow({ item }: { item: ReadLaterItem }) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const promoteMutation = useMutation({
mutationFn: () => promoteReadLaterItem(item.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["read-later"] });
queryClient.invalidateQueries({ queryKey: ["bookmarks"] });
},
});
const deleteMutation = useMutation({
mutationFn: () => deleteReadLaterItem(item.id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["read-later"] }),
});
if (editing) {
return <EditRow item={item} onDone={() => setEditing(false)} />;
}
return (
<tr className="border-b border-black/5 last:border-0 hover:bg-black/[0.02] dark:border-white/5 dark:hover:bg-white/5">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="sm" />
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-black hover:underline dark:text-white"
>
{item.displayName}
</a>
</div>
</td>
<td className="max-w-xs truncate px-4 py-3 font-mono text-xs text-black/60 dark:text-white/60">
{item.url}
</td>
<td className="px-4 py-3 text-xs text-black/50 dark:text-white/50">{formatDate(item.savedAt)}</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1">
<Button size="icon" variant="ghost" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"><FontAwesomeIcon icon={faPen} /></Button>
<Button
size="icon"
variant="secondary"
onClick={() => promoteMutation.mutate()}
disabled={promoteMutation.isPending}
title="Zu Lesezeichen (Favorit) machen und aus dieser Liste entfernen"
aria-label="Zu Lesezeichen machen"
>
<FontAwesomeIcon icon={faBookmark} />
</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>
);
}
export function ReadLaterPage() {
const { data: items, isLoading, isError } = useReadLater();
return (
<div>
<AdminPageHeader
title="Später lesen"
description='Von der Startseite gespeicherte Links. "Zu Lesezeichen" übernimmt einen Eintrag dauerhaft als Favorit und entfernt ihn hier. Wie viele Einträge auf der Startseite angezeigt werden, lässt sich unter Einstellungen konfigurieren.'
/>
{isLoading ? (
<p className="text-sm text-black/40 dark:text-white/40">Lade </p>
) : isError ? (
<p className="text-sm text-red-500">Liste konnte nicht geladen werden.</p>
) : items && items.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-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">URL</th>
<th className="px-4 py-2 font-medium">Gespeichert</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody>
{items.map((item) => (
<ReadLaterRow key={item.id} item={item} />
))}
</tbody>
</table>
</div>
</div>
) : (
<p className="text-sm text-black/40 dark:text-white/40">
Noch nichts gespeichert. Über die Startseite lassen sich Links per Merken" ablegen.
</p>
)}
</div>
);
}

View File

@@ -1,64 +1,712 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBan, faCheck, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
import { Button } from "@launchpad/ui"; import { Button } from "@launchpad/ui";
import type { Device, Service } from "@launchpad/shared";
import { useDevices } from "../../hooks/useDevices.js"; import { useDevices } from "../../hooks/useDevices.js";
import type { DetectedApiEntry } from "../../hooks/useDetectedApis.js";
import { usePersistedState } from "../../hooks/usePersistedState.js";
import { AdminPageHeader } from "./AdminPageHeader.js"; import { AdminPageHeader } from "./AdminPageHeader.js";
async function scanFritzBox() { interface DeviceChange {
const res = await fetch("/api/scan/fritzbox", { method: "POST" }); deviceId: string;
const body = await res.json(); deviceHostname: string;
if (!res.ok) { field: "hostname" | "ip";
throw new Error(body.error ?? `FritzBox-Scan fehlgeschlagen (HTTP ${res.status})`); current: string;
} suggested: string;
return body as { found: number };
} }
async function scanDeviceById(id: string) { async function patchDevice(id: string, patch: Record<string, unknown>) {
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST" }); const res = await fetch(`/api/devices/${id}`, {
const body = await res.json(); method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
if (!res.ok) { if (!res.ok) {
throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`); throw new Error(`Gerät konnte nicht aktualisiert werden (HTTP ${res.status})`);
} }
return body as { created: number; updated: number }; return res.json();
}
async function deleteDeviceRequest(id: string) {
const res = await fetch(`/api/devices/${id}`, { method: "DELETE" });
if (!res.ok && res.status !== 404) {
throw new Error(`Gerät konnte nicht gelöscht werden (HTTP ${res.status})`);
}
}
async function deleteServiceRequest(id: string) {
const res = await fetch(`/api/services/${id}`, { method: "DELETE" });
if (!res.ok && res.status !== 404) {
throw new Error(`Dienst konnte nicht gelöscht werden (HTTP ${res.status})`);
}
}
interface ScanNameChange {
serviceId: string;
serviceHostname: string;
servicePort: number;
field: "displayName" | "category";
current: string | null;
suggested: string;
/** true = weicht nur ab, weil der Wert zuvor manuell bearbeitet wurde (siehe unten). */
manualOverride: boolean;
}
async function patchService(id: string, patch: Record<string, unknown>) {
const res = await fetch(`/api/services/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
if (!res.ok) {
throw new Error(`Dienst konnte nicht aktualisiert werden (HTTP ${res.status})`);
}
return res.json();
}
function nameChangeKey(c: ScanNameChange): string {
return `${c.serviceId}:${c.field}:${c.suggested}`;
}
function deviceChangeKey(c: { deviceId: string; field: string; suggested: string }): string {
return `${c.deviceId}:${c.field}:${c.suggested}`;
}
/**
* Meldet eine Bestätigung/Ablehnung eines Scan-Vorschlags SERVERSEITIG
* (siehe routes/scan.ts POST /api/scan/dismiss), damit sie für ALLE
* Browser/Geräte gilt, nicht nur lokal im aufrufenden Browser
* (sessionStorage) - siehe Bugreport.
*/
async function dismissSuggestion(kind: string, key: string): Promise<void> {
try {
await fetch("/api/scan/dismiss", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind, key }),
});
} catch {
// Netzwerk-Hänger hier soll die Bedienung nicht blockieren - die lokale
// Ausblendung (siehe Aufrufer) greift trotzdem sofort; bleibt der
// Dismiss serverseitig aus, taucht der Vorschlag beim nächsten Scan
// erneut auf, ist also kein Datenverlust, nur ein einmal erneutes
// Bestätigen nötig.
}
}
/**
* Merkt sich (dauerhaft über sessionStorage, NICHT über React-State), welches
* Backend-Scan-Ergebnis (identifiziert über seinen startedAt-Zeitstempel)
* bereits in die lokale Anzeige übernommen wurde. Bewusst kein
* usePersistedState/useState hier: der Poll-Callback läuft in einem
* setInterval, das nur einmal erzeugt wird (leeres deps-Array) - ein
* React-State-Wert wäre in diesem Callback für immer auf seinem Anfangswert
* eingefroren (stale closure). Gleichzeitig darf der Wert NICHT nur im
* Speicher (useRef) leben, sonst würde ein Neuladen der Seite ihn auf null
* zurücksetzen und dieselbe Meldung (inkl. bereits bestätigter Einträge)
* einfach erneut aus dem immer noch unveränderten Backend-Ergebnis
* importieren - genau das gemeldete Problem.
*/
function getHandledResultId(key: string): string | null {
try {
return sessionStorage.getItem(`launchpad:${key}`);
} catch {
return null;
}
}
function setHandledResultId(key: string, value: string): void {
try {
sessionStorage.setItem(`launchpad:${key}`, value);
} catch {
// Speicher evtl. nicht verfügbar (Privatmodus o. ä.) - dann greift die
// Deduplizierung für die aktuelle Sitzung eben nicht, kein Absturz nötig.
}
}
/**
* Übernimmt einen Namens-/Kategorie-Vorschlag des Scanners. Setzt dabei
* explizit die *EditedManually-Flag auf false zurück, damit der Dienst
* danach wieder als "vom Scan" statt "manuell angepasst" gilt.
*/
function acceptNameChange(c: ScanNameChange) {
const resetFlag = c.field === "displayName" ? "displayNameEditedManually" : "categoryEditedManually";
return patchService(c.serviceId, { [c.field]: c.suggested, [resetFlag]: false });
}
function NameChangesReview({
nameChanges,
onResolve,
}: {
nameChanges: ScanNameChange[];
onResolve: (key: string) => void;
}) {
const queryClient = useQueryClient();
const applyMutation = useMutation({
mutationFn: acceptNameChange,
onSuccess: (_data, change) => {
onResolve(nameChangeKey(change));
queryClient.invalidateQueries({ queryKey: ["services"] });
},
});
if (nameChanges.length === 0) return null;
const fieldLabel = (field: ScanNameChange["field"]) => (field === "displayName" ? "Name" : "Kategorie");
return (
<div className="mt-3 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="font-medium text-blue-700 dark:text-blue-400">
{nameChanges.length} erkannte Änderung(en) weichen vom gespeicherten Wert ab:
</p>
<button
onClick={() => nameChanges.forEach((c) => onResolve(nameChangeKey(c)))}
className="shrink-0 text-black/40 underline hover:text-black/60 dark:text-white/40 dark:hover:text-white/60"
>
Alle bestätigen
</button>
</div>
<ul className="max-h-60 space-y-1 overflow-y-auto">
{nameChanges.map((c) => (
<li key={nameChangeKey(c)} className="flex items-center justify-between gap-2">
<span className="text-black/70 dark:text-white/70">
{c.serviceHostname}:{c.servicePort} {fieldLabel(c.field)}: {c.current ?? ""}" → „
{c.suggested}"
</span>
<div className="flex gap-1">
<Button size="icon" variant="ghost" onClick={() => onResolve(nameChangeKey(c))} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
<Button
size="icon"
variant="primary"
onClick={() => applyMutation.mutate(c)}
disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"><FontAwesomeIcon icon={faCheck} /></Button>
</div>
</li>
))}
</ul>
</div>
);
}
function NewServicesList({
newServices,
onResolve,
}: {
newServices: Service[];
onResolve: (id: string) => void;
}) {
if (newServices.length === 0) return null;
return (
<div className="mt-3 rounded-xl border border-emerald-500/30 bg-emerald-500/5 p-3 text-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="font-medium text-emerald-700 dark:text-emerald-400">
{newServices.length} neue(r) Dienst(e) gefunden:
</p>
<button
onClick={() => newServices.forEach((s) => onResolve(s.id))}
className="shrink-0 text-black/40 underline hover:text-black/60 dark:text-white/40 dark:hover:text-white/60"
>
Alle bestätigen
</button>
</div>
<ul className="max-h-60 space-y-1 overflow-y-auto">
{newServices.map((s) => (
<li key={s.id} className="flex items-center justify-between gap-2">
<span className="text-black/70 dark:text-white/70">
{s.displayName} ({s.hostname}:{s.port})
</span>
<Button
size="icon"
variant="ghost"
onClick={() => onResolve(s.id)}
title="Bestätigen (aus dieser Liste entfernen)"
aria-label="Bestätigen"
>
<FontAwesomeIcon icon={faCheck} />
</Button>
</li>
))}
</ul>
</div>
);
}
function StaleServicesReview({
staleServices,
onResolve,
}: {
staleServices: Service[];
onResolve: (id: string) => void;
}) {
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteServiceRequest(id),
onSuccess: (_data, id) => {
onResolve(id);
queryClient.invalidateQueries({ queryKey: ["services"] });
},
});
if (staleServices.length === 0) return null;
return (
<div className="mt-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="font-medium text-amber-700 dark:text-amber-400">
{staleServices.length} Dienst(e) über alle gescannten Geräte hinweg nicht mehr gefunden (Port
nicht mehr offen):
</p>
<button
onClick={() => staleServices.forEach((s) => onResolve(s.id))}
className="shrink-0 text-black/40 underline hover:text-black/60 dark:text-white/40 dark:hover:text-white/60"
>
Alle bestätigen
</button>
</div>
<ul className="max-h-60 space-y-1 overflow-y-auto">
{staleServices.map((s) => (
<li key={s.id} className="flex items-center justify-between gap-2">
<span className="text-black/70 dark:text-white/70">
{s.displayName} ({s.hostname}:{s.port})
</span>
<div className="flex gap-1">
<Button size="icon" variant="ghost" onClick={() => onResolve(s.id)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
<Button
size="icon"
variant="danger"
onClick={() => deleteMutation.mutate(s.id)}
disabled={deleteMutation.isPending} title="Entfernen" aria-label="Entfernen"><FontAwesomeIcon icon={faTrash} /></Button>
</div>
</li>
))}
</ul>
</div>
);
}
function DeviceChangesReview({
deviceChanges,
onResolve,
}: {
deviceChanges: DeviceChange[];
onResolve: (key: string) => void;
}) {
const queryClient = useQueryClient();
const applyMutation = useMutation({
mutationFn: (change: DeviceChange) => patchDevice(change.deviceId, { [change.field]: change.suggested }),
onSuccess: (_data, change) => {
onResolve(deviceChangeKey(change));
queryClient.invalidateQueries({ queryKey: ["devices"] });
},
});
if (deviceChanges.length === 0) return null;
const fieldLabel = (field: DeviceChange["field"]) => (field === "hostname" ? "Name" : "IP");
return (
<div className="mt-3 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="font-medium text-blue-700 dark:text-blue-400">
{deviceChanges.length} Geräte-Änderung(en) weichen von den gespeicherten Werten ab:
</p>
<button
onClick={() => deviceChanges.forEach((c) => onResolve(deviceChangeKey(c)))}
className="shrink-0 text-black/40 underline hover:text-black/60 dark:text-white/40 dark:hover:text-white/60"
>
Alle bestätigen
</button>
</div>
<ul className="max-h-60 space-y-1 overflow-y-auto">
{deviceChanges.map((c) => (
<li key={deviceChangeKey(c)} className="flex items-center justify-between gap-2">
<span className="text-black/70 dark:text-white/70">
{c.deviceHostname} {fieldLabel(c.field)}: {c.current}" → „{c.suggested}"
</span>
<div className="flex gap-1">
<Button size="icon" variant="ghost" onClick={() => onResolve(deviceChangeKey(c))} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
<Button
size="icon"
variant="primary"
onClick={() => applyMutation.mutate(c)}
disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"><FontAwesomeIcon icon={faCheck} /></Button>
</div>
</li>
))}
</ul>
</div>
);
}
function StaleDevicesReview({
staleDevices,
onResolve,
}: {
staleDevices: Device[];
onResolve: (id: string) => void;
}) {
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteDeviceRequest(id),
onSuccess: (_data, id) => {
onResolve(id);
queryClient.invalidateQueries({ queryKey: ["devices"] });
},
});
if (staleDevices.length === 0) return null;
return (
<div className="mt-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="font-medium text-amber-700 dark:text-amber-400">
{staleDevices.length} Gerät(e), die die FritzBox früher gemeldet hatte, diesmal aber nicht
mehr:
</p>
<button
onClick={() => staleDevices.forEach((d) => onResolve(d.id))}
className="shrink-0 text-black/40 underline hover:text-black/60 dark:text-white/40 dark:hover:text-white/60"
>
Alle bestätigen
</button>
</div>
<ul className="space-y-1">
{staleDevices.map((d) => (
<li key={d.id} className="flex items-center justify-between gap-2">
<span className="text-black/70 dark:text-white/70">
{d.hostname} ({d.ip})
</span>
<div className="flex gap-1">
<Button size="icon" variant="ghost" onClick={() => onResolve(d.id)} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)"><FontAwesomeIcon icon={faBan} /></Button>
<Button
size="icon"
variant="danger"
onClick={() => deleteMutation.mutate(d.id)}
disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
</div>
</li>
))}
</ul>
</div>
);
}
function ApiScannerCard() {
const queryClient = useQueryClient();
const [status, setStatus] = usePersistedState<string | null>("scanner:apiStatus", null);
const [running, setRunning] = useState(false);
const [lastNewFindings, setLastNewFindings] = usePersistedState<
{ serviceId: string; serviceName: string; apis: DetectedApiEntry[] }[]
>("scanner:apiNewFindings", []);
useEffect(() => {
let cancelled = false;
async function poll() {
try {
const res = await fetch("/api/scan/apis/status");
if (!res.ok || cancelled) return;
const s = await res.json();
setRunning(s.running);
if (s.running && s.progress) {
setStatus(
`Prüfe ${s.progress.currentLabel ?? "…"} (${s.progress.current + 1} von ${s.progress.total})`
);
} else if (s.lastResult && s.startedAt !== getHandledResultId("scanner:apiLastHandledAt")) {
setHandledResultId("scanner:apiLastHandledAt", s.startedAt);
const r = s.lastResult;
const newCount = r.newFindings.reduce(
(sum: number, f: { apis: unknown[] }) => sum + f.apis.length,
0
);
setStatus(
`Fertig: ${r.checked} Dienst(e) geprüft, ${r.totalFound} API-Endpunkt(e) insgesamt bei ${r.servicesWithApi} Dienst(en)` +
(newCount > 0
? `, davon ${newCount} neu/geändert seit dem letzten Scan.`
: ", keine Änderungen seit dem letzten Scan.")
);
setLastNewFindings(r.newFindings);
queryClient.invalidateQueries({ queryKey: ["detected-apis"] });
} else if (s.lastError) {
setStatus(`Fehlgeschlagen: ${s.lastError}`);
}
} catch {
// Netzwerk-Hänger beim Poll soll nicht die Seite stören.
}
}
poll();
const interval = setInterval(poll, 1500);
return () => {
cancelled = true;
clearInterval(interval);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function startScan() {
setStatus("Scan gestartet …");
const res = await fetch("/api/scan/apis", { method: "POST" });
if (res.status === 409) {
setStatus("Es läuft bereits ein API-Scan (evtl. von einem anderen Gerät gestartet).");
}
setRunning(true);
}
return (
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10 sm:col-span-2">
<h2 className="font-medium text-black dark:text-white">API-Scanner</h2>
<p className="mt-1 text-sm text-black/50 dark:text-white/50">
Eigener, separater Scan: prüft alle bekannten Dienste auf gängige API-Pfade
(OpenAPI/Swagger, GraphQL, REST und viele konkrete Selfhosted-Programme) und listet
neue/geänderte Funde hier auf. Läuft im Hintergrund (auch bei vielen Diensten kein
Zeitüberschreitungsfehler mehr) und nie zusammen mit dem normalen Geräte-Scan. Die
vollständige, dauerhafte Liste steht immer unter{" "}
<Link to="/admin/apis" className="underline">
Admin APIs
</Link>
.
</p>
<Button variant="primary" className="mt-4" onClick={startScan} disabled={running}>
{running ? "Scanne …" : "APIs jetzt scannen"}
</Button>
{status ? <p className="mt-2 text-sm text-black/50 dark:text-white/50">{status}</p> : null}
{lastNewFindings.length > 0 ? (
<>
<div className="mt-4 flex items-center justify-end">
<button
onClick={() => setLastNewFindings([])}
className="text-xs text-black/40 underline hover:text-black/60 dark:text-white/40 dark:hover:text-white/60"
>
Alle bestätigen
</button>
</div>
<ul className="max-h-72 space-y-2 overflow-y-auto text-sm">
{lastNewFindings.map(({ serviceId, serviceName, apis: entries }) => (
<li key={serviceId} className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-2">
<div className="flex items-center justify-between gap-2">
<div className="font-medium text-black dark:text-white">{serviceName}</div>
<Button
size="icon"
variant="ghost"
onClick={() =>
setLastNewFindings((prev) => prev.filter((f) => f.serviceId !== serviceId))
}
title="Bestätigen (aus dieser Liste entfernen)"
aria-label="Bestätigen"
>
<FontAwesomeIcon icon={faCheck} />
</Button>
</div>
<ul className="mt-1 flex flex-wrap gap-2">
{entries.map((e) => (
<li key={e.id}>
<span className="rounded-full bg-black/5 px-2 py-0.5 text-xs text-black/60 dark:bg-white/10 dark:text-white/60">
{e.type} · {e.path}
</span>
</li>
))}
</ul>
</li>
))}
</ul>
</>
) : null}
</div>
);
} }
export function ScannerPage() { export function ScannerPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: devices } = useDevices(); const { data: devices } = useDevices();
const [bulkStatus, setBulkStatus] = useState<string | null>(null);
const [bulkRunning, setBulkRunning] = useState(false);
const fritzboxMutation = useMutation({ const [bulkStatus, setBulkStatus] = usePersistedState<string | null>("scanner:bulkStatus", null);
mutationFn: scanFritzBox, const [bulkRunning, setBulkRunning] = useState(false);
onSuccess: (result) => { const [bulkStaleServices, setBulkStaleServices] = usePersistedState<Service[]>(
"scanner:bulkStaleServices",
[]
);
const [bulkNewServices, setBulkNewServices] = usePersistedState<Service[]>(
"scanner:bulkNewServices",
[]
);
const [bulkNameChanges, setBulkNameChanges] = usePersistedState<ScanNameChange[]>(
"scanner:bulkNameChanges",
[]
);
const [staleDevices, setStaleDevices] = usePersistedState<Device[]>("scanner:staleDevices", []);
const [deviceChanges, setDeviceChanges] = usePersistedState<DeviceChange[]>(
"scanner:deviceChanges",
[]
);
const [fritzboxSummary, setFritzboxSummary] = usePersistedState<
{ found: number; uniqueDevices: number; changesSinceLastScan?: string } | null
>("scanner:fritzboxSummary", null);
const [fritzboxError, setFritzboxError] = usePersistedState<string | null>(
"scanner:fritzboxError",
null
);
const [fritzboxRunning, setFritzboxRunning] = useState(false);
useEffect(() => {
let cancelled = false;
async function poll() {
try {
const res = await fetch("/api/scan/fritzbox/status");
if (!res.ok || cancelled) return;
const s = await res.json();
setFritzboxRunning(s.running);
if (!s.running && s.lastResult && s.startedAt !== getHandledResultId("scanner:fritzboxLastHandledAt")) {
setHandledResultId("scanner:fritzboxLastHandledAt", s.startedAt);
setFritzboxSummary({
found: s.lastResult.found,
uniqueDevices: s.lastResult.uniqueDevices,
changesSinceLastScan: s.lastResult.changesSinceLastScan,
});
setFritzboxError(null);
setStaleDevices(s.lastResult.staleDevices);
setDeviceChanges(s.lastResult.deviceChanges);
queryClient.invalidateQueries({ queryKey: ["devices"] }); queryClient.invalidateQueries({ queryKey: ["devices"] });
queryClient.invalidateQueries({ queryKey: ["logs"] }); queryClient.invalidateQueries({ queryKey: ["logs"] });
return result; } else if (!s.running && s.lastError && s.startedAt !== getHandledResultId("scanner:fritzboxLastHandledAt")) {
}, setHandledResultId("scanner:fritzboxLastHandledAt", s.startedAt);
}); setFritzboxError(s.lastError);
}
async function scanAllDevices() {
if (!devices || devices.length === 0) return;
setBulkRunning(true);
let created = 0;
let updated = 0;
for (const device of devices) {
try {
const result = await scanDeviceById(device.id);
created += result.created;
updated += result.updated;
setBulkStatus(`Scanne ${device.hostname} … (${created} neu, ${updated} aktualisiert bisher)`);
} catch { } catch {
// einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen // Netzwerk-Hänger beim Poll soll nicht die Seite stören.
} }
} }
setBulkStatus(`Fertig: ${devices.length} Gerät(e) gescannt, ${created} neue Dienste, ${updated} aktualisiert.`); poll();
setBulkRunning(false); const interval = setInterval(poll, 1500);
return () => {
cancelled = true;
clearInterval(interval);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function startFritzboxScan() {
setFritzboxError(null);
const res = await fetch("/api/scan/fritzbox", { method: "POST" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
setFritzboxError(body.error ?? `Fehlgeschlagen (HTTP ${res.status})`);
return;
}
setFritzboxRunning(true);
}
// Geräteübergreifender Sammel-Scan: läuft als Hintergrund-Job im Backend
// (siehe apps/backend/src/scanJobs.ts), nicht mehr als Schleife aus
// Einzelaufrufen im Browser. Dieser Poll-Mechanismus läuft IMMER, solange
// die Seite offen ist - egal ob DIESER Browser den Scan gestartet hat oder
// ein anderes Gerät: der Fortschritt/das letzte Ergebnis kommt direkt vom
// Backend, ist also für jeden sichtbar, der die Seite gerade offen hat.
useEffect(() => {
let cancelled = false;
async function poll() {
try {
const res = await fetch("/api/scan/devices/all/status");
if (!res.ok || cancelled) return;
const status = await res.json();
setBulkRunning(status.running);
if (status.running && status.progress) {
// Während der masscan-/httpx-Vorabphase (siehe routes/scan.ts) wird
// current bewusst auf 0 gehalten, während currentLabel den
// Phasen-Text trägt ("masscan läuft…" / "httpx läuft…") - ohne
// diese Ausnahme hätte das bisher IMMER "(1 von N)" angehängt,
// auch während dieser Phasen, in denen noch gar kein einzelnes
// Gerät bearbeitet wird. Das sah aus wie ein hängender Scan
// (siehe Bugreport), obwohl im Hintergrund gearbeitet wurde.
setBulkStatus(
status.progress.current === 0
? `Scanne ${status.progress.currentLabel ?? "…"}`
: `Scanne ${status.progress.currentLabel ?? "…"} (${status.progress.current + 1} von ${status.progress.total})`
);
} else if (status.lastResult && status.startedAt !== getHandledResultId("scanner:bulkLastHandledAt")) {
setHandledResultId("scanner:bulkLastHandledAt", status.startedAt);
const r = status.lastResult;
const pending = r.nameChanges.length + r.staleServices.length;
setBulkStatus(
(r.cancelled
? `Abgebrochen nach ${r.scannedCount} von ${r.totalDevices} Gerät(en). `
: `Fertig: ${r.totalDevices} Gerät(e) gescannt. `) +
`${r.created} neue(r) Dienst(e) gefunden.` +
(pending > 0
? ` ${pending} Änderung(en) warten unten auf deine Bestätigung.`
: " Keine Änderungen an bestehenden Diensten vorgeschlagen.") +
(r.changesSinceLastScan ? ` ${r.changesSinceLastScan}` : "")
);
setBulkStaleServices(r.staleServices);
setBulkNameChanges(r.nameChanges);
setBulkNewServices(r.newServices);
queryClient.invalidateQueries({ queryKey: ["devices"] }); queryClient.invalidateQueries({ queryKey: ["devices"] });
queryClient.invalidateQueries({ queryKey: ["services"] }); queryClient.invalidateQueries({ queryKey: ["services"] });
queryClient.invalidateQueries({ queryKey: ["logs"] }); queryClient.invalidateQueries({ queryKey: ["logs"] });
} else if (
status.lastError &&
status.startedAt !== getHandledResultId("scanner:bulkLastHandledAt")
) {
// WICHTIG: Bisher wurde status.lastError hier NIE geprüft - schlug
// der Sammel-Scan fehl (z. B. eine Ausnahme im Hintergrund-Job),
// blieb der zuletzt gesetzte Text ("Scan gestartet…"/"Scanne
// masscan läuft…") einfach stehen, obwohl der Scan längst beendet
// war (running wird false, das Badge in der Seitenleiste
// verschwindet) - der Fehler selbst blieb dabei komplett
// unsichtbar. Analog zum API- und FritzBox-Scanner jetzt auch hier
// angezeigt.
setHandledResultId("scanner:bulkLastHandledAt", status.startedAt);
setBulkStatus(`Fehlgeschlagen: ${status.lastError}`);
}
} catch {
// Netzwerk-Hänger beim Poll soll nicht die Seite stören - nächster
// Versuch folgt automatisch.
}
}
poll();
const interval = setInterval(poll, 1500);
return () => {
cancelled = true;
clearInterval(interval);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function scanAllDevices() {
if (!devices || devices.length === 0) return;
const res = await fetch("/api/scan/devices/all", { method: "POST" });
if (res.status === 409) {
setBulkStatus("Es läuft bereits ein Scan (evtl. von einem anderen Gerät gestartet).");
setBulkRunning(true);
return;
}
setBulkRunning(true);
setBulkStatus("Scan gestartet …");
}
async function cancelBulkScan() {
await fetch("/api/scan/devices/all/cancel", { method: "POST" });
} }
return ( return (
@@ -81,40 +729,106 @@ export function ScannerPage() {
<Button <Button
variant="primary" variant="primary"
className="mt-4" className="mt-4"
onClick={() => fritzboxMutation.mutate()} onClick={startFritzboxScan}
disabled={fritzboxMutation.isPending} disabled={fritzboxRunning}
> >
{fritzboxMutation.isPending ? "Scanne …" : "FritzBox jetzt scannen"} {fritzboxRunning ? "Scanne …" : "FritzBox jetzt scannen"}
</Button> </Button>
{fritzboxMutation.isSuccess ? ( {fritzboxSummary ? (
<p className="mt-2 text-sm text-emerald-600 dark:text-emerald-400"> <div className="mt-2 text-sm">
{fritzboxMutation.data.found} Gerät(e) gefunden. {fritzboxSummary.changesSinceLastScan ? (
<p
className={
fritzboxSummary.changesSinceLastScan.startsWith("Keine Änderungen")
? "text-black/50 dark:text-white/50"
: "font-medium text-emerald-600 dark:text-emerald-400"
}
>
{fritzboxSummary.changesSinceLastScan}
</p> </p>
) : null} ) : null}
{fritzboxMutation.isError ? ( <p className="text-black/50 dark:text-white/50">
<p className="mt-2 text-sm text-red-500">{(fritzboxMutation.error as Error).message}</p> {fritzboxSummary.found} Eintrag/Einträge von der FritzBox,{" "}
{fritzboxSummary.uniqueDevices} eindeutige Geräte
{fritzboxSummary.found !== fritzboxSummary.uniqueDevices
? " (mehrere Einträge desselben Geräts wurden zusammengeführt)"
: ""}
.
</p>
</div>
) : null} ) : null}
{fritzboxError ? <p className="mt-2 text-sm text-red-500">{fritzboxError}</p> : null}
<DeviceChangesReview
deviceChanges={deviceChanges}
onResolve={(key) => {
void dismissSuggestion("device_change", key);
setDeviceChanges((prev) => prev.filter((c) => deviceChangeKey(c) !== key));
}}
/>
<StaleDevicesReview
staleDevices={staleDevices}
onResolve={(id) => {
void dismissSuggestion("stale_device", id);
setStaleDevices((prev) => prev.filter((d) => d.id !== id));
}}
/>
</div> </div>
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10"> <div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<h2 className="font-medium text-black dark:text-white">Alle Geräte scannen</h2> <h2 className="font-medium text-black dark:text-white">Alle Geräte scannen</h2>
<p className="mt-1 text-sm text-black/50 dark:text-white/50"> <p className="mt-1 text-sm text-black/50 dark:text-white/50">
Führt den Netzwerk-Scan (DNS, Ports, Titel/Favicon, Softwareerkennung) nacheinander Führt einen vollständigen Portscan (alle 65535 Ports, nicht nur die üblichen) samt
für alle {devices?.length ?? 0} bekannten Geräte aus. Für ein einzelnes Gerät lieber DNS/Titel/Favicon/Softwareerkennung nacheinander für alle {devices?.length ?? 0} bekannten
den Button in der Geräte-Tabelle nutzen. Geräte aus. Läuft im Hintergrund (Fortschritt siehe unten) und kann bei vielen Geräten
durchaus einige Minuten dauern dafür werden auch Dienste auf unüblichen Ports gefunden.
</p> </p>
<div className="mt-4 flex items-center gap-2">
<Button <Button
variant="primary" variant="primary"
className="mt-4"
onClick={scanAllDevices} onClick={scanAllDevices}
disabled={bulkRunning || !devices || devices.length === 0} disabled={bulkRunning || !devices || devices.length === 0}
> >
{bulkRunning ? "Scanne …" : "Alle Geräte jetzt scannen"} {bulkRunning ? "Scanne …" : "Alle Geräte jetzt scannen"}
</Button> </Button>
{bulkRunning ? (
<Button
size="icon"
variant="ghost"
onClick={() => cancelBulkScan()}
title="Scan abbrechen"
aria-label="Scan abbrechen"
>
<FontAwesomeIcon icon={faXmark} />
</Button>
) : null}
</div>
{bulkStatus ? ( {bulkStatus ? (
<p className="mt-2 text-sm text-black/50 dark:text-white/50">{bulkStatus}</p> <p className="mt-2 text-sm text-black/50 dark:text-white/50">{bulkStatus}</p>
) : null} ) : null}
<NewServicesList
newServices={bulkNewServices}
onResolve={(id) => {
void dismissSuggestion("new_service", id);
setBulkNewServices((prev) => prev.filter((s) => s.id !== id));
}}
/>
<NameChangesReview
nameChanges={bulkNameChanges}
onResolve={(key) => {
void dismissSuggestion("name_change", key);
setBulkNameChanges((prev) => prev.filter((c) => nameChangeKey(c) !== key));
}}
/>
<StaleServicesReview
staleServices={bulkStaleServices}
onResolve={(id) => {
void dismissSuggestion("stale_service", id);
setBulkStaleServices((prev) => prev.filter((s) => s.id !== id));
}}
/>
</div> </div>
<ApiScannerCard />
</div> </div>
</div> </div>
); );

View File

@@ -1,8 +1,14 @@
import { useState } from "react"; import { useMemo, useState, type DragEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFloppyDisk, faPen, faTrash, faXmark, faSort, faSortUp, faSortDown, faStar as faStarSolid, faEye, faEyeSlash, faArrowLeft, faUserPen } from "@fortawesome/free-solid-svg-icons";
import { faStar } from "@fortawesome/free-regular-svg-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@launchpad/ui"; import { Button, Favicon, FaviconPicker } from "@launchpad/ui";
import type { Service } from "@launchpad/shared"; import type { Service } from "@launchpad/shared";
import { useServices } from "../../hooks/useServices.js"; import { useServices } from "../../hooks/useServices.js";
import { useBookmarks } from "../../hooks/useBookmarks.js";
import { useCategories } from "../../hooks/useCategories.js";
import { useDevices } from "../../hooks/useDevices.js";
import { AdminPageHeader } from "./AdminPageHeader.js"; import { AdminPageHeader } from "./AdminPageHeader.js";
interface ServicePatch { interface ServicePatch {
@@ -11,6 +17,12 @@ interface ServicePatch {
alias?: string[]; alias?: string[];
order?: number; order?: number;
favorite?: boolean; favorite?: boolean;
visible?: boolean;
hostname?: string;
port?: number;
https?: boolean;
url?: string;
favicon?: string | null;
} }
async function patchService(id: string, patch: ServicePatch): Promise<Service> { async function patchService(id: string, patch: ServicePatch): Promise<Service> {
@@ -20,7 +32,8 @@ async function patchService(id: string, patch: ServicePatch): Promise<Service> {
body: JSON.stringify(patch), body: JSON.stringify(patch),
}); });
if (!res.ok) { if (!res.ok) {
throw new Error(`Dienst konnte nicht aktualisiert werden (HTTP ${res.status})`); const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `Dienst konnte nicht aktualisiert werden (HTTP ${res.status})`);
} }
return res.json(); return res.json();
} }
@@ -32,34 +45,185 @@ async function deleteServiceRequest(id: string) {
} }
} }
async function reorderServicesRequest(entries: { id: string; order: number }[]) {
const res = await fetch("/api/services/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: (value: 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"
autoFocus
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>
);
}
const EDIT_FORM_COLSPAN = 12;
function EditForm({ service, onDone }: { service: Service; onDone: () => void }) { function EditForm({ service, onDone }: { service: Service; onDone: () => void }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: allServices } = useServices();
const { data: allBookmarks } = useBookmarks();
const [displayName, setDisplayName] = useState(service.displayName); const [displayName, setDisplayName] = useState(service.displayName);
const [category, setCategory] = useState(service.category ?? ""); const [category, setCategory] = useState(service.category ?? "");
const [alias, setAlias] = useState(service.alias.join(", ")); const [alias, setAlias] = useState(service.alias.join(", "));
const [order, setOrder] = useState(String(service.order)); const [order, setOrder] = useState(String(service.order));
const [hostname, setHostname] = useState(service.hostname);
const [port, setPort] = useState(String(service.port));
const [https, setHttps] = useState(service.https);
// undefined = unverändert (Feld nicht anfassen), null = Nutzer hat's
// explizit entfernt (fällt zurück auf automatische Erkennung/Buchstabe),
// string = neu hochgeladenes Favicon als data:-URL.
const [favicon, setFavicon] = useState<string | null | undefined>(undefined);
const [faviconError, setFaviconError] = useState<string | null>(null);
// Bereits verwendete Favicons (Dienste + Lesezeichen), zur Auswahl im
// "Vorhandenes Favicon wählen"-Picker - z. B. wenn mehrere Dienste
// desselben Geräts eigentlich dasselbe Icon zeigen sollten, der Scan aber
// nur bei einem davon eins gefunden hat.
const existingFavicons = useMemo(() => {
const seen = new Map<string, string>(); // favicon-URL -> Anzeigename für Tooltip
for (const s of allServices ?? []) {
if (s.favicon && s.id !== service.id && !seen.has(s.favicon)) seen.set(s.favicon, s.displayName);
}
for (const b of allBookmarks ?? []) {
if (b.favicon && !seen.has(b.favicon)) seen.set(b.favicon, b.displayName);
}
return Array.from(seen.entries());
}, [allServices, allBookmarks, service.id]);
const FAVICON_MAX_BYTES = 300 * 1024;
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 portNumber = Number(port) || service.port;
const previewUrl = `${https ? "https" : "http"}://${hostname || service.hostname}:${portNumber}`;
const faviconPreview = favicon === undefined ? service.favicon : favicon;
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => mutationFn: () => {
patchService(service.id, { const patch: Record<string, unknown> = {
displayName: displayName.trim(),
category: category.trim() || null,
alias: alias alias: alias
.split(",") .split(",")
.map((a) => a.trim()) .map((a) => a.trim())
.filter(Boolean), .filter(Boolean),
order: Number(order) || 0, order: Number(order) || 0,
}), hostname: hostname.trim(),
port: portNumber,
https,
url: previewUrl,
};
// displayName/category nur mitschicken, wenn sich der Wert tatsächlich
// geändert hat - sonst würde jedes Speichern (z. B. nur um den Port zu
// korrigieren) die *EditedManually-Flag fälschlich setzen, obwohl der
// Nutzer diese beiden Felder gar nicht angefasst hat (siehe
// upsertServiceFromScan/updateService-Doku in services.ts).
const trimmedName = displayName.trim();
if (trimmedName !== service.displayName) patch.displayName = trimmedName;
const trimmedCategory = category.trim() || null;
if (trimmedCategory !== service.category) patch.category = trimmedCategory;
if (favicon !== undefined) patch.favicon = favicon;
return patchService(service.id, patch);
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["services"] }); queryClient.invalidateQueries({ queryKey: ["services"] });
queryClient.invalidateQueries({ queryKey: ["categories"] });
onDone(); onDone();
}, },
}); });
return ( return (
<tr className="border-b border-black/5 bg-black/[0.02] last:border-0 dark:border-white/5 dark:bg-white/5"> <tr className="border-b border-black/5 bg-black/[0.02] last:border-0 dark:border-white/5 dark:bg-white/5">
<td colSpan={6} className="px-4 py-3"> <td colSpan={EDIT_FORM_COLSPAN} className="px-4 py-3">
<div className="flex flex-wrap items-end gap-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> <div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label> <label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label>
<input <input
@@ -71,12 +235,7 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
</div> </div>
<div> <div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Kategorie</label> <label className="mb-1 block text-xs text-black/50 dark:text-white/50">Kategorie</label>
<input <CategorySelect value={category} onChange={setCategory} />
value={category}
onChange={(e) => setCategory(e.target.value)}
className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
dark:border-white/10 dark:bg-white/10 dark:text-white"
/>
</div> </div>
<div> <div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50"> <label className="mb-1 block text-xs text-black/50 dark:text-white/50">
@@ -99,19 +258,81 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
dark:border-white/10 dark:bg-white/10 dark:text-white" dark:border-white/10 dark:bg-white/10 dark:text-white"
/> />
</div> </div>
<Button size="sm" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending}>
Speichern <div className="w-full border-t border-black/10 pt-3 dark:border-white/10" />
</Button>
<Button size="sm" variant="ghost" onClick={onDone}> <div>
Abbrechen <label className="mb-1 block text-xs text-black/50 dark:text-white/50">
</Button> Hostname / IP
</label>
<input
value={hostname}
onChange={(e) => setHostname(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">Port</label>
<input
value={port}
onChange={(e) => setPort(e.target.value)}
type="number"
min={1}
max={65535}
className="w-24 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 flex items-center gap-1.5 text-xs text-black/50 dark:text-white/50">
<input
type="checkbox"
checked={https}
onChange={(e) => setHttps(e.target.checked)}
className="rounded"
/>
HTTPS
</label>
</div>
<div className="text-xs text-black/40 dark:text-white/40">
Öffnet: <span className="font-mono">{previewUrl}</span>
</div>
<div className="flex w-full items-center gap-2 pt-1">
<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> </div>
</td> </td>
</tr> </tr>
); );
} }
function ServiceRow({ service }: { service: Service }) { function ServiceRow({
service,
deviceMac,
deviceIp,
deviceHostname,
draggable,
onDragStart,
onDragOver,
onDrop,
isDragging,
}: {
service: Service;
deviceMac: string | null;
deviceIp: string | null;
deviceHostname: string | null;
draggable: boolean;
onDragStart: (e: DragEvent<HTMLTableRowElement>) => void;
onDragOver: (e: DragEvent<HTMLTableRowElement>) => void;
onDrop: (e: DragEvent<HTMLTableRowElement>) => void;
isDragging: boolean;
}) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
@@ -120,6 +341,11 @@ function ServiceRow({ service }: { service: Service }) {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
}); });
const visibleMutation = useMutation({
mutationFn: () => patchService(service.id, { visible: !service.visible }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
});
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: () => deleteServiceRequest(service.id), mutationFn: () => deleteServiceRequest(service.id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
@@ -130,24 +356,98 @@ function ServiceRow({ service }: { service: Service }) {
} }
return ( return (
<tr className="border-b border-black/5 last:border-0 dark:border-white/5"> <tr
<td className="px-4 py-3"> draggable={draggable}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
className={`border-b border-black/5 last:border-0 dark:border-white/5 ${
service.visible ? "" : "opacity-50"
} ${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">
<div className="flex items-center gap-1.5">
<button <button
onClick={() => favoriteMutation.mutate()} onClick={() => favoriteMutation.mutate()}
aria-label={service.favorite ? "Favorit entfernen" : "Als Favorit markieren"} aria-label={service.favorite ? "Favorit entfernen" : "Als Favorit markieren"}
className={`text-lg ${service.favorite ? "text-amber-500" : "text-black/15 hover:text-amber-400 dark:text-white/15"}`} className={`text-lg ${service.favorite ? "text-amber-500" : "text-black/15 hover:text-amber-400 dark:text-white/15"}`}
> >
<FontAwesomeIcon icon={service.favorite ? faStarSolid : faStar} />
</button> </button>
<button
onClick={() => visibleMutation.mutate()}
aria-label={service.visible ? "In der Suche ausblenden" : "In der Suche einblenden"}
title={service.visible ? "In der Suche sichtbar" : "In der Suche ausgeblendet"}
className={`text-base ${service.visible ? "text-black/40 dark:text-white/40" : "text-black/60 dark:text-white/60"}`}
>
{service.visible ? <FontAwesomeIcon icon={faEye} /> : <FontAwesomeIcon icon={faEyeSlash} />}
</button>
</div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="font-medium text-black dark:text-white">{service.displayName}</div> <div className="flex items-center gap-2">
<div className="text-xs text-black/40 dark:text-white/40">{service.hostname}</div> <Favicon src={service.favicon} fallbackLetter={service.displayName} size="sm" />
<div className="flex items-center gap-2">
<span className="font-medium text-black dark:text-white">{service.displayName}</span>
{service.displayNameEditedManually ? (
<span title="Name wurde manuell angepasst Scan-Vorschläge dafür werden nicht mehr angezeigt">
<FontAwesomeIcon icon={faUserPen} className="text-black/40 dark:text-white/40" />
</span>
) : null}
</div>
</div>
</td>
<td className="px-4 py-3 font-mono text-xs text-black/60 dark:text-white/60">
{/* Der gespeicherte Hostname ist oft nur die IP (wenn DNS nichts
Besseres auflösen konnte) - dann lieber den bekannten Gerätenamen
zeigen statt der IP doppelt (die steht schon in der Spalte
daneben). Der technische Wert bleibt beim Bearbeiten unverändert
editierbar/verwendbar, hier geht es nur um die Anzeige. */}
{service.hostname === deviceIp && deviceHostname ? (
<span title={`Technisch: ${service.hostname}`}>{deviceHostname}</span>
) : (
service.hostname
)}
</td>
<td className="px-4 py-3 font-mono text-xs text-black/60 dark:text-white/60">
{deviceIp ?? ""}
</td>
<td className="px-4 py-3 text-black/60 dark:text-white/60">
<span className="inline-flex items-center gap-1">
{service.category ?? ""}
{service.categoryEditedManually ? (
<span title="Kategorie wurde manuell angepasst Scan-Vorschläge dafür werden nicht mehr angezeigt">
<FontAwesomeIcon icon={faUserPen} className="text-black/40 dark:text-white/40" />
</span>
) : null}
</span>
</td>
<td className="px-4 py-3 font-mono text-xs text-black/40 dark:text-white/40">
{deviceMac ?? ""}
</td> </td>
<td className="px-4 py-3 text-black/60 dark:text-white/60">{service.category ?? ""}</td>
<td className="px-4 py-3 text-black/60 dark:text-white/60"> <td className="px-4 py-3 text-black/60 dark:text-white/60">
{service.alias.length > 0 ? service.alias.join(", ") : ""} {service.alias.length > 0 ? service.alias.join(", ") : ""}
</td> </td>
<td className="px-4 py-3 font-mono text-black/60 dark:text-white/60">{service.port}</td>
<td className="px-4 py-3">
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
service.https
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-black/5 text-black/50 dark:bg-white/10 dark:text-white/50"
}`}
>
{service.https ? "https" : "http"}
</span>
</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<a <a
href={service.url} href={service.url}
@@ -160,57 +460,360 @@ function ServiceRow({ service }: { service: Service }) {
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<Button size="sm" onClick={() => setEditing(true)}> <Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"><FontAwesomeIcon icon={faPen} /></Button>
Bearbeiten <Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
</Button>
<Button size="sm" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending}>
Löschen
</Button>
</div> </div>
</td> </td>
</tr> </tr>
); );
} }
type SortColumn = "displayName" | "hostname" | "ip" | "category" | "alias" | "port" | "https" | 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>
);
}
interface FaviconSuggestion {
serviceId: string;
serviceName: string;
candidates: { name: string; url: string }[];
}
function FaviconSuggestionsPanel() {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [suggestions, setSuggestions] = useState<FaviconSuggestion[] | null>(null);
const [checkedCount, setCheckedCount] = useState(0);
const [loading, setLoading] = useState(false);
function load() {
setLoading(true);
setOpen(true);
fetch("/api/services/favicon-suggestions")
.then((res) => res.json())
.then((body) => {
setSuggestions(body.suggestions ?? []);
setCheckedCount(body.checked ?? 0);
})
.finally(() => setLoading(false));
}
const applyMutation = useMutation({
mutationFn: ({ serviceId, url }: { serviceId: string; url: string }) => patchService(serviceId, { favicon: url }),
onSuccess: (_data, { serviceId }) => {
setSuggestions((prev) => (prev ? prev.filter((s) => s.serviceId !== serviceId) : prev));
queryClient.invalidateQueries({ queryKey: ["services"] });
},
});
if (!open) {
return (
<div className="pb-2">
<Button
size="sm"
variant="ghost"
onClick={load}
title="Für Dienste ohne Favicon passende Treffer aus der Icon-Datenbank vorschlagen"
>
Fehlende Favicons ergänzen
</Button>
</div>
);
}
return (
<div className="mb-3 w-full rounded-xl border border-black/10 p-3 dark:border-white/10">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-black/60 dark:text-white/60">
{loading
? "Suche …"
: suggestions && suggestions.length > 0
? `Vorschläge für ${suggestions.length} von ${checkedCount} Diensten ohne Favicon:`
: `Keine Vorschläge gefunden (${checkedCount} Dienste ohne Favicon geprüft).`}
</span>
<button
onClick={() => setOpen(false)}
className="text-xs text-black/40 underline dark:text-white/40"
>
Schließen
</button>
</div>
{suggestions && suggestions.length > 0 ? (
<ul className="max-h-64 space-y-2 overflow-y-auto">
{suggestions.map((s) => (
<li key={s.serviceId} className="flex items-center gap-3">
<span className="w-32 shrink-0 truncate text-xs text-black/70 dark:text-white/70">
{s.serviceName}
</span>
<div className="flex flex-wrap gap-1.5">
{s.candidates.map((c) => (
<button
key={c.url}
type="button"
title={c.name}
onClick={() => applyMutation.mutate({ serviceId: s.serviceId, url: c.url })}
disabled={applyMutation.isPending}
className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10"
>
<Favicon src={c.url} fallbackLetter={c.name} size="lg" />
</button>
))}
</div>
<button
onClick={() => setSuggestions((prev) => (prev ? prev.filter((x) => x.serviceId !== s.serviceId) : prev))}
className="ml-auto shrink-0 text-xs text-black/30 hover:text-black/60 dark:text-white/30 dark:hover:text-white/60"
title="Überspringen"
>
<FontAwesomeIcon icon={faXmark} />
</button>
</li>
))}
</ul>
) : null}
</div>
);
}
export function ServicesPage() { export function ServicesPage() {
const { data: services, isLoading, isError } = useServices(); const { data: services, isLoading, isError } = useServices();
const { data: devices } = useDevices();
const queryClient = useQueryClient();
const [draggedId, setDraggedId] = useState<string | null>(null);
const [localOrder, setLocalOrder] = useState<Service[] | null>(null);
const [sortColumn, setSortColumn] = useState<SortColumn>(null);
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [activeTab, setActiveTab] = useState<"visible" | "hidden">("visible");
const macByDeviceId = useMemo(() => {
const map: Record<string, string | null> = {};
for (const d of devices ?? []) map[d.id] = d.mac;
return map;
}, [devices]);
const ipByDeviceId = useMemo(() => {
const map: Record<string, string | null> = {};
for (const d of devices ?? []) map[d.id] = d.ip;
return map;
}, [devices]);
const hostnameByDeviceId = useMemo(() => {
const map: Record<string, string | null> = {};
for (const d of devices ?? []) map[d.id] = d.hostname;
return map;
}, [devices]);
const reorderMutation = useMutation({
mutationFn: reorderServicesRequest,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["services"] });
setLocalOrder(null);
},
onError: () => setLocalOrder(null),
});
const baseList = localOrder ?? services ?? [];
const hiddenCount = services?.filter((s) => !s.visible).length ?? 0;
const visibleCount = services ? services.length - hiddenCount : 0;
const tabFilteredBase = useMemo(
() => baseList.filter((s) => (activeTab === "visible" ? s.visible : !s.visible)),
[baseList, activeTab]
);
const list = useMemo(() => {
if (!sortColumn) return tabFilteredBase;
const sorted = [...tabFilteredBase].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 "ip":
cmp = (ipByDeviceId[a.deviceId] ?? "").localeCompare(
ipByDeviceId[b.deviceId] ?? "",
undefined,
{ numeric: true }
);
break;
case "category":
cmp = (a.category ?? "").localeCompare(b.category ?? "");
break;
case "alias":
cmp = a.alias.join(",").localeCompare(b.alias.join(","));
break;
case "port":
cmp = a.port - b.port;
break;
case "https":
cmp = Number(a.https) - Number(b.https);
break;
}
return sortDirection === "asc" ? cmp : -cmp;
});
return sorted;
}, [tabFilteredBase, sortColumn, sortDirection, ipByDeviceId]);
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 ?? services ?? [];
const fromIndex = current.findIndex((s) => s.id === draggedId);
const toIndex = current.findIndex((s) => s.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 ?? services ?? [];
reorderMutation.mutate(current.map((s, index) => ({ id: s.id, order: index })));
};
}
return ( return (
<div> <div>
<AdminPageHeader <AdminPageHeader
title="Dienste" title="Dienste"
description="Name, Kategorie, Alias und Reihenfolge bleiben bei erneuten Scans erhalten." description="Spaltenköpfe anklickbar zum Sortieren; Drag & Drop (⠿⠿) nur in der Standard-Reihenfolge. Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten."
/> />
<div className="mb-4 flex items-center justify-between gap-2 border-b border-black/10 dark:border-white/10">
<div className="flex gap-1">
<button
onClick={() => setActiveTab("visible")}
className={`px-3 py-2 text-sm font-medium ${
activeTab === "visible"
? "border-b-2 border-black text-black dark:border-white dark:text-white"
: "text-black/40 hover:text-black/70 dark:text-white/40 dark:hover:text-white/70"
}`}
>
Sichtbar ({visibleCount})
</button>
<button
onClick={() => setActiveTab("hidden")}
className={`px-3 py-2 text-sm font-medium ${
activeTab === "hidden"
? "border-b-2 border-black text-black dark:border-white dark:text-white"
: "text-black/40 hover:text-black/70 dark:text-white/40 dark:hover:text-white/70"
}`}
>
<FontAwesomeIcon icon={faEyeSlash} /> Ausgeblendet ({hiddenCount})
</button>
</div>
</div>
<FaviconSuggestionsPanel />
{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 ? ( {isLoading ? (
<p className="text-sm text-black/40 dark:text-white/40">Lade Dienste </p> <p className="text-sm text-black/40 dark:text-white/40">Lade Dienste </p>
) : isError ? ( ) : isError ? (
<p className="text-sm text-red-500">Dienste konnten nicht geladen werden.</p> <p className="text-sm text-red-500">Dienste konnten nicht geladen werden.</p>
) : services && services.length > 0 ? ( ) : list.length > 0 ? (
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10"> <div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
<table className="w-full text-sm"> <div className="overflow-x-auto">
<table className="w-full min-w-[1140px] text-sm">
<thead> <thead>
<tr className="border-b border-black/10 bg-black/[0.02] text-left text-xs <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"> uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
<th className="px-4 py-2 font-medium" /> <th className="px-2 py-2" />
<th className="px-4 py-2 font-medium">Dienst</th> <th className="px-2 py-2" />
<th className="px-4 py-2 font-medium">Kategorie</th> <SortableHeader label="Dienst" column="displayName" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<th className="px-4 py-2 font-medium">Alias</th> <SortableHeader label="Host" column="hostname" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<SortableHeader label="IP" column="ip" 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">MAC</th>
<SortableHeader label="Alias" column="alias" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<SortableHeader label="Port" column="port" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<SortableHeader label="Protokoll" column="https" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<th className="px-4 py-2 font-medium">URL</th> <th className="px-4 py-2 font-medium">URL</th>
<th className="px-4 py-2" /> <th className="px-4 py-2" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{services.map((service) => ( {list.map((service) => (
<ServiceRow key={service.id} service={service} /> <ServiceRow
key={service.id}
service={service}
deviceMac={macByDeviceId[service.deviceId] ?? null}
deviceIp={ipByDeviceId[service.deviceId] ?? null}
deviceHostname={hostnameByDeviceId[service.deviceId] ?? null}
draggable={dragEnabled}
isDragging={draggedId === service.id}
onDragStart={handleDragStart(service.id)}
onDragOver={handleDragOver(service.id)}
onDrop={handleDrop()}
/>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
) : ( ) : (
<p className="text-sm text-black/40 dark:text-white/40"> <p className="text-sm text-black/40 dark:text-white/40">
Noch keine Dienste vorhanden. Scanne ein Gerät unter Geräte, um automatisch welche {activeTab === "hidden"
zu finden. ? "Keine ausgeblendeten Dienste."
: "Noch keine Dienste vorhanden. Scanne ein Gerät unter „Geräte“, um automatisch welche zu finden, oder importiere eine Liste oben."}
</p> </p>
)} )}
</div> </div>

View File

@@ -1,7 +1,34 @@
import { useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@launchpad/ui";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
import { useBackendHealth } from "../../hooks/useBackendHealth.js"; import { useBackendHealth } from "../../hooks/useBackendHealth.js";
import { useTheme } from "../../hooks/useTheme.js"; import { useTheme } from "../../hooks/useTheme.js";
import { useSettings } from "../../hooks/useSettings.js";
import { AdminPageHeader } from "./AdminPageHeader.js"; import { AdminPageHeader } from "./AdminPageHeader.js";
/**
* Formatiert eine Sekundenzahl als "Xd Yh Zm Ws" (nur die tatsächlich
* relevanten Einheiten, z. B. "3h 12m 5s" ohne führende "0d") - vorher stand
* hier die rohe Sekundenzahl ("uptimeSeconds"), z. B. "435822s", was bei
* einem Blick in die Einstellungen kaum sinnvoll einzuordnen war.
*/
function formatUptime(totalSeconds: number): string {
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (days > 0 || hours > 0) parts.push(`${hours}h`);
if (days > 0 || hours > 0 || minutes > 0) parts.push(`${minutes}m`);
parts.push(`${seconds}s`);
return parts.join(" ");
}
function InfoRow({ label, value }: { label: string; value: string }) { function InfoRow({ label, value }: { label: string; value: string }) {
return ( return (
<div className="flex items-center justify-between border-b border-black/5 py-3 last:border-0 dark:border-white/5"> <div className="flex items-center justify-between border-b border-black/5 py-3 last:border-0 dark:border-white/5">
@@ -11,15 +38,439 @@ function InfoRow({ label, value }: { label: string; value: string }) {
); );
} }
function LiveStatusToggle({ enabled }: { enabled: boolean }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (next: boolean) => {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ liveStatusEnabled: next }),
});
if (!res.ok) throw new Error(`Speichern fehlgeschlagen (HTTP ${res.status})`);
return res.json();
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
});
return (
<div className="flex items-center justify-between py-1">
<span className="text-sm text-black/50 dark:text-white/50">Aktiviert</span>
<button
onClick={() => mutation.mutate(!enabled)}
disabled={mutation.isPending}
role="switch"
aria-checked={enabled}
className={`relative h-6 w-11 rounded-full transition-colors ${
enabled ? "bg-indigo-500" : "bg-black/15 dark:bg-white/15"
}`}
>
<span
className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
enabled ? "translate-x-[20px]" : "translate-x-0"
}`}
/>
</button>
</div>
);
}
function SelectSetting<T extends string>({
label,
hint,
settingKey,
value: currentValue,
options,
}: {
label: string;
hint: string;
settingKey: "mobileHomeStyle";
value: T | undefined;
options: Array<{ value: T; label: string }>;
}) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (next: T) => {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [settingKey]: next }),
});
if (!res.ok) throw new Error(`Speichern fehlgeschlagen (HTTP ${res.status})`);
return res.json();
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
});
return (
<div className="flex items-center justify-between py-2">
<div>
<span className="text-sm text-black/50 dark:text-white/50">{label}</span>
<p className="text-xs text-black/30 dark:text-white/30">{hint}</p>
</div>
<select
value={currentValue}
onChange={(e) => mutation.mutate(e.target.value as T)}
className="rounded-lg border border-black/10 bg-white px-2 py-1.5 text-sm text-black
dark:border-white/10 dark:bg-white/10 dark:text-white"
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
);
}
function LimitSetting({
label,
hint,
settingKey,
value: currentValue,
max = 50,
}: {
label: string;
hint: string;
settingKey: "recentVisitsLimit" | "readLaterLimit" | "staleDeviceThresholdDays" | "liveStatusIntervalMinutes";
value: number | undefined;
max?: number;
}) {
const queryClient = useQueryClient();
const [value, setValue] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: async (limit: number) => {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [settingKey]: limit }),
});
if (!res.ok) throw new Error(`Speichern fehlgeschlagen (HTTP ${res.status})`);
return res.json();
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
});
const displayed = value ?? String(currentValue ?? 5);
return (
<div className="flex items-center justify-between py-2">
<div>
<span className="text-sm text-black/50 dark:text-white/50">{label}</span>
<p className="text-xs text-black/30 dark:text-white/30">{hint}</p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={max}
value={displayed}
onChange={(e) => setValue(e.target.value)}
onBlur={() => {
const n = Number(value);
if (value !== null && Number.isFinite(n)) mutation.mutate(n);
}}
className="w-20 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
text-black dark:border-white/10 dark:bg-white/10 dark:text-white"
/>
{mutation.isPending ? <span className="text-xs text-black/30 dark:text-white/30">speichere </span> : null}
</div>
</div>
);
}
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve((reader.result as string).split(",")[1] ?? "");
reader.onerror = () => reject(new Error("Datei konnte nicht gelesen werden"));
reader.readAsDataURL(file);
});
}
function detectFormat(filename: string): "csv" | "xlsx" | "json" {
if (filename.toLowerCase().endsWith(".xlsx")) return "xlsx";
if (filename.toLowerCase().endsWith(".json")) return "json";
return "csv";
}
interface TransferResult {
devicesImported: number;
devicesSkipped: number;
servicesImported: number;
servicesSkipped: number;
errors: string[];
}
function ImportExportSection() {
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [result, setResult] = useState<TransferResult | null>(null);
const importMutation = useMutation({
mutationFn: async (file: File): Promise<TransferResult> => {
const content = await readFileAsBase64(file);
const format = detectFormat(file.name);
const res = await fetch("/api/transfer/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ format, content }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `Import fehlgeschlagen (HTTP ${res.status})`);
}
return res.json();
},
onSuccess: (data) => {
setResult(data);
queryClient.invalidateQueries({ queryKey: ["devices"] });
queryClient.invalidateQueries({ queryKey: ["services"] });
queryClient.invalidateQueries({ queryKey: ["categories"] });
},
onError: (err: Error) =>
setResult({
devicesImported: 0,
devicesSkipped: 0,
servicesImported: 0,
servicesSkipped: 0,
errors: [err.message],
}),
});
return (
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<h2 className="mb-1 font-medium text-black dark:text-white">Import / Export</h2>
<p className="mb-4 text-sm text-black/50 dark:text-white/50">
Exportiert Geräte und Dienste in einer Datei. Import legt ausschließlich neue Einträge
an bereits vorhandene Geräte (Abgleich über IP/Hostname) und Dienste (Abgleich über
Gerät + Port) werden übersprungen, nie überschrieben oder verdoppelt.
</p>
<div className="mb-4 flex flex-wrap gap-2">
<a href="/api/transfer/export?format=csv" download>
<Button size="sm">CSV exportieren</Button>
</a>
<a href="/api/transfer/export?format=xlsx" download>
<Button size="sm">Excel exportieren</Button>
</a>
<a href="/api/transfer/export?format=json" download>
<Button size="sm">JSON exportieren</Button>
</a>
</div>
<div className="mb-4">
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) importMutation.mutate(file);
e.target.value = "";
}}
/>
<Button
variant="primary"
onClick={() => fileInputRef.current?.click()}
disabled={importMutation.isPending}
>
{importMutation.isPending ? "Importiere …" : "Datei importieren (CSV/Excel/JSON)"}
</Button>
</div>
{result ? (
<p className="mb-4 text-xs text-black/50 dark:text-white/50">
{result.devicesImported} Gerät(e) + {result.servicesImported} Dienst(e) importiert,{" "}
{result.devicesSkipped + result.servicesSkipped} übersprungen (bereits vorhanden)
{result.errors.length > 0
? `, ${result.errors.length} Fehler: ${result.errors.join(" | ")}`
: "."}
</p>
) : null}
<details className="text-xs text-black/50 dark:text-white/50">
<summary className="cursor-pointer font-medium text-black/70 dark:text-white/70">
Format-Anleitung für den Import anzeigen
</summary>
<div className="mt-2 space-y-2">
<p>
Eine Zeile pro Eintrag, mit einer Spalte{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">type</code> ={" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">device</code> oder{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">service</code>. Am
einfachsten: erst exportieren, die Datei als Vorlage nehmen.
</p>
<p>
<strong>Geräte-Zeilen</strong> brauchen:{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">hostname</code>,{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">ip</code> (Pflicht),
optional <code className="rounded bg-black/5 px-1 dark:bg-white/10">mac</code>,{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">manufacturer</code>,{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">model</code>.
</p>
<p>
<strong>Dienst-Zeilen</strong> brauchen:{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">displayName</code>,{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">hostname</code>,{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">port</code>,{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">url</code> (Pflicht),
plus <code className="rounded bg-black/5 px-1 dark:bg-white/10">deviceHostname</code>{" "}
und <code className="rounded bg-black/5 px-1 dark:bg-white/10">deviceIp</code>, um
sie einem Gerät zuzuordnen (wird bei Bedarf automatisch angelegt). Optional:{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">category</code>,{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">alias</code> (mit{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">;</code> getrennt),{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">favorite</code>,{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">visible</code>,{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">https</code> (jeweils{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">true</code>/
<code className="rounded bg-black/5 px-1 dark:bg-white/10">false</code>),{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">order</code> (Zahl).
</p>
</div>
</details>
</div>
);
}
async function resetEverything() {
const res = await fetch("/api/reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: true }),
});
const body = await res.json();
if (!res.ok) {
throw new Error(body.error ?? `Reset fehlgeschlagen (HTTP ${res.status})`);
}
return body as { deletedDevices: number };
}
function DangerZone() {
const queryClient = useQueryClient();
const [message, setMessage] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: resetEverything,
onSuccess: (result) => {
setMessage(`${result.deletedDevices} Gerät(e) und alle zugehörigen Dienste gelöscht.`);
queryClient.invalidateQueries({ queryKey: ["devices"] });
queryClient.invalidateQueries({ queryKey: ["services"] });
queryClient.invalidateQueries({ queryKey: ["logs"] });
},
onError: (err: Error) => setMessage(err.message),
});
function handleClick() {
const firstConfirm = window.confirm(
"Wirklich ALLE Geräte und Dienste unwiderruflich löschen? Kategorien bleiben erhalten."
);
if (!firstConfirm) return;
const secondConfirm = window.confirm(
"Ganz sicher? Das kann nicht rückgängig gemacht werden."
);
if (!secondConfirm) return;
setMessage(null);
mutation.mutate();
}
return (
<div className="rounded-2xl border border-red-500/20 p-5">
<h2 className="mb-1 font-medium text-red-600 dark:text-red-400">Gefahrenzone</h2>
<p className="mb-4 text-sm text-black/50 dark:text-white/50">
Löscht alle Geräte und alle zugehörigen Dienste unwiderruflich. Kategorien und Logs
bleiben erhalten.
</p>
<Button variant="danger" onClick={handleClick} disabled={mutation.isPending}>
{mutation.isPending ? "Lösche …" : "Alle Geräte & Dienste löschen"}
</Button>
{message ? (
<p className="mt-3 text-sm text-black/60 dark:text-white/60">{message}</p>
) : null}
</div>
);
}
function SearchEngineHint() {
const [open, setOpen] = useState(false);
const origin = typeof window !== "undefined" ? window.location.origin : "";
return (
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center justify-between text-left"
>
<span>
<span className="font-medium text-black dark:text-white">
💡 Als Browser-Suchmaschine einrichten
</span>
<p className="mt-0.5 text-sm text-black/50 dark:text-white/50">
Tippe künftig z. B. <code className="rounded bg-black/5 px-1 dark:bg-white/10">lp proxmox</code>{" "}
direkt in die Adresszeile deines Browsers.
</p>
</span>
<span className="text-black/30 dark:text-white/30">{open ? "▾" : "▸"}</span>
</button>
{open ? (
<div className="mt-3 space-y-3 text-sm text-black/70 dark:text-white/70">
<div>
<p className="font-medium text-black dark:text-white">Chrome / Edge</p>
<ol className="ml-4 list-decimal space-y-0.5">
<li>Einstellungen Suchmaschine" → „Suchmaschinen verwalten"</li>
<li>Website-Suchmaschine hinzufügen"</li>
<li>
Name: <code className="rounded bg-black/5 px-1 dark:bg-white/10">LaunchPad</code>, Kürzel:{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">lp</code>, URL:{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">{origin}/search?q=%s</code>
</li>
</ol>
</div>
<div>
<p className="font-medium text-black dark:text-white">Firefox</p>
<ol className="ml-4 list-decimal space-y-0.5">
<li>Diese Seite einmal besucht (fertig, Firefox hat den Eintrag bereits erkannt)</li>
<li>Einstellungen → „Suche" bei LaunchPad" ein Schlagwort (z. B. „lp") eintragen</li>
</ol>
</div>
<p className="text-xs text-black/40 dark:text-white/40">
Danach: lp &lt;suchbegriff&gt;" in die Adresszeile bei eindeutigem Treffer geht's direkt
zum Dienst, sonst zur LaunchPad-Suche mit vorbefülltem Begriff.
</p>
<div className="rounded-lg bg-amber-500/10 p-2 text-xs text-amber-700 dark:text-amber-400">
<strong>Android:</strong> Firefox für Android unterstützt Schlagwort-Suchen bislang
grundsätzlich nicht (auch keine synchronisierten Desktop-Kürzel) das ist eine
Firefox-Einschränkung, kein LaunchPad-Problem. Auf Android funktioniert es in{" "}
<strong>Chrome</strong> genauso wie oben für Chrome/Edge beschrieben. Alternative: die
LaunchPad-Seite über „Zum Startbildschirm hinzufügen" als App-Symbol ablegen dann reicht
ein Fingertipp bis zum Suchfeld.
</div>
</div>
) : null}
</div>
);
}
export function SettingsPage() { export function SettingsPage() {
const { health, error } = useBackendHealth(); const { health, error } = useBackendHealth();
const [theme, toggleTheme] = useTheme(); const [theme, toggleTheme] = useTheme();
const { data: settings } = useSettings();
return ( return (
<div> <div>
<AdminPageHeader title="Einstellungen" /> <AdminPageHeader title="Einstellungen" />
<div className="max-w-lg space-y-6"> <div className="max-w-2xl space-y-6">
<SearchEngineHint />
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10"> <div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<h2 className="mb-2 font-medium text-black dark:text-white">Darstellung</h2> <h2 className="mb-2 font-medium text-black dark:text-white">Darstellung</h2>
<div className="flex items-center justify-between py-2"> <div className="flex items-center justify-between py-2">
@@ -30,11 +481,87 @@ export function SettingsPage() {
transition-colors hover:bg-black/5 dark:border-white/10 dark:text-white transition-colors hover:bg-black/5 dark:border-white/10 dark:text-white
dark:hover:bg-white/10" dark:hover:bg-white/10"
> >
{theme === "dark" ? "🌙 Dunkel" : "☀️ Hell"} wechseln <FontAwesomeIcon icon={theme === "dark" ? faMoon : faSun} /> {theme === "dark" ? "Dunkel" : "Hell"} wechseln
</button> </button>
</div> </div>
<LimitSetting
label='Anzahl „Zuletzt besucht"'
hint="0 = Leiste ausblenden"
settingKey="recentVisitsLimit"
value={settings?.recentVisitsLimit}
/>
<LimitSetting
label='Anzahl „Später lesen" auf der Startseite'
hint="0 = Box ausblenden. Ändert nichts an der Gesamtliste (siehe Admin -> Später lesen)."
settingKey="readLaterLimit"
value={settings?.readLaterLimit}
/>
<SelectSetting
label="Design (mobile Ansicht)"
hint="Gilt nur auf schmalen Bildschirmen (Handy). Am Desktop bleibt immer das gewohnte Design."
settingKey="mobileHomeStyle"
value={settings?.mobileHomeStyle}
options={[
{ value: "classic", label: "Klassisch (Liste)" },
{ value: "android", label: "Startbildschirm (Android-Stil)" },
]}
/>
</div> </div>
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<h2 className="mb-2 font-medium text-black dark:text-white">Scan</h2>
<LimitSetting
label="Nicht mehr gemeldete Geräte/Dienste anzeigen nach (Tagen)"
hint="Gilt für Geräte, die die FritzBox nicht mehr meldet, UND für einzelne Dienste, deren Port bei einem Scan nicht mehr offen ist. 0 = sofort beim ersten verpassten Scan."
settingKey="staleDeviceThresholdDays"
value={settings?.staleDeviceThresholdDays}
max={90}
/>
</div>
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<h2 className="mb-1 font-medium text-black dark:text-white">Live-Status</h2>
<p className="mb-2 text-xs text-black/40 dark:text-white/40">
Prüft in regelmäßigem Abstand per Ping, ob ein Gerät erreichbar ist, und
aktualisiert nur dessen Online-Punkt. Legt nie neue Dienste an und schlägt nie
Namen/Kategorien vor das bleibt weiterhin ausschließlich manuellen Scans
vorbehalten. Standardmäßig aus.
</p>
<LiveStatusToggle enabled={settings?.liveStatusEnabled ?? false} />
{settings?.liveStatusEnabled ? (
<p className="mb-2 text-xs text-black/30 dark:text-white/30">
{settings.liveStatusLastRunAt
? `Letzter Durchlauf: ${new Date(settings.liveStatusLastRunAt).toLocaleString("de-DE", {
dateStyle: "medium",
timeStyle: "short",
})}`
: "Noch kein Durchlauf seit dem Aktivieren."}
</p>
) : null}
{settings?.liveStatusEnabled ? (
<LimitSetting
label="Prüfintervall (Minuten)"
hint="Wie oft alle Geräte angepingt werden."
settingKey="liveStatusIntervalMinutes"
value={settings?.liveStatusIntervalMinutes}
max={1440}
/>
) : null}
</div>
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<h2 className="mb-2 font-medium text-black dark:text-white">HTTPS</h2>
<p className="mb-3 text-sm text-black/50 dark:text-white/50">
Root-CA-Zertifikat herunterladen und auf deinen Geräten als vertrauenswürdig
einstufen, um die Browser-Warnung dauerhaft loszuwerden (einmal pro Gerät).
</p>
<a href="/ca.crt" download>
<Button variant="primary">Root-Zertifikat herunterladen (ca.crt)</Button>
</a>
</div>
<ImportExportSection />
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10"> <div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<h2 className="mb-2 font-medium text-black dark:text-white">Backend</h2> <h2 className="mb-2 font-medium text-black dark:text-white">Backend</h2>
{error ? ( {error ? (
@@ -43,12 +570,14 @@ export function SettingsPage() {
<div> <div>
<InfoRow label="Status" value={health.status} /> <InfoRow label="Status" value={health.status} />
<InfoRow label="Version" value={health.version} /> <InfoRow label="Version" value={health.version} />
<InfoRow label="Läuft seit" value={`${health.uptimeSeconds}s`} /> <InfoRow label="Läuft seit" value={formatUptime(health.uptimeSeconds)} />
</div> </div>
) : ( ) : (
<p className="text-sm text-black/40 dark:text-white/40">Lade </p> <p className="text-sm text-black/40 dark:text-white/40">Lade </p>
)} )}
</div> </div>
<DangerZone />
</div> </div>
</div> </div>
); );

View File

@@ -18,6 +18,19 @@ export default {
"sans-serif", "sans-serif",
], ],
}, },
keyframes: {
// Sehr langsames, kaum wahrnehmbares Wandern des Ambient-Gradient-
// Hintergrunds beim Android-Startbildschirm-Design (siehe
// MobileAndroidHome.tsx) - über motion-safe: nur aktiv, wenn das
// Betriebssystem "reduzierte Bewegung" nicht eingeschaltet hat.
drift: {
"0%, 100%": { transform: "translate(0%, 0%) scale(1)" },
"50%": { transform: "translate(-4%, 3%) scale(1.06)" },
},
},
animation: {
drift: "drift 26s ease-in-out infinite",
},
}, },
}, },
plugins: [], plugins: [],

View File

@@ -35,8 +35,15 @@ export default defineConfig({
], ],
}, },
workbox: { workbox: {
// Zeigt beim Offline-Öffnen weiterhin die (gecachte) App-Shell an. // Zeigt beim Offline-Öffnen weiterhin die (gecachte) App-Shell an -
// ABER NICHT für /api/* oder /ca.crt: ein <a href="/api/..." download>-
// oder <a href="/ca.crt" download>-Klick wird vom Browser als
// "Navigation" behandelt, und ohne dieses Denylist hätte der Service
// Worker dafür fälschlich die App-Shell (index.html) ausgeliefert
// statt die Anfrage ans Backend/an nginx durchzulassen (Ursache für
// "Export lädt weiße .htm-Seite" / "ca.crt enthält HTML-Code").
navigateFallback: "/index.html", navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api\//, /^\/ca\.crt$/],
runtimeCaching: [ runtimeCaching: [
{ {
// Zuletzt geladene Geräte/Dienste/Kategorien bleiben offline verfügbar // Zuletzt geladene Geräte/Dienste/Kategorien bleiben offline verfügbar

View File

@@ -15,8 +15,17 @@ services:
- NODE_ENV=production - NODE_ENV=production
volumes: volumes:
- launchpad-data:/data - launchpad-data:/data
- ./apps/backend/plugins:/app/plugins:ro
ports: ports:
- "3001:3001" - "3001:3001"
# Für den optionalen, per masscan beschleunigten vollständigen Portscan
# (siehe scanner/masscan.ts) - masscan braucht Zugriff auf rohe Sockets.
# Ohne diese Capabilities funktioniert LaunchPad trotzdem ganz normal,
# der Portscan fällt dann automatisch auf die eingebaute (langsamere)
# Methode zurück - kein Muss, aber empfohlen für Tempo.
cap_add:
- NET_RAW
- NET_ADMIN
frontend: frontend:
build: build:
@@ -24,11 +33,23 @@ services:
dockerfile: apps/frontend/Dockerfile dockerfile: apps/frontend/Dockerfile
container_name: launchpad-frontend container_name: launchpad-frontend
restart: unless-stopped restart: unless-stopped
env_file:
- path: ./.env
required: false
depends_on: depends_on:
- backend - backend
ports: ports:
- "8080:80" - "8080:80"
- "8443:443"
volumes:
# Lokale Root-CA + Server-Zertifikat, vom Container selbst erzeugt
# (siehe apps/frontend/docker-entrypoint.sh). Als Volume, damit die CA
# über Neustarts/Rebuilds stabil bleibt - einmal pro Gerät importiert
# (https://<host>:8080/ca.crt), reicht dauerhaft.
- launchpad-certs:/etc/nginx/certs
volumes: volumes:
launchpad-data: launchpad-data:
name: launchpad-data name: launchpad-data
launchpad-certs:
name: launchpad-certs

View File

@@ -92,7 +92,250 @@ Grundgerüst aus Commit 1.
> Docker-Build-Simulation durchlaufen. Die tatsächliche "Zum Homescreen hinzufügen"- > Docker-Build-Simulation durchlaufen. Die tatsächliche "Zum Homescreen hinzufügen"-
> Installation konnte mangels Browser in dieser Umgebung nicht getestet werden. > Installation konnte mangels Browser in dieser Umgebung nicht getestet werden.
## Commit 8 — Plugin-System ## Commit 8 — Plugin-System (erledigt)
- Plugin-Schnittstelle zum Registrieren von Scannern, Import von Geräten, - Plugins liegen als Ordner unter `apps/backend/plugins/*`, jeweils mit einer
Erweitern von Menüs, Bereitstellen von Icons `index.js` (reines ESM, kein Build-Schritt). Werden beim Backend-Start
geladen (`apps/backend/src/plugins/loader.ts`) und per Docker-Volume
eingebunden neue Plugins brauchen nur einen Container-Neustart, kein
Image-Rebuild
- Plugin-Vertrag (`apps/backend/src/plugins/types.ts`): `setup(ctx)` zum
Registrieren zusätzlicher Softwareerkennung (inkl. Icon), `importDevices()`
für eigene Geräte-Importquellen
- Zwei funktionierende Beispiel-Plugins: `example-signatures` (Homebridge,
Uptime Kuma) und `static-import` (Geräte aus lokaler `devices.json`)
- `GET /api/plugins`, `POST /api/plugins/:name/import` Import läuft wie
Scans ausschließlich manuell per Knopfdruck
- Frontend-Plugins-Seite zeigt echte geladene Plugins mit Capabilities und
Import-Button (ersetzt den ehrlichen Platzhalter aus Commit 6)
- Von neuen Plugins erkannte Software liefert ein Icon, das wie `category`
seit Commit 5 nur beim erstmaligen Anlegen eines Dienstes als Startwert
übernommen wird, nie nachträglich überschrieben
> Verifiziert: beide Plugins laden nachweislich beim Start; ein echter Scan
> gegen einen Test-Server mit "Homebridge" im Response-Body wurde über die
> Plugin-Signatur erkannt, inkl. korrekt übernommenem Icon; Geräte-Import per
> Plugin getestet (`source: "plugin"`); Fehlerfälle (unbekanntes Plugin → 404,
> Plugin ohne Import-Fähigkeit → 400) geprüft; Docker-Build-Simulation
> bestanden, dabei auch verifiziert, dass ein fehlendes Plugin-Verzeichnis
> nicht zum Absturz führt, sondern nur zu einer leeren Liste.
> **Bewusst nicht umgesetzt:** eigene Admin-Routen/Menüpunkte pro Plugin
> (alle Plugins erscheinen gesammelt auf einer Seite) und Sandboxing
> (Plugin-Code läuft mit vollem Zugriff im Backend-Prozess nur Plugins aus
> vertrauenswürdiger Quelle einbinden, siehe `apps/backend/plugins/README.md`).
Damit ist die komplette in der ursprünglichen Projektübergabe beschriebene
Funktionalität umgesetzt.
## Bugfixes aus dem ersten Praxistest (nach Commit 8)
Basierend auf echtem Testen gegen ein reales Homelab (FritzBox + gescannte
Geräte):
- **Neues Feld `visible` auf Diensten.** Scan-Treffer mit Fehlerantworten
(404, 400, 302 als Titel) oder generischen Ladebildschirm-Titeln
("Loading...", der Scanner führt kein JavaScript aus) werden beim
erstmaligen Anlegen automatisch als `visible: false` markiert sie
erscheinen nicht mehr in der Suche, bleiben aber unter Admin -> Dienste
sichtbar, umschaltbar (👁️/🙈) und löschbar. Bestehende Datenbanken werden
beim Start automatisch migriert (`ALTER TABLE ... ADD COLUMN`), keine
Daten gehen verloren.
- **Hostname/URL/HTTPS sind jetzt im Adminbereich editierbar** (Dienste ->
Bearbeiten) und werden ab sofort bei einem erneuten Scan eines bereits
bekannten Dienstes **nicht mehr überschrieben** (vorher wurden diese Felder
bei jedem Scan aufgefrischt). Nur `favicon`/`description` werden weiterhin
aktualisiert. Achtung: Wird dabei der **Port** geändert, entsteht beim
nächsten Scan ein neuer Eintrag für den ursprünglichen Port, da die
Zuordnung über `deviceId + port` läuft.
- **Suchergebnisliste erscheint nur noch bei aktiver Eingabe** vorher war
sie auch bei leerem Suchfeld dauerhaft aufgeklappt.
- **Port wird jetzt überall mit angezeigt** (`hostname:port` statt nur
`hostname`), in der Suche wie im Adminbereich.
- **Admin-Sidebar ist jetzt echt fixiert** (`fixed` statt `static`) vorher
scrollte "Zurück zur Suche" bei langem Seiteninhalt aus dem Sichtfeld.
## Weitere Fixes (zweite Testrunde)
- **Reset-Button** unter Admin -> Einstellungen -> Gefahrenzone: löscht alle
Geräte und (per Cascade) alle Dienste unwiderruflich. Doppelte Bestätigung
im Frontend (`window.confirm`) plus eine Pflicht-Bestätigung auf API-Ebene
(`POST /api/reset` verlangt `{"confirm": true}`, sonst `400`). Kategorien
und Logs bleiben erhalten.
- **Port und Protokoll (http/https) als eigene Spalten** in der
Dienste-Tabelle im Adminbereich, nicht mehr nur implizit in der URL.
- **Favoriten-Leiste über der Suchleiste** auf der Startseite immer
sichtbar, unabhängig vom Suchfeld, sortiert nach der einstellbaren
Reihenfolge.
- **Dienste per Drag & Drop sortierbar** im Adminbereich (`PATCH
/api/services/reorder`), bestimmt sowohl die Reihenfolge in der
Favoriten-Leiste als auch bei gleichrangigen Suchtreffern.
- **Healthcheck durchgängig in der Admin-Sidebar sichtbar** (vorher nur auf
der Startseite und versteckt unter Einstellungen).
- **Kategorien-Synchronisierung:** Die `categories`-Tabelle war leer, obwohl
Dienste bereits Kategorien-Text trugen (z. B. aus der automatischen
Softwareerkennung) `services.category` ist reiner Freitext, kein
Fremdschlüssel. Jetzt: (1) beim Start werden alle bereits auf Diensten
vorhandenen Kategorienamen automatisch in die `categories`-Tabelle
nachgetragen, (2) jeder Scan legt neu erkannte Kategorien automatisch an,
(3) Umbenennen einer Kategorie im Adminbereich aktualisiert automatisch
alle Dienste mit dem alten Namen. Einschränkung: manuell per API angelegte
Dienste (nicht über einen Scan) lösen diesen Sync aktuell nicht aus.
## HTTPS im lokalen Netz
- Frontend-Container liefert jetzt zusätzlich zu HTTP (Port 8080) auch HTTPS
auf Port 8443 aus, kein erzwungener Redirect.
- Entrypoint-Skript (`apps/frontend/docker-entrypoint.sh`) erzeugt beim
Containerstart automatisch ein selbstsigniertes Zertifikat für die in
`.env` hinterlegte `LAUNCHPAD_HOST` (IP-SAN bei IP-Adressen, DNS-SAN bei
Hostnamen) übernimmt nichts, falls unter `/etc/nginx/certs/` bereits
eigene Zertifikate eingebunden sind (siehe README, z. B. per mkcert für
eine Variante ohne Browser-Warnung).
- `nginx.conf` in HTTP- und HTTPS-Serverblock aufgeteilt, gemeinsame
Location-Regeln in `nginx-locations.conf` ausgelagert (kein doppelt
gepflegter Proxy-/Cache-Code).
> Verifiziert: Zertifikatserzeugung für IP-, Hostname- und Fallback-Fall
> getestet, komplette nginx-Konfiguration mit einem echten nginx-Binary
> validiert (`nginx -t` erfolgreich), echter TLS-Handshake gegen den
> laufenden Server durchgeführt und geprüft, dass das ausgelieferte
> Zertifikat die korrekte IP als Subject Alternative Name trägt.
## HTTPS-Revision: eigene lokale Root-CA statt DuckDNS
Nutzer-Feedback: "Will nix externes, soll auch nur lokal laufen" die
DuckDNS/Let's-Encrypt-Lösung wurde daher wieder entfernt und durch eine
komplett offline funktionierende Variante ersetzt.
- `apps/frontend/docker-entrypoint.sh` erzeugt beim ersten Containerstart
eine eigene Root-CA (`LaunchPad Local CA`, `CA:TRUE`, 10 Jahre gültig) und
signiert damit ein Server-Zertifikat (`CA:FALSE`, korrekte SAN je nach
`LAUNCHPAD_HOST`) keinerlei externe Dienste beteiligt
- CA + Server-Zertifikat liegen im neuen Docker-Volume `launchpad-certs`
(statt im Container-Dateisystem) bleiben dadurch über Neustarts und
`--build` stabil. Ändert sich nur `LAUNCHPAD_HOST`, wird ausschließlich das
Server-Zertifikat neu ausgestellt, die CA (und damit bereits erteiltes
Gerätevertrauen) bleibt unangetastet
- Root-CA wird unter `http://<host>:8080/ca.crt` zum Download bereitgestellt
(`nginx-locations.conf`, korrekter MIME-Type `application/x-x509-ca-cert`
löst auf Android/iOS automatisch den nativen "Zertifikat installieren"-Dialog
aus) einziger verbleibender manueller Schritt ist der einmalige Import
pro Gerät, dafür gibt es ohne externe CA keine Alternative
- DuckDNS/`acme`-Service und zugehörige `.env`-Variablen vollständig entfernt
> Verifiziert: CA- und Leaf-Zertifikat-Erzeugung lokal mit echtem `openssl`
> durchgespielt (Zertifikatskette validiert erfolgreich gegen die CA,
> `CA:FALSE`/`CA:TRUE` korrekt gesetzt, SAN korrekt), Idempotenz geprüft
> (zweiter Lauf mit gleichem Host verändert nichts, geänderter Host erzeugt
> nur ein neues Server-Zertifikat, CA bleibt stabil), kompletter End-to-End-
> Test mit echtem nginx: `/ca.crt` per HTTP heruntergeladen und **damit ohne
> `-k`-Flag** eine echte HTTPS-Verbindung erfolgreich validiert simuliert
> exakt das Verhalten eines Geräts nach CA-Import.
## Dritte Feature-Runde: Lesezeichen, Import/Export, Favoriten im Frontend
- **Lesezeichen als eigene Entität** (neue Tabelle, eigenes Repository,
eigene Routen unter `/api/bookmarks`) nicht an ein Gerät gebunden, im
Unterschied zu Diensten. Titel + Favicon werden beim Anlegen automatisch
geladen (Wiederverwendung der Scanner-HTTP-Logik). Erscheinen zusammen mit
Diensten in der Suche (generisches `rankServices<T extends Rankable>` in
`packages/shared`), aber als eigene Favoriten-Gruppe auf der Startseite.
- **Import/Export für Dienste**: CSV, XLSX (echtes Excel, via `xlsx`-Paket)
und JSON, beide Richtungen. Import legt ausschließlich neue Dienste an
(Abgleich über Gerät+Port), bestehende werden nie überschrieben oder
verdoppelt; fehlende Geräte werden bei Bedarf automatisch angelegt.
- **Scan-Ergebnis zeigt jetzt die tatsächlichen Portnummern**, nicht nur
deren Anzahl (`ports: number[]` in der API-Antwort, Anzeige in der
Geräte-Tabelle).
- **Favoriten direkt im Frontend per Drag & Drop sortierbar** (nicht mehr
nur im Adminbereich), getrennte Gruppen für Dienste und Lesezeichen.
- **Favicon-Kontrast-Fix**: neue `Favicon`-Komponente mit immer hellem
Hintergrund, damit dunkle/schwarze Favicons nicht mit dem Dark-Mode-
Hintergrund verschmelzen.
- **Kategorie-Dropdown** im Bearbeiten-Formular (Dienste + Lesezeichen)
mit bestehenden Kategorien plus "+ Neue Kategorie …"-Option, statt freiem
Textfeld.
- **Sortierbare Spaltenköpfe** in der Dienste-Tabelle (Name, Kategorie,
Alias, Port, Protokoll). Bei aktiver Spaltensortierung ist Drag & Drop
vorübergehend deaktiviert (macht in dem Moment keinen Sinn), ein Klick auf
"zurück zur manuellen Reihenfolge" stellt den Drag-&-Drop-Modus wieder her.
> Verifiziert (Backend, alles per echtem HTTP-Roundtrip getestet): Lesezeichen
> anlegen mit automatischem Titel-/Favicon-Abruf gegen einen echten
> Testserver, automatisches Anlegen der Kategorie; CSV-, XLSX- und
> JSON-Export erzeugt (XLSX als `file`-Befehl gegengeprüft: "Microsoft Excel
> 2007+"); Import in allen drei Formaten getestet, insbesondere der
> kritische Fall "bestehender Dienst bleibt unverändert, neuer wird
> angelegt, keine Duplikate"; Bookmark- und Service-Reorder-Endpunkte
> getestet. Frontend: vollständiger `pnpm build` (inkl. `tsc --noEmit`)
> erfolgreich die eigentliche UI-Interaktion (Drag & Drop, Dropdown-
> Verhalten) konnte mangels Browser in dieser Umgebung nicht geklickt
> werden, nur durch Code-Review abgesichert.
## Vierte Feature-Runde: Bugfixes, Lesezeichen-Ausbau, Admin-Überarbeitung
**Bugfixes:**
- Dark-Mode wird jetzt in `main.tsx` sofort beim Start angewendet (vorher nur
wenn eine Seite `useTheme()` aufrief) behebt hellen Start bei Direktlink
auf `/admin/...`
- `color-scheme`-CSS-Property gesetzt native `<select>`-Dropdowns folgen
jetzt automatisch dem Theme (vorher weiße Schrift auf weißem Grund im Dark
Mode möglich)
- Suche findet jetzt auch Teiltreffer im Hostnamen (z. B. "47" findet
"192.168.1.47"), zusätzliche Ranking-Stufe in `rankService`
- `packages/ui` hatte keine DOM-Typen in der TS-Konfiguration (seit Commit 6)
`navigator.clipboard` für den neuen Copy-Button hat das aufgedeckt, jetzt
behoben
**Neue Backend-Entitäten:**
- Kategorie-Farbe (`color`-Spalte, Migration für Bestandsinstallationen)
- "Zuletzt besucht" (`recent_visits`-Tabelle, per Einstellung konfigurierbare
Anzahl)
- Key-Value-Einstellungen (`app_settings`-Tabelle)
- "Später lesen" (`read_later`-Tabelle, inkl. "zu Lesezeichen befördern")
- Lesezeichen: automatische Beschreibungs-Extraktion (Meta-Tag), Favicon jetzt
editierbar
- Kombinierter Geräte+Dienste-Import/Export (`/api/transfer/*`, ersetzt die
bisherigen `/api/services/export|import`) mit `type`-Spalte pro Zeile
- Scan-Reconciliation: `/api/scan/devices/:id` und `/api/scan/fritzbox` geben
jetzt zusätzlich `staleServices`/`staleDevices` zurück (Einträge, die beim
letzten Scan nicht mehr gefunden wurden) nichts wird automatisch
gelöscht, nur zur manuellen Durchsicht zurückgegeben
- CA-Zertifikat unterstützt jetzt zwei Hostnamen gleichzeitig
(`LAUNCHPAD_HOST` + optional `LAUNCHPAD_EXTRA_HOST`, z. B. IP + LXC-Name)
**Frontend Startseite:**
- Nur noch die Trefferliste scrollt auf Mobilgeräten (`h-dvh` + Flexbox-
Scroll-Trick), nicht mehr die ganze Seite
- Copy-Icon pro Suchtreffer (Zwischenablage)
- Kompaktere Kopfzeile, Favoriten jetzt reine Icon-Chips (Name als Tooltip)
- Favicons: heller Hintergrund + automatischer Fallback bei Ladefehlern
- Kategorie-Farbe als Punkt (Suche) bzw. Ring (Favoriten)
- "Später lesen"-Eingabefeld + "Zuletzt besucht"-Leiste unterhalb der Suche
- Favoriten direkt hier per Drag & Drop sortierbar (vorher nur im
Adminbereich)
**Frontend Adminbereich:**
- Einstellungen: CA-Download-Link, Import/Export (inkl. aufklappbarer
Format-Anleitung), Anzahl-Regler für "Zuletzt besucht"
- Dienste: Favicon in der Tabelle, neue MAC-Spalte (vom Gerät abgeleitet),
eigene sortierbare Host/IP-Spalte
- Geräte: MAC/Hersteller/Modell editierbar, alle Spalten sortierbar,
aufklappbare Dienste-Liste pro Gerät, "zuletzt gesehen"-Zeitstempel,
Stale-Service-Review direkt nach einem Scan (Behalten/Entfernen)
- Lesezeichen: sortierbare Spalten (gleiches Muster wie Dienste)
- Scanner: Stale-Device-Review nach FritzBox-Scan (Behalten/Löschen)
- Dashboard: Favoriten getrennt nach Diensten/Lesezeichen, neue
Lesezeichen-Kachel, "🏠 Startseite"-Schnellzugriff
> Verifiziert: vollständiger `pnpm build` (`tsc --noEmit` + `vite build`)
> nach jedem größeren Schritt erfolgreich; alle neuen Backend-Endpunkte
> (Kategorie-Farbe inkl. Validierung, Lesezeichen mit Auto-Beschreibung,
> Einstellungen, Zuletzt-besucht, Später-lesen inkl. Beförderung zu
> Lesezeichen, kombinierter Import/Export inkl. Idempotenz, Scan-
> Reconciliation) per echtem HTTP-Roundtrip getestet; finaler Smoke-Test
> bestätigt alle Routen nach dem vollständigen Umbau erreichbar. Die
> eigentliche UI-Interaktion (Klicks, Drag & Drop, Formulare) konnte mangels
> Browser in dieser Umgebung nicht getestet werden nur Build-Erfolg und
> Code-Review.

View File

@@ -5,6 +5,8 @@
* als auch vom Frontend (apps/frontend) verwendet werden. * als auch vom Frontend (apps/frontend) verwendet werden.
*/ */
import { deviceSourceValues } from "./schemas.js";
export * from "./schemas.js"; export * from "./schemas.js";
@@ -18,9 +20,11 @@ export interface Device {
online: boolean; online: boolean;
source: DeviceSource; source: DeviceSource;
lastScan: string | null; // ISO-8601 Zeitstempel lastScan: string | null; // ISO-8601 Zeitstempel
/** Zeitpunkt des letzten Ping-Live-Status-Checks, falls aktiviert (siehe Einstellungen). */
lastPing: string | null;
} }
export type DeviceSource = "fritzbox" | "dns" | "http" | "https" | "portscan" | "manual"; export type DeviceSource = (typeof deviceSourceValues)[number];
export interface Service { export interface Service {
id: string; id: string;
@@ -28,6 +32,8 @@ export interface Service {
displayName: string; displayName: string;
hostname: string; hostname: string;
url: string; url: string;
/** Zuletzt geändert (auch durch reinen Scan-Fund, siehe upsertServiceFromScan) - ISO-8601. */
updatedAt: string;
https: boolean; https: boolean;
port: number; port: number;
category: string | null; category: string | null;
@@ -37,6 +43,29 @@ export interface Service {
favorite: boolean; favorite: boolean;
alias: string[]; alias: string[];
order: number; order: number;
/** Ausgeblendete Dienste erscheinen nicht in der Suche, bleiben aber im Adminbereich sichtbar/löschbar. */
visible: boolean;
/**
* true, wenn Name/Kategorie zuletzt manuell über die Admin-UI gesetzt
* wurden (statt vom letzten Scan-Vorschlag übernommen). Ein Scan schlägt
* dann zwar intern weiterhin den automatisch erkannten Wert vor, meldet
* das aber NICHT mehr als zu bestätigende Änderung (siehe
* upsertServiceFromScan) - stattdessen zeigt die Admin-UI ein Hinweis-Icon.
*/
displayNameEditedManually: boolean;
categoryEditedManually: boolean;
/**
* Nicht vom Backend geliefert, sondern optional vom Frontend ergänzt (aus
* den bekannten Geräten), damit die Suche auch über den sprechenden
* Gerätenamen greift - `hostname` ist bei Diensten oft nur die IP, wenn
* keine DNS-Auflösung möglich war.
*/
deviceHostname?: string | null;
/**
* Ebenfalls nur frontend-seitig ergänzt - Online-Status des zugehörigen
* Geräts, für den kleinen Online/Offline-Punkt in der Trefferliste.
*/
deviceOnline?: boolean;
} }
export interface HealthStatus { export interface HealthStatus {
@@ -50,17 +79,107 @@ export interface Category {
id: string; id: string;
name: string; name: string;
order: number; order: number;
/** Hex-Farbe (z. B. "#3b82f6"), zeigt sich als Punkt in Suche/Favoriten. */
color: string | null;
} }
export interface ScanLogEntry { export interface ScanLogEntry {
id: string; id: string;
type: "device" | "fritzbox"; type: "device" | "fritzbox" | "api";
targetId: string | null; targetId: string | null;
level: "info" | "error"; level: "info" | "error";
message: string; message: string;
createdAt: string; createdAt: string;
} }
export interface PluginInfo {
name: string;
version: string;
description?: string;
/** z. B. "scanner" (registriert Softwareerkennung), "device-import" */
capabilities: string[];
}
/**
* Ein manuell angelegtes Lesezeichen im Unterschied zu Diensten nicht an
* ein gescanntes Gerät gebunden. Erscheint zusammen mit Diensten in der
* Suche, wird aber als eigene Entität verwaltet (siehe Admin -> Lesezeichen).
*/
export interface Bookmark {
id: string;
url: string;
displayName: string;
hostname: string; // aus der URL abgeleitet, für konsistentes Ranking
description: string | null;
category: string | null;
icon: string | null;
favicon: string | null;
favorite: boolean;
alias: string[];
order: number;
}
/** Gemeinsame Form, die Service und Bookmark fürs Ranking erfüllen. */
export interface Rankable {
displayName: string;
hostname: string;
alias: string[];
description: string | null;
favorite: boolean;
order: number;
/** Optional: sprechender Gerätename, zusätzlich zum technischen hostname-Feld durchsucht. */
deviceHostname?: string | null;
}
/**
* Synthetische Rankable-Form für Geräte in der Suche - Geräte haben keine
* eigene URL zum Öffnen (siehe DeviceSearchResult.kind==="device" in
* ResultsList: wird ausgegraut und nicht anklickbar dargestellt), sollen
* aber trotzdem über Name/IP/MAC auffindbar sein (IP/MAC landen in `alias`,
* dort durchsucht rankService bereits).
*/
export interface DeviceSearchResult {
kind: "device";
id: string;
displayName: string;
hostname: string;
ip: string;
mac: string | null;
online: boolean;
favicon: null;
category: null;
favorite: false;
order: number;
alias: string[];
description: string | null;
}
/** Synthetische Rankable-Form für Später-lesen-Einträge in der Suche. */
export interface ReadLaterSearchResult {
kind: "readlater";
id: string;
displayName: string;
hostname: string;
url: string;
favicon: string | null;
category: null;
favorite: false;
order: number;
alias: string[];
description: string | null;
}
/**
* Vereinheitlichte Suchtreffer-Form für die Trefferliste: Dienste,
* Lesezeichen, Geräte und Später-lesen-Einträge zusammen, aber
* unterscheidbar über `kind`.
*/
export type SearchResult =
| (Service & { kind: "service" })
| (Bookmark & { kind: "bookmark" })
| DeviceSearchResult
| ReadLaterSearchResult;
/** /**
* Ranking-Stufen für die Suche, gemäß Spezifikation: * Ranking-Stufen für die Suche, gemäß Spezifikation:
* 1. Displayname beginnt mit Suchtext * 1. Displayname beginnt mit Suchtext
@@ -68,50 +187,122 @@ export interface ScanLogEntry {
* 3. Hostname beginnt mit Suchtext * 3. Hostname beginnt mit Suchtext
* 4. Displayname enthält Suchtext * 4. Displayname enthält Suchtext
* 5. Alias enthält Suchtext * 5. Alias enthält Suchtext
* 6. Beschreibung enthält Suchtext * 6. Hostname enthält Suchtext (z. B. "47" findet "192.168.1.47")
* 7. Beschreibung enthält Suchtext
* *
* Niedrigere Werte sind relevanter. `null` bedeutet: kein Treffer. * Niedrigere Werte sind relevanter. `null` bedeutet: kein Treffer.
* Funktioniert generisch für alles, was die Rankable-Form erfüllt
* (Service, Bookmark).
*/ */
export function rankService(service: Service, query: string): number | null { export function rankService<T extends Rankable>(item: T, query: string): number | null {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
if (q.length === 0) return null; if (q.length === 0) return null;
const displayName = service.displayName.toLowerCase(); const displayName = item.displayName.toLowerCase();
const hostname = service.hostname.toLowerCase(); const hostname = item.hostname.toLowerCase();
const description = (service.description ?? "").toLowerCase(); const deviceHostname = (item.deviceHostname ?? "").toLowerCase();
const alias = service.alias.map((a) => a.toLowerCase()); const description = (item.description ?? "").toLowerCase();
const alias = item.alias.map((a) => a.toLowerCase());
if (displayName.startsWith(q)) return 1; if (displayName.startsWith(q)) return 1;
if (alias.some((a) => a.startsWith(q))) return 2; if (alias.some((a) => a.startsWith(q))) return 2;
if (hostname.startsWith(q)) return 3; if (hostname.startsWith(q) || deviceHostname.startsWith(q)) return 3;
if (displayName.includes(q)) return 4; if (displayName.includes(q)) return 4;
if (alias.some((a) => a.includes(q))) return 5; if (alias.some((a) => a.includes(q))) return 5;
if (description.includes(q)) return 6; if (hostname.includes(q) || deviceHostname.includes(q)) return 6;
if (description.includes(q)) return 7;
return null; return null;
} }
/** /**
* Sortiert und filtert eine Liste von Diensten anhand des Suchtexts. * Sortiert und filtert eine Liste (Dienste, Lesezeichen oder eine Mischung
* Favoriten werden bei gleichem Rang bevorzugt, danach die definierte Reihenfolge. * über einen gemeinsamen Union-Typ) anhand des Suchtexts. Favoriten werden
* bei gleichem Rang bevorzugt, danach die definierte Reihenfolge.
*/ */
export function rankServices(services: Service[], query: string): Service[] { export function rankServices<T extends Rankable>(items: T[], query: string): T[] {
const q = query.trim(); const q = query.trim();
if (q.length === 0) { if (q.length === 0) {
return [...services].sort((a, b) => { return [...items].sort((a, b) => {
if (a.favorite !== b.favorite) return a.favorite ? -1 : 1; if (a.favorite !== b.favorite) return a.favorite ? -1 : 1;
return a.order - b.order; return a.order - b.order;
}); });
} }
return services return items
.map((service) => ({ service, rank: rankService(service, q) })) .map((item) => ({ item, rank: rankService(item, q) }))
.filter((entry): entry is { service: Service; rank: number } => entry.rank !== null) .filter((entry): entry is { item: T; rank: number } => entry.rank !== null)
.sort((a, b) => { .sort((a, b) => {
if (a.rank !== b.rank) return a.rank - b.rank; if (a.rank !== b.rank) return a.rank - b.rank;
if (a.service.favorite !== b.service.favorite) return a.service.favorite ? -1 : 1; if (a.item.favorite !== b.item.favorite) return a.item.favorite ? -1 : 1;
return a.service.order - b.service.order; return a.item.order - b.item.order;
}) })
.map((entry) => entry.service); .map((entry) => entry.item);
}
/**
* Wie rankServices, aber für die Startseiten-Suche, die mehrere Arten von
* Treffern mischt (Dienste, Lesezeichen, Später-lesen, Geräte). Dienste
* stehen dabei IMMER vor allen anderen Arten, unabhängig von der
* Treffergüte - sie sind die eigentlichen "Ziele" der App. Geräte stehen
* immer zuletzt (sie sind nur informativ, nicht direkt anklickbar, siehe
* DeviceSearchResult). Lesezeichen/Später-lesen liegen dazwischen.
*/
export function rankSearchResults(items: SearchResult[], query: string): SearchResult[] {
const q = query.trim();
if (q.length === 0) return [];
function kindPriority(kind: SearchResult["kind"]): number {
if (kind === "service") return 0;
if (kind === "device") return 2;
return 1; // bookmark, readlater
}
return items
.map((item) => ({ item, rank: rankService(item, q) }))
.filter((entry): entry is { item: SearchResult; rank: number } => entry.rank !== null)
.sort((a, b) => {
const kindDiff = kindPriority(a.item.kind) - kindPriority(b.item.kind);
if (kindDiff !== 0) return kindDiff;
if (a.rank !== b.rank) return a.rank - b.rank;
if (a.item.favorite !== b.item.favorite) return a.item.favorite ? -1 : 1;
return a.item.order - b.item.order;
})
.map((entry) => entry.item);
}
/**
* Bekannte Domain-Fragmente -> naheliegende Kategorie, für den Kategorie-
* Vorschlag beim Anlegen eines Lesezeichens (siehe BookmarksPage.tsx). Rein
* heuristisch anhand des Hostnamens - keine Netzwerkabfrage, kein externer
* Dienst. Bewusst knapp gehalten: lieber gar kein Vorschlag als ein falscher.
*/
const BOOKMARK_CATEGORY_HINTS: Array<{ pattern: RegExp; category: string }> = [
{ pattern: /github|gitlab|gitea|bitbucket|stackoverflow|npmjs|developer\.mozilla/i, category: "Entwicklung" },
{ pattern: /youtube|netflix|twitch|spotify|disneyplus|primevideo/i, category: "Unterhaltung" },
{ pattern: /amazon|ebay|etsy|otto\.de|zalando/i, category: "Shopping" },
{ pattern: /wikipedia|wiktionary/i, category: "Wissen" },
{ pattern: /reddit|twitter|x\.com|instagram|facebook|mastodon|threads\.net/i, category: "Social Media" },
{ pattern: /paypal|sparkasse|bank|n26\.com|dkb\.de|ing\.de/i, category: "Finanzen" },
{ pattern: /docs\.google|notion\.so|trello|asana|slack\.com|confluence/i, category: "Produktivität" },
{ pattern: /heise\.de|golem\.de|tagesschau|spiegel\.de|zeit\.de|faz\.net/i, category: "Nachrichten" },
];
/**
* Liefert eine Kategorie-Vermutung anhand des Hostnamens einer URL, oder
* null wenn keine der bekannten Regeln zutrifft. Nur ein Vorschlag - wird nie
* automatisch übernommen (siehe BookmarksPage.tsx: nur als Chip zum
* Bestätigen angezeigt).
*/
export function suggestBookmarkCategory(url: string): string | null {
// Kein URL()-Konstruktor - der ist ein DOM-Global und in diesem Pakets
// tsconfig-"lib" nicht garantiert verfügbar (das Paket wird auch vom
// Backend importiert). Einfache manuelle Extraktion reicht hier völlig.
const withoutProtocol = url.replace(/^[a-z]+:\/\//i, "");
const hostname = withoutProtocol.split(/[/?#]/)[0] ?? withoutProtocol;
for (const { pattern, category } of BOOKMARK_CATEGORY_HINTS) {
if (pattern.test(hostname)) return category;
}
return null;
} }

View File

@@ -7,6 +7,7 @@ export const deviceSourceValues = [
"https", "https",
"portscan", "portscan",
"manual", "manual",
"plugin",
] as const; ] as const;
export const DeviceSourceSchema = z.enum(deviceSourceValues); export const DeviceSourceSchema = z.enum(deviceSourceValues);
@@ -39,16 +40,50 @@ export const ServiceCreateSchema = z.object({
favorite: z.boolean().optional(), favorite: z.boolean().optional(),
alias: z.array(z.string()).optional(), alias: z.array(z.string()).optional(),
order: z.number().optional(), order: z.number().optional(),
visible: z.boolean().optional(),
}); });
export type ServiceCreateInput = z.infer<typeof ServiceCreateSchema>; export type ServiceCreateInput = z.infer<typeof ServiceCreateSchema>;
export const ServiceUpdateSchema = ServiceCreateSchema.omit({ export const ServiceUpdateSchema = ServiceCreateSchema.omit({
deviceId: true, deviceId: true,
}).partial(); })
.partial()
.extend({
// Frontend schickt beim Leeren eines Felds bewusst null (nicht nur
// "Feld weglassen") - .partial() allein akzeptiert dafür nur
// string|undefined, ein PATCH mit category:null schlug daher bisher mit
// 400 fehl, ohne dass das Formular das sichtbar gemacht hätte.
category: z.string().nullable().optional(),
description: z.string().nullable().optional(),
icon: z.string().nullable().optional(),
favicon: z.string().nullable().optional(),
// Standardmäßig markiert ein PATCH mit displayName/category den jeweiligen
// Wert als "manuell bearbeitet" (siehe updateService-Doku in
// repositories/services.ts). Wird explizit false mitgeschickt - z. B. beim
// Übernehmen eines Scan-Vorschlags in der Änderungsübersicht -, bleibt der
// Wert stattdessen als "vom Scan" markiert.
displayNameEditedManually: z.boolean().optional(),
categoryEditedManually: z.boolean().optional(),
});
export type ServiceUpdateInput = z.infer<typeof ServiceUpdateSchema>; export type ServiceUpdateInput = z.infer<typeof ServiceUpdateSchema>;
/** Für Drag & Drop: neue Reihenfolge mehrerer Dienste auf einmal setzen. */
export const ServiceReorderSchema = z
.array(
z.object({
id: z.string().min(1),
order: z.number(),
})
)
.min(1, "mindestens ein Eintrag erforderlich");
export type ServiceReorderInput = z.infer<typeof ServiceReorderSchema>;
export const CategoryCreateSchema = z.object({ export const CategoryCreateSchema = z.object({
name: z.string().min(1, "name darf nicht leer sein"), name: z.string().min(1, "name darf nicht leer sein"),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, "Farbe muss ein Hex-Code sein, z. B. #3b82f6")
.optional(),
}); });
export type CategoryCreateInput = z.infer<typeof CategoryCreateSchema>; export type CategoryCreateInput = z.infer<typeof CategoryCreateSchema>;
@@ -65,3 +100,39 @@ export const CategoryReorderSchema = z
) )
.min(1, "mindestens ein Eintrag erforderlich"); .min(1, "mindestens ein Eintrag erforderlich");
export type CategoryReorderInput = z.infer<typeof CategoryReorderSchema>; export type CategoryReorderInput = z.infer<typeof CategoryReorderSchema>;
export const BookmarkCreateSchema = z.object({
url: z.string().url("url muss eine gültige URL sein"),
// Optional: wird nicht angegeben, versucht das Backend automatisch den
// Seitentitel zu lesen (siehe apps/backend/src/routes/bookmarks.ts).
displayName: z.string().min(1).optional(),
description: z.string().optional(),
category: z.string().optional(),
icon: z.string().optional(),
favicon: z.string().optional(),
favorite: z.boolean().optional(),
alias: z.array(z.string()).optional(),
order: z.number().optional(),
});
export type BookmarkCreateInput = z.infer<typeof BookmarkCreateSchema>;
export const BookmarkUpdateSchema = BookmarkCreateSchema.partial().extend({
// Siehe Kommentar bei ServiceUpdateSchema: null muss erlaubt sein, damit
// sich Kategorie/Beschreibung/Icons im Bearbeiten-Formular leeren lassen.
category: z.string().nullable().optional(),
description: z.string().nullable().optional(),
icon: z.string().nullable().optional(),
favicon: z.string().nullable().optional(),
});
export type BookmarkUpdateInput = z.infer<typeof BookmarkUpdateSchema>;
/** Für Drag & Drop: neue Reihenfolge mehrerer Lesezeichen auf einmal setzen. */
export const BookmarkReorderSchema = z
.array(
z.object({
id: z.string().min(1),
order: z.number(),
})
)
.min(1, "mindestens ein Eintrag erforderlich");
export type BookmarkReorderInput = z.infer<typeof BookmarkReorderSchema>;

View File

@@ -10,11 +10,17 @@
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "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/shared": "workspace:*",
"react": "^18.3.1" "react": "^18.3.1",
"react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"typescript": "^5.5.4" "typescript": "^5.5.4"
} }
} }

View File

@@ -2,7 +2,7 @@ import { forwardRef, type ButtonHTMLAttributes } from "react";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost"; variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md"; size?: "sm" | "md" | "icon";
} }
const VARIANT_CLASSES: Record<NonNullable<ButtonProps["variant"]>, string> = { const VARIANT_CLASSES: Record<NonNullable<ButtonProps["variant"]>, string> = {
@@ -18,6 +18,9 @@ const VARIANT_CLASSES: Record<NonNullable<ButtonProps["variant"]>, string> = {
const SIZE_CLASSES: Record<NonNullable<ButtonProps["size"]>, string> = { const SIZE_CLASSES: Record<NonNullable<ButtonProps["size"]>, string> = {
sm: "px-3 py-1.5 text-sm", sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-sm", md: "px-4 py-2 text-sm",
// Für Icon-only-Buttons (Zeilenaktionen in Tabellen) - immer mit title=""
// für ein natives Browser-Tooltip verwenden, da kein sichtbarer Text da ist.
icon: "h-8 w-8 p-0 text-base leading-none",
}; };
export const Button = forwardRef<HTMLButtonElement, ButtonProps>( export const Button = forwardRef<HTMLButtonElement, ButtonProps>(

View File

@@ -0,0 +1,69 @@
import { useState, useEffect } from "react";
export interface FaviconProps {
src?: string | null;
fallbackLetter: string;
size?: "sm" | "md" | "lg";
}
const SIZE_CLASSES: Record<NonNullable<FaviconProps["size"]>, string> = {
sm: "h-4 w-4",
md: "h-5 w-5",
lg: "h-7 w-7",
};
/**
* Zeigt ein Favicon mit einem immer hellen Hintergrund (unabhängig vom
* Dark/Light-Theme der App) viele Favicons sind selbst dunkel/schwarz und
* wären auf dunklem Hintergrund sonst kaum zu erkennen. Ohne Favicon oder bei
* einem fehlgeschlagenen Ladeversuch (kaputte URL, 404, CORS) wird
* stattdessen der erste Buchstabe des Namens gezeigt.
*/
/**
* Externe/Geräte-Favicons laufen über den Backend-Proxy (/api/favicon-proxy):
* Ohne das würden auf der HTTPS-Seite viele http://-Favicons (typisch für
* Homelab-Geräte ohne eigenes TLS) vom Browser als "Mixed Content" blockiert
* und nie angezeigt - das Backend selbst fetcht sie ohne diese Einschränkung.
* Data-URLs (z. B. selbst hochgeladene Icons) werden direkt durchgereicht.
*/
function resolveFaviconSrc(src: string): string {
if (src.startsWith("data:")) return src;
return `/api/favicon-proxy?url=${encodeURIComponent(src)}`;
}
export function Favicon({ src, fallbackLetter, size = "md" }: FaviconProps) {
const dimension = SIZE_CLASSES[size];
const [failed, setFailed] = useState(false);
// Bei geändertem src (z. B. anderer Listeneintrag durch Virtualisierung/Key-Wiederverwendung)
// erneut versuchen statt dauerhaft im Fehlerzustand zu bleiben.
useEffect(() => {
setFailed(false);
}, [src]);
if (!src || failed) {
return (
<span
className={`flex ${dimension} shrink-0 items-center justify-center rounded
bg-black/10 text-[10px] font-medium text-black/50 dark:bg-white/10
dark:text-white/50`}
>
{fallbackLetter.charAt(0).toUpperCase()}
</span>
);
}
return (
<span
className={`flex ${dimension} shrink-0 items-center justify-center rounded
bg-white p-0.5 ring-1 ring-black/5`}
>
<img
src={resolveFaviconSrc(src)}
alt=""
className="h-full w-full object-contain"
onError={() => setFailed(true)}
/>
</span>
);
}

View File

@@ -0,0 +1,272 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Favicon } from "./Favicon.js";
export interface FaviconPickerProps {
/** Bereits im System verwendete Favicons: [url, Anzeigename-für-Tooltip][], dedupliziert. */
existingFavicons: [string, string][];
onSelect: (url: string) => void;
}
interface IconDbResult {
name: string;
url: string;
}
function resolvableSrc(src: string): string {
if (src.startsWith("data:")) return src;
return `/api/favicon-proxy?url=${encodeURIComponent(src)}`;
}
/**
* Lädt eine Favicon-URL tatsächlich, prüft per createImageBitmap, ob es sich
* WIRKLICH um ein dekodierbares Bild handelt (nicht nur einen HTTP-200 mit
* z. B. einer HTML-Fehlerseite als Inhalt), und bildet bei Erfolg einen
* Inhalts-Hash. Liefert null, wenn das Laden oder Dekodieren fehlschlägt.
* Der Hash dient zum Deduplizieren visuell identischer Favicons, die unter
* unterschiedlichen URLs liegen (z. B. mehrere FritzBox-Mesh-Geräte, die
* alle dasselbe generische AVM-Icon ausliefern) - ein reiner URL-Vergleich
* würde solche Duplikate nicht erkennen. Ein bloßer fetch-Erfolg reicht
* NICHT als Bild-Bestätigung: manche Geräte antworten mit HTTP 200 und einer
* Login-/Fehlerseite statt eines echten Bildes, was sonst als Treffer
* durchgerutscht wäre und im Browser als Buchstaben-Fallback endete.
*/
async function loadAndHash(src: string): Promise<string | null> {
try {
const res = await fetch(resolvableSrc(src));
if (!res.ok) return null;
const buffer = await res.arrayBuffer();
if (buffer.byteLength === 0) return null;
try {
const bitmap = await createImageBitmap(new Blob([buffer]));
bitmap.close();
} catch {
return null; // kein echtes, dekodierbares Bild
}
const digest = await crypto.subtle.digest("SHA-256", buffer);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
} catch {
return null;
}
}
interface NamedIcon {
url: string;
name: string;
}
/** Filtert eine Liste von {url,name}-Objekten auf tatsächlich ladbare Bilder
* und dedupliziert dabei nach Bildinhalt (nicht nur nach URL). */
function useLoadableOnly(items: NamedIcon[]): NamedIcon[] {
const [loadable, setLoadable] = useState<NamedIcon[]>([]);
const key = items.map((i) => i.url).join("|");
useEffect(() => {
let cancelled = false;
setLoadable([]);
(async () => {
const seenHashes = new Set<string>();
const results: NamedIcon[] = [];
for (const item of items) {
const hash = await loadAndHash(item.url);
if (!hash || seenHashes.has(hash)) continue;
seenHashes.add(hash);
results.push(item);
}
if (!cancelled) setLoadable(results);
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
return loadable;
}
/**
* Größere Bild-Vorschau beim Hover, über ein Portal direkt in document.body
* gerendert (nicht innerhalb des scrollenden Picker-Containers) - sonst
* würde sie vom umgebenden overflow-y-auto abgeschnitten, sobald die
* gehoverte Kachel nah am oberen/unteren Rand der Liste liegt.
*/
function HoverPreview({ url, name, anchorRect }: { url: string; name: string; anchorRect: DOMRect }) {
const width = 176;
const left = Math.min(
Math.max(anchorRect.left + anchorRect.width / 2 - width / 2, 8),
window.innerWidth - width - 8
);
const top = anchorRect.top - 8;
return createPortal(
<div
style={{ position: "fixed", left, top, width, transform: "translateY(-100%)" }}
className="pointer-events-none z-50 rounded-xl border border-black/10 bg-white p-2 text-center
shadow-xl dark:border-white/10 dark:bg-neutral-800"
>
<span className="mx-auto flex h-14 w-14 items-center justify-center rounded-lg bg-white p-1
ring-1 ring-black/5">
<img src={resolvableSrc(url)} alt="" className="h-full w-full object-contain" />
</span>
<span className="mt-1 block truncate text-xs text-black/70 dark:text-white/70">{name}</span>
</div>,
document.body
);
}
function IconTile({ url, name, onSelect }: { url: string; name: string; onSelect: (url: string) => void }) {
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
return (
<>
{anchorRect ? <HoverPreview url={url} name={name} anchorRect={anchorRect} /> : null}
<button
ref={buttonRef}
type="button"
onMouseEnter={() => setAnchorRect(buttonRef.current?.getBoundingClientRect() ?? null)}
onMouseLeave={() => setAnchorRect(null)}
onClick={() => onSelect(url)}
className="rounded-lg p-1 hover:bg-black/5 dark:hover:bg-white/10"
>
<Favicon src={url} fallbackLetter={name} size="lg" />
</button>
</>
);
}
/**
* Ein Button, der einen Picker öffnet, der ZWEI Favicon-Quellen kombiniert:
* bereits im System verwendete Favicons (z. B. von anderen Diensten) und
* eine Live-Suche in der externen Icon-Datenbank (homarr-labs/dashboard-
* icons, kuratierte Icons für Selfhosted-Software). Bewusst EIN Button statt
* zwei getrennter, damit die Bearbeiten-Formulare nicht mit noch einem
* weiteren Button überladen werden.
*
* Beide Listen werden vor der Anzeige tatsächlich geladen UND als Bild
* dekodiert geprüft (siehe loadAndHash) - nur echte, funktionierende Icons
* erscheinen. Eine URL, die nur plausibel aussieht aber ins Leere läuft oder
* keine echte Bilddatei liefert, würde sonst als Buchstaben-Fallback
* auftauchen und die Auswahl unnötig vollstopfen.
*/
export function FaviconPicker({ existingFavicons, onSelect }: FaviconPickerProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [rawDbResults, setRawDbResults] = useState<IconDbResult[]>([]);
const [searching, setSearching] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function onPointerDown(e: MouseEvent) {
if (!containerRef.current?.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, [open]);
useEffect(() => {
const q = query.trim();
if (q.length < 2) {
setRawDbResults([]);
return;
}
setSearching(true);
const handle = setTimeout(() => {
fetch(`/api/icons/search?q=${encodeURIComponent(q)}`)
.then((res) => (res.ok ? res.json() : { results: [] }))
.then((body) => setRawDbResults(body.results ?? []))
.catch(() => setRawDbResults([]))
.finally(() => setSearching(false));
}, 300);
return () => clearTimeout(handle);
}, [query]);
const existingAsResults = existingFavicons.map(([url, name]) => ({ url, name }));
const loadableExisting = useLoadableOnly(existingAsResults);
const loadableDbResults = useLoadableOnly(rawDbResults);
const showingSearch = query.trim().length >= 2;
return (
<div ref={containerRef} className="relative inline-block">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="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"
>
Favicon wählen
</button>
{open ? (
<div
className="absolute left-0 top-full z-20 mt-1 w-80 rounded-xl border border-black/10
bg-white p-2 shadow-lg dark:border-white/10 dark:bg-neutral-900"
>
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Icon-Datenbank durchsuchen … z. B. „plex“"
className="mb-2 w-full rounded-lg border border-black/10 bg-white px-2 py-1 text-xs
text-black outline-none placeholder:text-black/30 focus:border-black/30
dark:border-white/10 dark:bg-white/10 dark:text-white dark:placeholder:text-white/30"
/>
<div className="max-h-56 overflow-y-auto">
{showingSearch ? (
searching ? (
<p className="p-2 text-xs text-black/40 dark:text-white/40">Suche </p>
) : loadableDbResults.length > 0 ? (
<div className="flex flex-wrap gap-2">
{loadableDbResults.map((r) => (
<IconTile
key={r.url}
url={r.url}
name={r.name}
onSelect={(url) => {
onSelect(url);
setOpen(false);
setQuery("");
}}
/>
))}
</div>
) : (
<p className="p-2 text-xs text-black/40 dark:text-white/40">Keine Treffer.</p>
)
) : loadableExisting.length > 0 ? (
<>
<p className="mb-1 px-1 text-[10px] font-medium uppercase tracking-wide text-black/30 dark:text-white/30">
Bereits im System verwendet
</p>
<div className="flex flex-wrap gap-2">
{loadableExisting.map((r) => (
<IconTile
key={r.url}
url={r.url}
name={r.name}
onSelect={(url) => {
onSelect(url);
setOpen(false);
}}
/>
))}
</div>
</>
) : (
<p className="p-2 text-xs text-black/40 dark:text-white/40">
Noch keine anderen Favicons vorhanden. Tippe oben, um die Icon-Datenbank zu durchsuchen.
</p>
)}
</div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,120 @@
import { useState, type DragEvent } from "react";
import { Favicon } from "./Favicon.js";
export interface FavoriteItem {
id: string;
displayName: string;
favicon: string | null;
hostname: string;
port?: number;
category?: string | null;
}
export interface FavoritesBarProps {
items: FavoriteItem[];
label?: string;
/** Kategoriename -> Hex-Farbe, zeigt sich als Ring um das Icon. */
categoryColors?: Record<string, string>;
onOpen: (item: FavoriteItem) => void;
/** Wenn gesetzt, sind die Kacheln per Drag & Drop sortierbar. */
onReorder?: (orderedIds: string[]) => void;
}
/**
* Zeigt Einträge als Icon-Kacheln mit sichtbarer Beschriftung darunter
* (Launchpad-/Homescreen-Stil) statt als reine Icon-Chips mit Tooltip -
* lesbarer und wirkt strukturierter, wenn mehrere Gruppen (Favoriten,
* Zuletzt besucht, Später lesen) auf derselben Seite stehen. Anklickbar,
* per Drag & Drop sortierbar. Dienste und Lesezeichen werden über getrennte
* FavoritesBar-Instanzen gerendert (siehe HomePage), daher rein generisch
* über FavoriteItem statt fest an Service gebunden.
*/
export function FavoritesBar({
items,
label,
categoryColors,
onOpen,
onReorder,
}: FavoritesBarProps) {
const [draggedId, setDraggedId] = useState<string | null>(null);
const [localOrder, setLocalOrder] = useState<FavoriteItem[] | null>(null);
if (items.length === 0) return null;
const list = localOrder ?? items;
function handleDragStart(id: string) {
return (_e: DragEvent<HTMLButtonElement>) => setDraggedId(id);
}
function handleDragOver(targetId: string) {
return (e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!draggedId || draggedId === targetId) return;
const current = localOrder ?? items;
const fromIndex = current.findIndex((i) => i.id === draggedId);
const toIndex = current.findIndex((i) => i.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(e: DragEvent<HTMLButtonElement>) {
e.preventDefault();
setDraggedId(null);
const current = localOrder ?? items;
onReorder?.(current.map((i) => i.id));
}
return (
<div>
{label ? (
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-black/35 dark:text-white/35">
{label}
</div>
) : null}
<div role="toolbar" aria-label={label ?? "Favoriten"} className="flex flex-wrap gap-1.5">
{list.map((item) => {
const ringColor = item.category ? categoryColors?.[item.category] : undefined;
return (
<button
key={item.id}
type="button"
draggable={!!onReorder}
onDragStart={handleDragStart(item.id)}
onDragOver={handleDragOver(item.id)}
onDrop={handleDrop}
onClick={() => onOpen(item)}
title={
item.port !== undefined
? `${item.displayName} (${item.hostname}:${item.port})`
: `${item.displayName} (${item.hostname})`
}
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] ${
onReorder ? "cursor-grab active:cursor-grabbing" : ""
} ${draggedId === item.id ? "opacity-40" : ""}`}
>
<span
style={ringColor ? { boxShadow: `0 0 0 2px ${ringColor}` } : undefined}
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>
</div>
);
}

View File

@@ -1,32 +1,74 @@
import type { KeyboardEvent } from "react"; import { useState, type KeyboardEvent } from "react";
import type { Service } from "@launchpad/shared"; import type { SearchResult } from "@launchpad/shared";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faStar as faStarSolid, faCheck, faClone } from "@fortawesome/free-solid-svg-icons";
import { faStar } from "@fortawesome/free-regular-svg-icons";
import { Favicon } from "./Favicon.js";
export interface ResultsListProps { function hexToRgba(hex: string, alpha: number): string | null {
services: Service[]; const clean = hex.replace("#", "");
selectedIndex: number; if (clean.length !== 6) return null;
emptyLabel?: string; const r = parseInt(clean.slice(0, 2), 16);
onHover: (index: number) => void; const g = parseInt(clean.slice(2, 4), 16);
onOpen: (service: Service) => void; const b = parseInt(clean.slice(4, 6), 16);
onToggleFavorite?: (service: Service) => void; if ([r, g, b].some((n) => Number.isNaN(n))) return null;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
} }
export interface ResultsListProps {
results: SearchResult[];
selectedIndex: number;
emptyLabel?: string;
/** Kategoriename -> Hex-Farbe, für den kleinen Farbpunkt neben dem Kategorie-Badge. */
categoryColors?: Record<string, string>;
onHover: (index: number) => void;
onOpen: (item: SearchResult) => void;
onToggleFavorite?: (item: SearchResult) => void;
}
function OnlineDot({ online }: { online: boolean }) {
return (
<span
title={online ? "Online" : "Offline"}
className={`h-1.5 w-1.5 shrink-0 rounded-full ${online ? "bg-emerald-500" : "bg-black/20 dark:bg-white/20"}`}
/>
);
}
const KIND_BADGE: Record<Exclude<SearchResult["kind"], "service">, string> = {
bookmark: "Lesezeichen",
readlater: "Später lesen",
device: "Gerät",
};
/** /**
* Zeigt die (bereits per rankServices sortierten) Suchtreffer an. * Zeigt die (bereits per rankSearchResults sortierten) Suchtreffer an
* Dienste, Lesezeichen, Später-lesen und Geräte gemeinsam, unterscheidbar an
* einem kleinen Badge. Geräte haben keine eigene URL zum Öffnen und werden
* deshalb ausgegraut und nicht anklickbar dargestellt (nur zum Auffinden per
* Name/IP/MAC, siehe rankSearchResults) - Dienste und Geräte zeigen
* zusätzlich einen Online/Offline-Punkt.
*
* Die Tastatur-Navigation (Pfeiltasten/Enter) wird vom Elternelement * Die Tastatur-Navigation (Pfeiltasten/Enter) wird vom Elternelement
* gesteuert; diese Komponente ist rein darstellend + klick-/tastaturbar. * gesteuert; diese Komponente ist rein darstellend + klick-/tastaturbar.
*
* Füllt die Höhe des Elternelements aus (h-full) und scrollt selbst intern
* das Elternelement muss dafür `flex-1 min-h-0` sein (klassischer
* Flexbox-Scroll-Trick), damit nur die Liste scrollt und nicht die ganze Seite.
*/ */
export function ResultsList({ export function ResultsList({
services, results,
selectedIndex, selectedIndex,
emptyLabel = "Keine Dienste gefunden.", emptyLabel = "Keine Treffer gefunden.",
categoryColors,
onHover, onHover,
onOpen, onOpen,
onToggleFavorite, onToggleFavorite,
}: ResultsListProps) { }: ResultsListProps) {
if (services.length === 0) { if (results.length === 0) {
return ( return (
<div <div
className="mt-4 rounded-2xl border border-black/5 bg-white/50 px-5 py-8 text-center className="rounded-2xl border border-black/5 bg-white/50 px-5 py-8 text-center
text-sm text-black/40 dark:border-white/5 dark:bg-white/5 dark:text-white/40" text-sm text-black/40 dark:border-white/5 dark:bg-white/5 dark:text-white/40"
> >
{emptyLabel} {emptyLabel}
@@ -37,74 +79,100 @@ export function ResultsList({
return ( return (
<ul <ul
role="listbox" role="listbox"
className="mt-4 flex max-h-[60vh] flex-col overflow-y-auto rounded-2xl border className="flex max-h-full flex-col overflow-y-auto rounded-2xl border border-black/10
border-black/10 bg-white/80 shadow-lg backdrop-blur-md dark:border-white/10 bg-white/80 shadow-lg backdrop-blur-md dark:border-white/10 dark:bg-white/5"
dark:bg-white/5"
> >
{services.map((service, index) => { {results.map((item, index) => {
const active = index === selectedIndex; const active = index === selectedIndex;
const isDevice = item.kind === "device";
const subtitle =
item.kind === "service"
? `${item.hostname}:${item.port}`
: item.kind === "device"
? item.ip
: item.hostname;
const categoryColor = item.category ? categoryColors?.[item.category] : undefined;
const online = item.kind === "device" ? item.online : item.kind === "service" ? item.deviceOnline : undefined;
return ( return (
<li key={service.id} role="option" aria-selected={active}> <li key={`${item.kind}-${item.id}`} role="option" aria-selected={active}>
<div <div
tabIndex={-1} tabIndex={-1}
onMouseEnter={() => onHover(index)} onMouseEnter={() => onHover(index)}
onClick={() => onOpen(service)} onClick={() => !isDevice && onOpen(item)}
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => { onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter") onOpen(service); if (e.key === "Enter" && !isDevice) onOpen(item);
}} }}
className={`flex w-full cursor-pointer items-center gap-3 px-5 py-3 text-left style={
transition-colors ${ categoryColor && !isDevice
active ? { backgroundColor: hexToRgba(categoryColor, active ? 0.18 : 0.1) ?? undefined }
: undefined
}
className={`flex w-full items-center gap-3 px-5 py-3 text-left transition-colors ${
isDevice
? "cursor-default opacity-50"
: `cursor-pointer ${
categoryColor
? ""
: active
? "bg-black/5 dark:bg-white/10" ? "bg-black/5 dark:bg-white/10"
: "hover:bg-black/[0.03] dark:hover:bg-white/5" : "hover:bg-black/[0.03] dark:hover:bg-white/5"
}`
}`} }`}
> >
{service.favicon ? ( <Favicon src={item.favicon} fallbackLetter={item.displayName} />
<img src={service.favicon} alt="" className="h-5 w-5 shrink-0 rounded" />
) : (
<span
className="flex h-5 w-5 shrink-0 items-center justify-center rounded
bg-black/10 text-[10px] font-medium text-black/50 dark:bg-white/10
dark:text-white/50"
>
{service.displayName.charAt(0).toUpperCase()}
</span>
)}
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
{online !== undefined ? <OnlineDot online={online} /> : null}
<span className="block truncate text-sm font-medium text-black dark:text-white"> <span className="block truncate text-sm font-medium text-black dark:text-white">
{service.displayName} {item.displayName}
</span>
{item.kind !== "service" ? (
<span className="shrink-0 rounded-full bg-black/5 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-black/40 dark:bg-white/10 dark:text-white/40">
{KIND_BADGE[item.kind]}
</span>
) : null}
</span> </span>
<span className="block truncate text-xs text-black/40 dark:text-white/40"> <span className="block truncate text-xs text-black/40 dark:text-white/40">
{service.hostname} {subtitle}
</span> </span>
</span> </span>
{service.category ? ( {item.category ? (
<span className="shrink-0 text-xs text-black/30 dark:text-white/30"> <span className="flex shrink-0 items-center gap-1 text-xs text-black/30 dark:text-white/30">
{service.category} {categoryColor ? (
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: categoryColor }}
aria-hidden
/>
) : null}
{item.category}
</span> </span>
) : null} ) : null}
{!isDevice ? <CopyButton url={item.url} /> : null}
{item.kind === "service" || item.kind === "bookmark" ? (
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onToggleFavorite?.(service); onToggleFavorite?.(item);
}} }}
disabled={!onToggleFavorite} disabled={!onToggleFavorite}
aria-pressed={service.favorite} aria-pressed={item.favorite}
aria-label={ aria-label={item.favorite ? "Als Favorit entfernen" : "Als Favorit markieren"}
service.favorite ? "Als Favorit entfernen" : "Als Favorit markieren"
}
className={`shrink-0 text-lg leading-none transition-colors ${ className={`shrink-0 text-lg leading-none transition-colors ${
service.favorite item.favorite
? "text-amber-500" ? "text-amber-500"
: "text-black/15 hover:text-amber-400 dark:text-white/15 dark:hover:text-amber-400" : "text-black/15 hover:text-amber-400 dark:text-white/15 dark:hover:text-amber-400"
} ${onToggleFavorite ? "" : "cursor-default"}`} } ${onToggleFavorite ? "" : "cursor-default"}`}
> >
<FontAwesomeIcon icon={item.favorite ? faStarSolid : faStar} />
</button> </button>
) : null}
</div> </div>
</li> </li>
); );
@@ -112,3 +180,31 @@ export function ResultsList({
</ul> </ul>
); );
} }
function CopyButton({ url }: { url: string }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard
.writeText(url)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
})
.catch(() => {
/* Zwischenablage evtl. ohne Berechtigung (z. B. unsicherer Kontext) - stillschweigend ignorieren */
});
}}
aria-label="Adresse kopieren"
title={copied ? "Kopiert!" : "Adresse kopieren"}
className="shrink-0 text-sm leading-none text-black/20 transition-colors hover:text-black/60
dark:text-white/20 dark:hover:text-white/60"
>
<FontAwesomeIcon icon={copied ? faCheck : faClone} />
</button>
);
}

View File

@@ -3,6 +3,8 @@ import { forwardRef, type InputHTMLAttributes } from "react";
export interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> { export interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
/** Wird links im Suchfeld angezeigt, z. B. ein Tastaturkürzel-Hinweis. */ /** Wird links im Suchfeld angezeigt, z. B. ein Tastaturkürzel-Hinweis. */
hint?: string; hint?: string;
/** Zeigt ein X zum Leeren des Felds, sobald Text eingegeben wurde. */
onClear?: () => void;
} }
/** /**
@@ -10,7 +12,9 @@ export interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElem
* Bewusst schlicht gehalten: großer Text, viel Weißraum, keine Ablenkung. * Bewusst schlicht gehalten: großer Text, viel Weißraum, keine Ablenkung.
*/ */
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>( export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
({ hint, className = "", ...props }, ref) => { ({ hint, onClear, className = "", ...props }, ref) => {
const hasValue = typeof props.value === "string" && props.value.length > 0;
return ( return (
<div <div
className={`flex items-center gap-3 rounded-2xl border border-black/10 bg-white/80 className={`flex items-center gap-3 rounded-2xl border border-black/10 bg-white/80
@@ -40,6 +44,30 @@ export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
dark:text-white dark:placeholder:text-white/30" dark:text-white dark:placeholder:text-white/30"
{...props} {...props}
/> />
{hasValue && onClear ? (
<button
type="button"
onClick={onClear}
aria-label="Suche leeren"
className="shrink-0 rounded-full p-1 text-black/30 transition-colors
hover:bg-black/5 hover:text-black/60 dark:text-white/30
dark:hover:bg-white/10 dark:hover:text-white/60"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
) : null}
{hint ? ( {hint ? (
<span className="shrink-0 rounded-md border border-black/10 px-1.5 py-0.5 text-xs <span className="shrink-0 rounded-md border border-black/10 px-1.5 py-0.5 text-xs
text-black/40 dark:border-white/10 dark:text-white/40"> text-black/40 dark:border-white/10 dark:text-white/40">

View File

@@ -9,3 +9,12 @@ export type { ResultsListProps } from "./ResultsList.js";
export { Button } from "./Button.js"; export { Button } from "./Button.js";
export type { ButtonProps } from "./Button.js"; export type { ButtonProps } from "./Button.js";
export { FavoritesBar } from "./FavoritesBar.js";
export type { FavoritesBarProps } from "./FavoritesBar.js";
export { Favicon } from "./Favicon.js";
export type { FaviconProps } from "./Favicon.js";
export { FaviconPicker } from "./FaviconPicker.js";
export type { FaviconPickerProps } from "./FaviconPicker.js";

View File

@@ -5,7 +5,8 @@
"rootDir": "src", "rootDir": "src",
"jsx": "react-jsx", "jsx": "react-jsx",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler" "moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
}, },
"include": ["src"] "include": ["src"]
} }

132
pnpm-lock.yaml generated
View File

@@ -32,6 +32,9 @@ importers:
fastify: fastify:
specifier: ^4.28.1 specifier: ^4.28.1
version: 4.29.1 version: 4.29.1
xlsx:
specifier: ^0.18.5
version: 0.18.5
devDependencies: devDependencies:
'@types/better-sqlite3': '@types/better-sqlite3':
specifier: ^7.6.11 specifier: ^7.6.11
@@ -84,10 +87,10 @@ importers:
version: 4.7.0(vite@5.4.21(@types/node@20.19.43)(terser@5.49.0)) version: 4.7.0(vite@5.4.21(@types/node@20.19.43)(terser@5.49.0))
autoprefixer: autoprefixer:
specifier: ^10.4.20 specifier: ^10.4.20
version: 10.5.4(postcss@8.5.19) version: 10.5.4(postcss@8.5.20)
postcss: postcss:
specifier: ^8.4.41 specifier: ^8.4.41
version: 8.5.19 version: 8.5.20
tailwindcss: tailwindcss:
specifier: ^3.4.10 specifier: ^3.4.10
version: 3.4.19(tsx@4.23.1) version: 3.4.19(tsx@4.23.1)
@@ -1549,6 +1552,10 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
ajv-formats@2.1.1: ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies: peerDependencies:
@@ -1702,6 +1709,10 @@ packages:
caniuse-lite@1.0.30001806: caniuse-lite@1.0.30001806:
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
cfb@1.2.2:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
chokidar@3.6.0: chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'} engines: {node: '>= 8.10.0'}
@@ -1709,6 +1720,10 @@ packages:
chownr@1.1.4: chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
colorette@2.0.20: colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
@@ -1736,6 +1751,11 @@ packages:
core-js-compat@3.49.0: core-js-compat@3.49.0:
resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==}
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -2026,11 +2046,11 @@ packages:
fast-safe-stringify@2.1.1: fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
fast-uri@2.4.2: fast-uri@2.4.3:
resolution: {integrity: sha512-Ll1wlF3LBJ2+vFEeTSH9SFrjnXJorZQexrn0yHa4BJdGS+FFkWW3xU/YuIdmdyloSDuUgTYh/YeY/vUNcdCS/g==} resolution: {integrity: sha512-8V8UrSDUkYpi4AXM7Na0G6hctXSaRHBGMuANOotuFdHEFtTdqDTRNfcDczA9WkKODI17o7o10iQvzdMIxXb8eA==}
fast-uri@3.1.3: fast-uri@3.1.4:
resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==}
fastify-plugin@4.5.1: fastify-plugin@4.5.1:
resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==}
@@ -2076,6 +2096,10 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
frac@1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
fraction.js@5.3.4: fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
@@ -2602,8 +2626,8 @@ packages:
postcss-value-parser@4.2.0: postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.19: postcss@8.5.20:
resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} resolution: {integrity: sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
prebuild-install@7.1.3: prebuild-install@7.1.3:
@@ -2867,6 +2891,10 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'} engines: {node: '>= 10.x'}
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3120,6 +3148,14 @@ packages:
engines: {node: '>= 8'} engines: {node: '>= 8'}
hasBin: true hasBin: true
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
workbox-background-sync@7.4.1: workbox-background-sync@7.4.1:
resolution: {integrity: sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==} resolution: {integrity: sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==}
@@ -3172,6 +3208,11 @@ packages:
wrappy@1.0.2: wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
hasBin: true
yallist@3.1.1: yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -4159,7 +4200,7 @@ snapshots:
dependencies: dependencies:
ajv: 8.20.0 ajv: 8.20.0
ajv-formats: 2.1.1(ajv@8.20.0) ajv-formats: 2.1.1(ajv@8.20.0)
fast-uri: 2.4.2 fast-uri: 2.4.3
'@fastify/cors@9.0.1': '@fastify/cors@9.0.1':
dependencies: dependencies:
@@ -4444,6 +4485,8 @@ snapshots:
acorn@8.17.0: {} acorn@8.17.0: {}
adler-32@1.3.1: {}
ajv-formats@2.1.1(ajv@8.20.0): ajv-formats@2.1.1(ajv@8.20.0):
optionalDependencies: optionalDependencies:
ajv: 8.20.0 ajv: 8.20.0
@@ -4455,7 +4498,7 @@ snapshots:
ajv@8.20.0: ajv@8.20.0:
dependencies: dependencies:
fast-deep-equal: 3.1.3 fast-deep-equal: 3.1.3
fast-uri: 3.1.3 fast-uri: 3.1.4
json-schema-traverse: 1.0.0 json-schema-traverse: 1.0.0
require-from-string: 2.0.2 require-from-string: 2.0.2
@@ -4491,13 +4534,13 @@ snapshots:
atomic-sleep@1.0.0: {} atomic-sleep@1.0.0: {}
autoprefixer@10.5.4(postcss@8.5.19): autoprefixer@10.5.4(postcss@8.5.20):
dependencies: dependencies:
browserslist: 4.28.6 browserslist: 4.28.6
caniuse-lite: 1.0.30001806 caniuse-lite: 1.0.30001806
fraction.js: 5.3.4 fraction.js: 5.3.4
picocolors: 1.1.1 picocolors: 1.1.1
postcss: 8.5.19 postcss: 8.5.20
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
available-typed-arrays@1.0.7: available-typed-arrays@1.0.7:
@@ -4611,6 +4654,11 @@ snapshots:
caniuse-lite@1.0.30001806: {} caniuse-lite@1.0.30001806: {}
cfb@1.2.2:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
chokidar@3.6.0: chokidar@3.6.0:
dependencies: dependencies:
anymatch: 3.1.3 anymatch: 3.1.3
@@ -4625,6 +4673,8 @@ snapshots:
chownr@1.1.4: {} chownr@1.1.4: {}
codepage@1.15.0: {}
colorette@2.0.20: {} colorette@2.0.20: {}
commander@2.20.3: {} commander@2.20.3: {}
@@ -4643,6 +4693,8 @@ snapshots:
dependencies: dependencies:
browserslist: 4.28.6 browserslist: 4.28.6
crc-32@1.2.2: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -4978,7 +5030,7 @@ snapshots:
ajv: 8.20.0 ajv: 8.20.0
ajv-formats: 3.0.1(ajv@8.20.0) ajv-formats: 3.0.1(ajv@8.20.0)
fast-deep-equal: 3.1.3 fast-deep-equal: 3.1.3
fast-uri: 2.4.2 fast-uri: 2.4.3
json-schema-ref-resolver: 1.0.1 json-schema-ref-resolver: 1.0.1
rfdc: 1.4.1 rfdc: 1.4.1
@@ -4988,9 +5040,9 @@ snapshots:
fast-safe-stringify@2.1.1: {} fast-safe-stringify@2.1.1: {}
fast-uri@2.4.2: {} fast-uri@2.4.3: {}
fast-uri@3.1.3: {} fast-uri@3.1.4: {}
fastify-plugin@4.5.1: {} fastify-plugin@4.5.1: {}
@@ -5048,6 +5100,8 @@ snapshots:
forwarded@0.2.0: {} forwarded@0.2.0: {}
frac@1.1.2: {}
fraction.js@5.3.4: {} fraction.js@5.3.4: {}
fs-constants@1.0.0: {} fs-constants@1.0.0: {}
@@ -5512,29 +5566,29 @@ snapshots:
possible-typed-array-names@1.1.0: {} possible-typed-array-names@1.1.0: {}
postcss-import@15.1.0(postcss@8.5.19): postcss-import@15.1.0(postcss@8.5.20):
dependencies: dependencies:
postcss: 8.5.19 postcss: 8.5.20
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
read-cache: 1.0.0 read-cache: 1.0.0
resolve: 1.22.12 resolve: 1.22.12
postcss-js@4.1.0(postcss@8.5.19): postcss-js@4.1.0(postcss@8.5.20):
dependencies: dependencies:
camelcase-css: 2.0.1 camelcase-css: 2.0.1
postcss: 8.5.19 postcss: 8.5.20
postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.19)(tsx@4.23.1): postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.20)(tsx@4.23.1):
dependencies: dependencies:
lilconfig: 3.1.3 lilconfig: 3.1.3
optionalDependencies: optionalDependencies:
jiti: 1.21.7 jiti: 1.21.7
postcss: 8.5.19 postcss: 8.5.20
tsx: 4.23.1 tsx: 4.23.1
postcss-nested@6.2.0(postcss@8.5.19): postcss-nested@6.2.0(postcss@8.5.20):
dependencies: dependencies:
postcss: 8.5.19 postcss: 8.5.20
postcss-selector-parser: 6.1.4 postcss-selector-parser: 6.1.4
postcss-selector-parser@6.1.4: postcss-selector-parser@6.1.4:
@@ -5544,7 +5598,7 @@ snapshots:
postcss-value-parser@4.2.0: {} postcss-value-parser@4.2.0: {}
postcss@8.5.19: postcss@8.5.20:
dependencies: dependencies:
nanoid: 3.3.16 nanoid: 3.3.16
picocolors: 1.1.1 picocolors: 1.1.1
@@ -5861,6 +5915,10 @@ snapshots:
split2@4.2.0: {} split2@4.2.0: {}
ssf@0.11.2:
dependencies:
frac: 1.1.2
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -5950,11 +6008,11 @@ snapshots:
normalize-path: 3.0.0 normalize-path: 3.0.0
object-hash: 3.0.0 object-hash: 3.0.0
picocolors: 1.1.1 picocolors: 1.1.1
postcss: 8.5.19 postcss: 8.5.20
postcss-import: 15.1.0(postcss@8.5.19) postcss-import: 15.1.0(postcss@8.5.20)
postcss-js: 4.1.0(postcss@8.5.19) postcss-js: 4.1.0(postcss@8.5.20)
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.19)(tsx@4.23.1) postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.20)(tsx@4.23.1)
postcss-nested: 6.2.0(postcss@8.5.19) postcss-nested: 6.2.0(postcss@8.5.20)
postcss-selector-parser: 6.1.4 postcss-selector-parser: 6.1.4
resolve: 1.22.12 resolve: 1.22.12
sucrase: 3.35.1 sucrase: 3.35.1
@@ -6123,7 +6181,7 @@ snapshots:
vite@5.4.21(@types/node@20.19.43)(terser@5.49.0): vite@5.4.21(@types/node@20.19.43)(terser@5.49.0):
dependencies: dependencies:
esbuild: 0.21.5 esbuild: 0.21.5
postcss: 8.5.19 postcss: 8.5.20
rollup: 4.62.2 rollup: 4.62.2
optionalDependencies: optionalDependencies:
'@types/node': 20.19.43 '@types/node': 20.19.43
@@ -6183,6 +6241,10 @@ snapshots:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
wmf@1.0.2: {}
word@0.3.0: {}
workbox-background-sync@7.4.1: workbox-background-sync@7.4.1:
dependencies: dependencies:
idb: 7.1.1 idb: 7.1.1
@@ -6298,6 +6360,16 @@ snapshots:
wrappy@1.0.2: {} wrappy@1.0.2: {}
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
yallist@3.1.1: {} yallist@3.1.1: {}
zod@3.25.76: {} zod@3.25.76: {}