Compare commits

...

74 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
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`) ─────
# Ziel für den Vite-Dev-Proxy von /api -> Backend
BACKEND_URL=http://localhost:3001
# ─── HTTPS im lokalen Netz ──────────────────────────────────────────
# IP-Adresse oder Hostname, unter der du LaunchPad im LAN aufrufst
# (z. B. https://192.168.1.49:8443). Der Frontend-Container erzeugt daraus
# automatisch eine eigene lokale Root-CA + ein davon signiertes Zertifikat
# (komplett offline, keine externen Dienste). Die CA liegt danach zum
# Download unter http://<LAUNCHPAD_HOST>:8080/ca.crt bereit einmal pro
# Gerät importiert/als vertrauenswürdig eingestuft, verschwindet die
# Browser-Warnung dauerhaft. Siehe README für die genauen Schritte.
# Ändern erfordert einen Neustart: docker compose up -d --force-recreate frontend
LAUNCHPAD_HOST=192.168.1.49

209
README.md
View File

@@ -14,9 +14,74 @@ cp .env.example .env # optional anpassen, siehe unten
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
## HTTPS im lokalen Netz
LaunchPad ist per HTTPS auf Port `8443` erreichbar (`https://<deine-ip>:8443`),
zusätzlich zu HTTP auf `8080` kein erzwungener Redirect, kein externer
Dienst beteiligt.
Der Frontend-Container erzeugt beim ersten Start automatisch eine **eigene
lokale Root-CA** (`LaunchPad Local CA`) und signiert damit ein Server-
Zertifikat für die in `.env` hinterlegte `LAUNCHPAD_HOST` (IP oder Hostname).
Beides liegt in einem Docker-Volume (`launchpad-certs`) und bleibt über
Neustarts und `--build` hinweg stabil die CA wird nur einmal erzeugt, nicht
bei jedem Deploy neu.
**Ohne weiteren Schritt** funktioniert HTTPS sofort, der Browser zeigt aber
eine Warnung (unbekannte CA). Für eine echte, warnungsfreie Verbindung und
damit Chrome die PWA auch wirklich "installiert" statt nur eine Verknüpfung
anzulegen reicht **ein einziger, pro Gerät einmaliger Schritt**: die
Root-CA importieren. Es gibt dafür keinen Weg drumherum, der nicht doch einen
externen Dienst braucht (siehe Kasten unten) aber so ist es so einfach wie
möglich gehalten.
### Root-CA einmal pro Gerät importieren
```bash
# .env: IP/Hostname eintragen, unter der du zugreifst
LAUNCHPAD_HOST=192.168.1.49
docker compose up -d --force-recreate frontend
```
**Am Handy (Android):** `http://192.168.1.49:8080/ca.crt` im Browser öffnen
(bewusst HTTP, dafür braucht's noch kein Vertrauen) → Android bietet direkt
"Zertifikat installieren" an → als "VPN und App" oder "WLAN"-Zertifikat
speichern (je nach Android-Version heißt die Kategorie leicht anders,
Hauptsache es landet unter "Von Nutzer installierte Zertifikate"). Danach ggf.
kurz Chrome neu starten.
**Am PC (Windows):** dieselbe URL im Browser öffnen, `ca.crt` herunterladen,
Datei doppelklicken → "Zertifikat installieren" → "Lokaler Computer" →
"Alle Zertifikate in folgendem Speicher speichern" → "Vertrauenswürdige
Stammzertifizierungsstellen" → Fertigstellen. Kein zusätzliches Tool nötig.
**macOS:** `ca.crt` herunterladen, doppelklicken (öffnet Schlüsselbundverwaltung),
dort öffnen, Vertrauenseinstellung auf "Immer vertrauen" setzen.
Danach zeigt `https://192.168.1.49:8443` keine Warnung mehr, und die
PWA-Installation sollte funktionieren.
> **Warum es keinen Weg ganz ohne diesen Schritt gibt:** Damit ein Browser
> einem Zertifikat *automatisch*, ohne jeden manuellen Trust-Schritt, vertraut,
> muss es entweder von einer öffentlich vorinstallierten CA stammen (z. B.
> Let's Encrypt) das erfordert zwangsläufig einen externen Dienst und meist
> einen öffentlich auflösbaren Domainnamen oder eben eine lokale CA, die du
> einmal manuell für vertrauenswürdig erklärst. Es gibt keine dritte Option.
> Diese Lösung minimiert den Aufwand auf "eine Datei öffnen, einmal pro Gerät",
> mehr geht ohne externe Dienste nicht.
### Zertifikat wechseln / eigenes einbinden
Falls du lieber ein eigenes Zertifikat verwendest (z. B. von einer internen
CA, die du schon hast): `fullchain.pem` + `privkey.pem` ins Volume
`launchpad-certs` legen (z. B. `docker cp fullchain.pem launchpad-frontend:/etc/nginx/certs/` bei laufendem Container, oder das Volume direkt mounten),
danach `docker compose restart frontend`. Liegt dabei keine `ca-cert.pem` im
selben Verzeichnis, generiert der Entrypoint nichts automatisch nach.
## Quickstart (lokale Entwicklung)
Voraussetzungen: Node.js ≥ 20, pnpm ≥ 9.
@@ -80,11 +145,15 @@ Dieser erste Commit liefert ein lauffähiges Grundgerüst:
- ✅ PWA: installierbar (Manifest + Icons für Android/iOS/Desktop), Service
Worker mit App-Shell-Precaching, `/api/*` läuft offline über den letzten
Cache-Stand (NetworkFirst, 3s-Timeout)
- ✅ Plugin-System: echte, ladbare Plugins unter `apps/backend/plugins/*`
(Bind-Mount, kein Rebuild nötig). Plugins können die Softwareerkennung des
Scanners erweitern (inkl. eigenem Icon) und eigene Geräte-Importquellen
bereitstellen. Zwei funktionierende Beispiel-Plugins liegen bei.
Noch **nicht** enthalten (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)
- Eigene Admin-Routen/Menüpunkte pro Plugin (aktuell gesammelt auf einer Seite)
### 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/:id
POST /api/services erfordert existierende deviceId
PATCH /api/services/reorder Body: [{ id, order }, ...]
PATCH /api/services/:id
DELETE /api/services/:id
GET /api/bookmarks
POST /api/bookmarks Titel/Favicon/Beschreibung werden automatisch
geladen, falls nicht angegeben
PATCH /api/bookmarks/reorder Body: [{ id, order }, ...]
PATCH /api/bookmarks/:id
DELETE /api/bookmarks/:id
GET /api/categories
POST /api/categories
POST /api/categories optional color (Hex, z. B. "#3b82f6")
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),
werden aber nicht gelöscht
POST /api/scan/devices/:id Netzwerk-Scan für ein Gerät (DNS, Ports, Titel,
Favicon, Softwareerkennung); legt/aktualisiert Dienste
POST /api/scan/fritzbox Liest Geräteliste der FritzBox per TR-064
(erfordert FRITZBOX_HOST/USERNAME/PASSWORD)
POST /api/scan/devices/:id Netzwerk-Scan für ein Gerät; Antwort enthält
zusätzlich staleServices (nicht mehr gefundene
Dienste), nameChanges (abweichender Name/
Kategorie ggü. gespeichertem Wert) und
deviceNameSuggestion (per Reverse-DNS) -
nichts davon wird automatisch übernommen,
nur zur Bestätigung im Adminbereich zurückgegeben
POST /api/scan/fritzbox Liest Geräteliste der FritzBox per TR-064;
Antwort enthält found (rohe Einträge) und
uniqueDevices (nach MAC/IP zusammengeführt,
kann kleiner als found sein - kein Datenverlust),
zusätzlich staleDevices und deviceChanges
(abweichender Name/IP ggü. gespeichertem Wert,
nichts davon wird automatisch übernommen)
GET /api/logs optional ?limit= (Default 100, Max 500)
GET /api/transfer/export ?format=csv|xlsx|json (Default csv). Geräte
UND Dienste in einer Datei, Spalte "type"
unterscheidet die Zeilen.
POST /api/transfer/import Body: { format, content: base64 }. Legt nur
neue Geräte/Dienste an, überschreibt/
dupliziert nie Bestehendes.
GET /api/recent-visits optional ?limit=
POST /api/recent-visits Body: { itemType: "service"|"bookmark", itemId }
DELETE /api/recent-visits
GET /api/settings
PATCH /api/settings Body: { recentVisitsLimit }
GET /api/read-later
POST /api/read-later Titel/Favicon werden automatisch geladen
PATCH /api/read-later/:id
DELETE /api/read-later/:id
POST /api/read-later/:id/promote verschiebt zu Lesezeichen (als Favorit)
POST /api/reset Löscht ALLE Geräte + Dienste (Cascade). Erfordert
Body { "confirm": true }, sonst 400.
GET /api/plugins geladene Plugins mit Capabilities
POST /api/plugins/:name/import löst importDevices() eines Plugins aus
```
## Frontend-Routen
@@ -153,6 +266,7 @@ GET /api/logs optional ?limit= (Default 100, Max 500)
/admin/dashboard
/admin/devices
/admin/services
/admin/bookmarks
/admin/categories
/admin/scanner
/admin/plugins Hinweis: Plugin-System noch nicht gebaut
@@ -160,6 +274,83 @@ GET /api/logs optional ?limit= (Default 100, Max 500)
/admin/logs
```
## Als Browser-Suchmaschine einrichten
LaunchPad lässt sich als eigene Suchmaschine mit Kürzel in Chrome, Edge und
Firefox hinterlegen danach reicht z. B. `lp proxmox` in der Adresszeile,
um direkt beim passenden Dienst zu landen (ohne LaunchPad überhaupt erst zu
öffnen). Das ist ein **einmaliger, manueller Schritt pro Browser/Gerät**
kein Browser lässt eine Website das automatisch für sich einrichten,
aus gutem Grund (Sicherheit).
**Chrome / Edge:**
1. Einstellungen → „Suchmaschine" → „Suchmaschinen verwalten"
2. „Website-Suchmaschine hinzufügen"
3. Suchmaschine: `LaunchPad`, Kürzel: `lp`, URL: `https://<deine-domain-oder-ip>:8443/search?q=%s`
**Firefox:**
1. Einmal `https://<deine-domain-oder-ip>:8443/` öffnen (Firefox erkennt den
hinterlegten OpenSearch-Eintrag automatisch)
2. Einstellungen → „Suche" → bei „LaunchPad" ein Schlagwort (z. B. `lp`)
eintragen
Danach: `lp <suchbegriff>` in die Adresszeile, Enter landet bei eindeutigem
Treffer direkt beim Dienst, sonst auf der LaunchPad-Startseite mit bereits
eingetragenem Suchbegriff.
**Android:** Firefox für Android unterstützt Schlagwort-Suchen (auch keine
synchronisierten Desktop-Kürzel) grundsätzlich nicht eine bekannte
Firefox-Einschränkung, kein LaunchPad-Problem. Auf Android funktioniert die
Chrome-Anleitung oben unverändert. Alternative für jeden Browser: die
LaunchPad-Seite über „Zum Startbildschirm hinzufügen" als App-Symbol
ablegen (LaunchPad ist als PWA installierbar) ein Fingertipp genügt dann
bis zum Suchfeld.
## Deployment auf unterschiedlichen Geräten
Läuft überall, wo Docker Compose verfügbar ist. Ein paar konkrete Wege:
### Generischer Linux-Server / LXC-Container (Proxmox, etc.)
```bash
git clone <repo-url> LaunchPad
cd LaunchPad
cp .env.example .env
docker compose up -d --build
```
### Synology NAS mit Container Manager (Portainer-ähnliche Oberfläche, DSM 7.2+)
1. Repo per `git clone` auf die NAS holen (z. B. per SSH, oder mit File
Station hochladen) Zielordner z. B. `/volume1/docker/LaunchPad`.
2. In Container Manager → „Projekt" → „Erstellen" → als Pfad den geklonten
Ordner wählen. Container Manager erkennt die `docker-compose.yml`
automatisch und bietet an, sie als Projekt zu importieren.
3. `.env` vorher (per File Station oder SSH) aus `.env.example` anlegen und
anpassen Container Manager selbst bietet dafür keine Oberfläche.
4. Projekt starten. Ports `8080`/`8443` müssen frei sein (in DSM ggf. mit
bereits belegten NAS-eigenen Ports abgleichen, notfalls in
`docker-compose.yml` die linke Seite der Port-Zuordnung ändern, z. B.
`18080:80`).
### Synology NAS mit Portainer (falls separat installiert)
1. Repo wie oben auf die NAS holen.
2. Portainer → „Stacks" → „Add stack" → „Repository" (Git-URL direkt
eintragen) oder „Upload" (`docker-compose.yml` hochladen).
3. Unter „Environment variables" die Inhalte aus `.env.example` eintragen
(Portainer braucht dafür keine separate `.env`-Datei, nimmt die Stack-Vars).
4. „Deploy the stack".
### DockHand / andere Docker-Compose-Oberflächen
Jede Oberfläche, die eine vorhandene `docker-compose.yml` importieren oder
auf einen Git-Ordner zeigen kann, funktioniert grundsätzlich gleich: Repo
bereitstellen (lokal geklont oder per Git-Integration der jeweiligen
Oberfläche), `.env` aus `.env.example` befüllen, Compose-Datei
starten/deployen. Es gibt keine LaunchPad-spezifischen Besonderheiten
außer den Standard-Docker-Compose-Grundlagen (Ports, Volumes, `.env`).
## Deployment auf dem Server (xlc-launchpad)
```bash

View File

@@ -31,6 +31,53 @@ ENV NODE_ENV=production
ENV DATABASE_PATH=/data/launchpad.db
ENV PORT=3001
ENV HOST=0.0.0.0
# node:dns/promises (lookup/reverse, siehe scanner/dns.ts) läuft NICHT über
# normales nicht-blockierendes I/O, sondern über getaddrinfo() im
# Betriebssystem - Node schickt das über den libuv-Threadpool ab, der
# standardmäßig nur 4 Threads hat. Beim Sammel-Scan mit bis zu 10 parallel
# gescannten Geräten (siehe routes/scan.ts CONCURRENT_DEVICES) und je bis zu
# 4 DNS-Abfragen pro Gerät reichte das nicht annähernd und führte zu einem
# Rückstau, der wie ein hängender Sammel-Scan aussah (siehe Bugreport - Scan
# blieb nach erfolgreichem masscan-/httpx-Vorabscan minutenlang in der
# eigentlichen Geräte-Schleife stehen). MUSS hier als Umgebungsvariable
# gesetzt werden, nicht im JS-Code selbst: das Projekt läuft als natives ESM
# ("type": "module"), dort werden alle import-Anweisungen VOR jeglichem
# sonstigen Code im Modul ausgeführt (auch wenn dieser Code textuell davor
# steht) - ein Setzen von process.env.UV_THREADPOOL_SIZE am Dateianfang von
# index.ts käme damit zu spät.
ENV UV_THREADPOOL_SIZE=32
# iputils-ping stellt einen "echten" ping-Befehl mit ICMP-Unterstützung
# bereit (nicht nur die BusyBox-Variante) - für den optionalen Live-Status-
# Heartbeat (siehe src/liveStatus.ts, standardmäßig deaktiviert).
# masscan beschleunigt den vollständigen Portscan (1-65535, siehe
# scanner/masscan.ts) erheblich gegenüber reinem TCP-Connect-Scanning - wird
# automatisch genutzt, falls vorhanden, sonst fällt der Scanner auf die
# eingebaute (langsamere, aber ohne Zusatzrechte auskommende) Methode zurück.
RUN apk add --no-cache iputils-ping masscan
# httpx (ProjectDiscovery) prüft eine Liste offener Ports und liefert NUR
# die, die tatsächlich HTTP/HTTPS sprechen, inkl. Status/Titel/Server-Header
# in einem einzigen, hochparallelen Durchlauf (siehe scanner/httpx.ts) -
# deutlich schneller als eigenes Protokoll-Raten pro Port, und verhindert,
# dass Nicht-Web-Ports (SSH, SMB, NFS, ...) überhaupt erst per HTTP
# angesprochen werden. Kein Alpine-Paket vorhanden, daher als
# Binary-Release von GitHub geladen. Bei Fehlschlag (z. B. kein
# Internetzugang beim Bauen des Images) läuft der Build trotzdem durch -
# der Scanner erkennt ein fehlendes httpx zur Laufzeit automatisch und
# fällt auf die eingebaute Methode zurück.
RUN apk add --no-cache --virtual .httpx-build curl unzip && \
( \
HTTPX_URL=$(curl -fsSL https://api.github.com/repos/projectdiscovery/httpx/releases/latest \
| grep -o 'https://[^"]*httpx_[0-9][^"]*_linux_amd64\.zip' | head -n1) && \
test -n "$HTTPX_URL" && \
curl -fsSL "$HTTPX_URL" -o /tmp/httpx.zip && \
unzip -o /tmp/httpx.zip -d /usr/local/bin httpx && \
chmod +x /usr/local/bin/httpx \
|| echo "httpx-Installation fehlgeschlagen - Scanner nutzt automatisch die eingebaute Methode" \
) && \
rm -f /tmp/httpx.zip && \
apk del .httpx-build
COPY --from=build /app/deploy/ ./
COPY --from=build /app/apps/backend/dist ./dist

View File

@@ -17,7 +17,8 @@
"better-sqlite3": "^11.3.0",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.33.0",
"fastify": "^4.28.1"
"fastify": "^4.28.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@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,
source TEXT NOT NULL,
last_scan TEXT,
last_ping TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -52,6 +53,11 @@ export function ensureSchema(): void {
hostname TEXT NOT NULL,
url TEXT NOT NULL,
https INTEGER NOT NULL DEFAULT 0,
visible INTEGER NOT NULL DEFAULT 1,
display_name_edited_manually INTEGER NOT NULL DEFAULT 0,
category_edited_manually INTEGER NOT NULL DEFAULT 0,
last_suggested_display_name TEXT,
last_suggested_category TEXT,
port INTEGER NOT NULL,
favicon TEXT,
description TEXT,
@@ -63,6 +69,7 @@ export function ensureSchema(): void {
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
"order" REAL NOT NULL DEFAULT 0,
color TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -75,5 +82,143 @@ export function ensureSchema(): void {
message TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
display_name TEXT NOT NULL,
hostname TEXT NOT NULL,
description TEXT,
category TEXT,
icon TEXT,
favicon TEXT,
favorite INTEGER NOT NULL DEFAULT 0,
alias TEXT NOT NULL DEFAULT '[]',
"order" REAL NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS recent_visits (
id TEXT PRIMARY KEY,
item_type TEXT NOT NULL,
item_id TEXT NOT NULL,
visited_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS read_later (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
display_name TEXT NOT NULL,
favicon TEXT,
saved_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS detected_apis (
id TEXT PRIMARY KEY,
service_id TEXT NOT NULL REFERENCES services(id) ON DELETE CASCADE,
path TEXT NOT NULL,
type TEXT NOT NULL,
status INTEGER NOT NULL,
detected_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS scan_dismissals (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
dedupe_key TEXT NOT NULL,
dismissed_at TEXT NOT NULL
);
`);
// Leichte Migration für Datenbanken, die vor Einführung von "visible"/
// "color" angelegt wurden: CREATE TABLE IF NOT EXISTS rüstet bei bereits
// existierenden Tabellen keine neuen Spalten nach, das übernehmen wir hier
// manuell. Bestehende Dienste werden dabei auf sichtbar (1) gesetzt, damit
// sich am bisherigen Verhalten nichts unerwartet ändert.
ensureColumn("services", "visible", "INTEGER NOT NULL DEFAULT 1");
ensureColumn("categories", "color", "TEXT");
// Für Datenbanken vor Einführung der Unterscheidung "manuell angepasst" vs.
// "vom Scan abweichend" bei displayName/category (siehe schema.ts). Default
// 0 (nicht manuell bearbeitet) ist für Bestandsdaten die richtige Annahme.
ensureColumn("services", "display_name_edited_manually", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("services", "category_edited_manually", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("services", "last_suggested_display_name", "TEXT");
ensureColumn("services", "last_suggested_category", "TEXT");
ensureColumn("devices", "last_ping", "TEXT");
deduplicateServicesByDeviceAndPort();
// Verhindert künftig auf Datenbankebene, dass zwei Dienste mit demselben
// (Gerät, Port) gleichzeitig existieren - kann sonst passieren, wenn zwei
// Scans desselben Geräts überlappen (z. B. "Alle Geräte scannen" läuft
// noch, während gleichzeitig auch "Jetzt scannen" für genau dieses eine
// Gerät geklickt wird) und beide parallel einen "existiert noch nicht"-Fund
// zu einem CREATE statt UPDATE machen. Erst NACH der Bereinigung oben
// anlegen, sonst würde das Erstellen des Index an vorhandenen Duplikaten
// scheitern.
sqlite.exec(
`CREATE UNIQUE INDEX IF NOT EXISTS idx_services_device_port ON services(device_id, port)`
);
// Verhindert doppelte Einträge für denselben Vorschlag (z. B. wenn zwei
// Browser fast gleichzeitig denselben neuen Dienst bestätigen) - der
// Dismiss-Endpunkt selbst nutzt ohnehin "INSERT OR IGNORE" (siehe
// db/repositories/scanDismissals.ts), dieser Index macht das zusätzlich
// auf Datenbankebene verbindlich.
sqlite.exec(
`CREATE UNIQUE INDEX IF NOT EXISTS idx_scan_dismissals_kind_key ON scan_dismissals(kind, dedupe_key)`
);
}
/**
* Entfernt doppelte Dienst-Einträge für dasselbe (Gerät, Port) - kann bei
* älteren Datenbanken vorkommen, wenn zwei überlappende Scans vor Einführung
* des UNIQUE-Index (s. o.) beide gleichzeitig einen neuen Dienst angelegt
* haben. Behält den Eintrag mit den meisten ausgefüllten Feldern (Favicon,
* Kategorie, ...), bei Gleichstand den ältesten (kleinste created_at).
*/
function deduplicateServicesByDeviceAndPort(): void {
const rows = sqlite
.prepare(`SELECT * FROM services ORDER BY created_at ASC`)
.all() as Array<Record<string, unknown>>;
const groups = new Map<string, Array<Record<string, unknown>>>();
for (const row of rows) {
const key = `${row.device_id}::${row.port}`;
const list = groups.get(key) ?? [];
list.push(row);
groups.set(key, list);
}
function richness(row: Record<string, unknown>): number {
return ["favicon", "category", "description", "icon"].filter((f) => !!row[f]).length;
}
for (const group of groups.values()) {
if (group.length <= 1) continue;
const keeper = [...group].sort((a, b) => richness(b) - richness(a))[0];
const toDelete = group.filter((r) => r.id !== keeper.id);
for (const row of toDelete) {
sqlite.prepare(`DELETE FROM services WHERE id = ?`).run(row.id as string);
}
}
}
function ensureColumn(table: string, column: string, definition: string): void {
const existingColumns = sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{
name: string;
}>;
const hasColumn = existingColumns.some((col) => col.name === column);
if (!hasColumn) {
sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
}

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 {
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[] {
@@ -42,6 +42,7 @@ export function createCategory(input: CategoryCreateInput): Category {
id,
name: input.name,
order: nextOrder,
color: input.color ?? null,
createdAt: timestamp,
updatedAt: timestamp,
})
@@ -50,14 +51,29 @@ export function createCategory(input: CategoryCreateInput): Category {
return getCategory(id)!;
}
/**
* Benennt eine Kategorie um. Da Dienste ihre Kategorie als freier Text
* speichern (kein Fremdschlüssel), werden alle Dienste mit dem alten Namen
* automatisch auf den neuen Namen mit umgestellt, damit beides synchron bleibt.
*/
export function updateCategory(id: string, input: CategoryUpdateInput): Category | null {
const existing = getCategory(id);
if (!existing) return null;
const timestamp = nowIso();
if (input.name !== undefined && input.name !== existing.name) {
db.update(services)
.set({ category: input.name, updatedAt: timestamp })
.where(eq(services.category, existing.name))
.run();
}
db.update(categories)
.set({
...(input.name !== undefined && { name: input.name }),
updatedAt: nowIso(),
...(input.color !== undefined && { color: input.color }),
updatedAt: timestamp,
})
.where(eq(categories.id, id))
.run();
@@ -99,3 +115,41 @@ export function deleteCategory(id: string): boolean {
const result = db.delete(categories).where(eq(categories.id, id)).run();
return result.changes > 0;
}
/**
* Legt eine Kategorie an, falls noch keine mit diesem Namen existiert
* (Vergleich ohne Groß-/Kleinschreibung). Wird beim Scannen aufgerufen,
* damit jede automatisch erkannte Kategorie auch im Adminbereich auftaucht.
*/
export function ensureCategory(name: string): Category {
const trimmed = name.trim();
const existing = listCategories().find(
(c) => c.name.toLowerCase() === trimmed.toLowerCase()
);
if (existing) return existing;
return createCategory({ name: trimmed });
}
/**
* Gleicht eine Liste von Kategorienamen (z. B. alle distinct-Werte aus
* services.category) mit der categories-Tabelle ab und legt fehlende an.
* Wird beim Start aufgerufen, damit bereits vorhandene, aber nie über
* ensureCategory() angelegte Kategorien (z. B. aus Scans vor diesem Fix)
* im Adminbereich sichtbar werden. Gibt die Anzahl neu angelegter zurück.
*/
export function syncCategoriesFromServiceValues(categoryNames: string[]): number {
const existingNames = new Set(listCategories().map((c) => c.name.toLowerCase()));
let created = 0;
for (const rawName of categoryNames) {
const trimmed = rawName.trim();
if (!trimmed) continue;
if (existingNames.has(trimmed.toLowerCase())) continue;
createCategory({ name: trimmed });
existingNames.add(trimmed.toLowerCase());
created++;
}
return created;
}

View File

@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import type { Device, DeviceCreateInput, DeviceUpdateInput } from "@launchpad/shared";
import { db } from "../client.js";
import { devices } from "../schema.js";
import { lookupVendorFromMac } from "../../scanner/macVendors.js";
function nowIso(): string {
return new Date().toISOString();
@@ -19,9 +20,26 @@ function mapRow(row: typeof devices.$inferSelect): Device {
online: row.online,
source: row.source as Device["source"],
lastScan: row.lastScan,
lastPing: row.lastPing,
};
}
/**
* Aktualisiert online-Status + (nur bei online=true) lastPing. Ein
* fehlgeschlagener Ping ist keine "Sichtung" - lastPing soll ausschließlich
* den Zeitpunkt zeigen, an dem das Gerät zuletzt tatsächlich geantwortet
* hat, nicht wann zuletzt VERSUCHT wurde, es zu erreichen. Sonst würde
* "zuletzt gesehen" bei einem dauerhaft offline/entfernten Gerät trotzdem
* ständig auf "gerade eben" weiterrücken, nur weil der Heartbeat es
* regelmäßig (erfolglos) anpingt.
*/
export function updateDeviceOnlineStatus(id: string, online: boolean): void {
db.update(devices)
.set(online ? { online, lastPing: nowIso() } : { online })
.where(eq(devices.id, id))
.run();
}
export function listDevices(): Device[] {
return db.select().from(devices).all().map(mapRow);
}
@@ -34,6 +52,8 @@ export function getDevice(id: string): Device | null {
export function createDevice(input: DeviceCreateInput): Device {
const id = randomUUID();
const timestamp = nowIso();
const manufacturer =
input.manufacturer ?? (input.mac ? lookupVendorFromMac(input.mac) : null);
db.insert(devices)
.values({
@@ -41,7 +61,7 @@ export function createDevice(input: DeviceCreateInput): Device {
hostname: input.hostname,
ip: input.ip,
mac: input.mac ?? null,
manufacturer: input.manufacturer ?? null,
manufacturer,
model: input.model ?? null,
online: input.online ?? false,
source: input.source ?? "manual",
@@ -62,12 +82,22 @@ export function updateDevice(id: string, input: DeviceUpdateInput): Device | nul
const existing = getDevice(id);
if (!existing) return null;
// Wenn der Nutzer eine MAC einträgt/ändert, aber selbst keinen Hersteller
// angibt UND noch keiner gespeichert ist, automatisch versuchen zu
// erkennen (siehe scanner/macVendors.ts) - überschreibt nie einen bereits
// gesetzten oder gerade explizit eingegebenen Wert.
const autoManufacturer =
input.manufacturer === undefined && !existing.manufacturer && input.mac
? lookupVendorFromMac(input.mac)
: undefined;
db.update(devices)
.set({
...(input.hostname !== undefined && { hostname: input.hostname }),
...(input.ip !== undefined && { ip: input.ip }),
...(input.mac !== undefined && { mac: input.mac }),
...(input.manufacturer !== undefined && { manufacturer: input.manufacturer }),
...(autoManufacturer && { manufacturer: autoManufacturer }),
...(input.model !== undefined && { model: input.model }),
...(input.online !== undefined && { online: input.online }),
...(input.source !== undefined && { source: input.source }),
@@ -84,6 +114,12 @@ export function deleteDevice(id: string): boolean {
return result.changes > 0;
}
/** Löscht ALLE Geräte (und per FK-Cascade alle zugehörigen Dienste). Für den Reset-Button im Adminbereich. */
export function deleteAllDevices(): number {
const result = db.delete(devices).run();
return result.changes;
}
export interface DeviceScanInput {
hostname: string;
ip: string;
@@ -94,32 +130,75 @@ export interface DeviceScanInput {
source: Device["source"];
}
export interface DeviceChange {
field: "hostname" | "ip";
current: string;
suggested: string;
}
export interface DeviceScanUpsertResult {
device: Device;
/**
* Wird nur bei bereits bekannten Geräten befüllt, wenn ein frischer Scan
* (z. B. FritzBox meldet eine neue IP oder einen anderen Namen) vom
* gespeicherten Wert abweicht. Wird NICHT automatisch übernommen - nur zur
* manuellen Bestätigung im Adminbereich zurückgegeben, analog zu
* nameChanges bei Diensten (siehe services.ts).
*/
changes: DeviceChange[];
}
function findByMacOrIp(mac: string | null | undefined, ip: string): Device | null {
const all = listDevices();
if (mac) {
const byMac = all.find((d) => d.mac && d.mac.toLowerCase() === mac.toLowerCase());
if (byMac) return byMac;
// Keine MAC-Übereinstimmung: nur noch über die IP abgleichen, wenn das per
// IP gefundene Gerät selbst KEINE eigene MAC-Adresse gespeichert hat. Hat
// es bereits eine (andere) MAC, handelt es sich nachweislich um ein
// anderes physisches Gerät, das (noch) dieselbe IP gemeldet bekommt - z. B.
// weil die FritzBox eine wiederverwendete/gerade neu vergebene IP kurzzeitig
// sowohl im alten als auch im neuen Host-Eintrag führt. Ohne diese Prüfung
// würde ein per Scan neu entdecktes Gerät fälschlich mit einem bereits
// bekannten, anderen Gerät zusammengeführt und als "Namensänderung"
// vorgeschlagen (siehe Bugreport: leere Datenbank, trotzdem sofort eine
// vorgeschlagene Änderung bei der allerersten FritzBox-Abfrage).
const byIp = all.find((d) => d.ip === ip);
if (byIp && !byIp.mac) return byIp;
return null;
}
return all.find((d) => d.ip === ip) ?? null;
}
/**
* Legt ein per Scan gefundenes Gerät an oder aktualisiert ein bestehendes
* (abgeglichen über MAC, sonst IP). Geräte haben keine "Benutzerfelder" im
* Sinne der Spezifikation (die betrifft nur Dienste) Scans dürfen hier
* alle Felder aktualisieren.
* (abgeglichen über MAC, sonst IP).
*
* Bei einem BESTEHENDEN Gerät werden hostname/ip NICHT mehr automatisch
* überschrieben (frühere Version tat das bedingungslos) - stattdessen als
* changes zurückgegeben, damit der Nutzer selbst entscheidet, ob er eine neue
* IP/einen neuen Namen übernehmen möchte. Nur online/lastScan gelten als
* "Live-Status" und werden immer aktualisiert. Achtung: Wird eine geänderte
* IP bei einem Gerät OHNE bekannte MAC nie bestätigt, kann ein künftiger Scan
* dasselbe physische Gerät unter der neuen IP nicht mehr wiedererkennen und
* legt stattdessen einen neuen Eintrag an - mit MAC-Adresse (meist vorhanden)
* bleibt die Wiedererkennung unabhängig von der IP stabil.
*/
export function upsertDeviceFromScan(input: DeviceScanInput): Device {
export function upsertDeviceFromScan(input: DeviceScanInput): DeviceScanUpsertResult {
const existing = findByMacOrIp(input.mac, input.ip);
const timestamp = nowIso();
const mac = input.mac ?? existing?.mac ?? null;
const manufacturer =
input.manufacturer ??
existing?.manufacturer ??
(mac ? lookupVendorFromMac(mac) : null);
if (existing) {
db.update(devices)
.set({
hostname: input.hostname,
ip: input.ip,
mac: input.mac ?? existing.mac,
manufacturer: input.manufacturer ?? existing.manufacturer,
mac,
manufacturer,
model: input.model ?? existing.model,
online: input.online ?? existing.online,
source: input.source,
@@ -128,7 +207,16 @@ export function upsertDeviceFromScan(input: DeviceScanInput): Device {
})
.where(eq(devices.id, existing.id))
.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();
@@ -137,8 +225,8 @@ export function upsertDeviceFromScan(input: DeviceScanInput): Device {
id,
hostname: input.hostname,
ip: input.ip,
mac: input.mac ?? null,
manufacturer: input.manufacturer ?? null,
mac,
manufacturer,
model: input.model ?? null,
online: input.online ?? false,
source: input.source,
@@ -148,5 +236,5 @@ export function upsertDeviceFromScan(input: DeviceScanInput): Device {
})
.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,
hostname: row.hostname,
url: row.url,
updatedAt: row.updatedAt,
https: row.https,
port: row.port,
category: row.category,
@@ -24,6 +25,9 @@ function mapRow(row: typeof services.$inferSelect): Service {
favorite: row.favorite,
alias: JSON.parse(row.alias) as string[],
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,
alias: JSON.stringify(input.alias ?? []),
order: input.order ?? 0,
visible: input.visible ?? true,
createdAt: 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).
* 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 {
const existing = getService(id);
@@ -95,18 +109,25 @@ export function updateService(id: string, input: ServiceUpdateInput): Service |
db.update(services)
.set({
...(input.displayName !== undefined && { displayName: input.displayName }),
...(input.displayName !== undefined && {
displayName: input.displayName,
displayNameEditedManually: input.displayNameEditedManually ?? true,
}),
...(input.hostname !== undefined && { hostname: input.hostname }),
...(input.url !== undefined && { url: input.url }),
...(input.https !== undefined && { https: input.https }),
...(input.port !== undefined && { port: input.port }),
...(input.category !== undefined && { category: input.category }),
...(input.category !== undefined && {
category: input.category,
categoryEditedManually: input.categoryEditedManually ?? true,
}),
...(input.icon !== undefined && { icon: input.icon }),
...(input.favicon !== undefined && { favicon: input.favicon }),
...(input.description !== undefined && { description: input.description }),
...(input.favorite !== undefined && { favorite: input.favorite }),
...(input.alias !== undefined && { alias: JSON.stringify(input.alias) }),
...(input.order !== undefined && { order: input.order }),
...(input.visible !== undefined && { visible: input.visible }),
updatedAt: nowIso(),
})
.where(eq(services.id, id))
@@ -120,6 +141,23 @@ export function deleteService(id: string): boolean {
return result.changes > 0;
}
/**
* Setzt die Reihenfolge mehrerer Dienste in einer Transaktion neu
* (z. B. nach Drag & Drop in der Admin-UI).
*/
export function reorderServices(input: { id: string; order: number }[]): Service[] {
db.transaction((tx) => {
for (const entry of input) {
tx.update(services)
.set({ order: entry.order, updatedAt: nowIso() })
.where(eq(services.id, entry.id))
.run();
}
});
return listServices();
}
export interface ServiceScanInput {
deviceId: string;
hostname: string;
@@ -132,11 +170,36 @@ export interface ServiceScanInput {
suggestedDisplayName: string;
/** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird. */
suggestedCategory?: string | null;
/** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird (z. B. von einem Plugin bereitgestellt). */
suggestedIcon?: string | null;
/** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird (z. B. Fehlerseite -> versteckt). */
suggestedVisible?: boolean;
}
export interface ScanNameChange {
field: "displayName" | "category";
current: string | null;
suggested: string;
/**
* true, wenn der aktuell gespeicherte Wert vom Scan-Vorschlag nur deshalb
* abweicht, weil der Nutzer ihn zuvor manuell angepasst hat (siehe
* displayNameEditedManually/categoryEditedManually). Solche Änderungen sind
* erwartbar/harmlos und sollten in der UI klar von echten inhaltlichen
* Änderungen getrennt dargestellt werden.
*/
manualOverride: boolean;
}
export interface ScanUpsertResult {
service: Service;
created: boolean;
/**
* Wird nur bei bereits bekannten Diensten befüllt, wenn ein frischer Scan
* einen anderen Namen/Kategorie vorschlagen würde als aktuell gespeichert.
* Wird NICHT automatisch übernommen (siehe updateService-Dokumentation
* oben) - nur zur manuellen Durchsicht/Bestätigung im Adminbereich.
*/
nameChanges: ScanNameChange[];
}
function findByDeviceAndPort(deviceId: string, port: number): Service | null {
@@ -144,33 +207,87 @@ function findByDeviceAndPort(deviceId: string, port: number): Service | null {
}
/**
* Legt einen per Scan gefundenen Dienst an oder aktualisiert die scan-eigenen
* Felder eines bereits bekannten Dienstes (abgeglichen über deviceId + port).
* Legt einen per Scan gefundenen Dienst an oder aktualisiert einen bereits
* bekannten Dienst (abgeglichen über deviceId + port).
*
* displayName, category, favorite, order, alias und icon werden bei einem
* bestehenden Dienst NIEMALS verändert nur beim erstmaligen Anlegen dienen
* suggestedDisplayName/suggestedCategory als sinnvoller Startwert.
* Bei einem BESTEHENDEN Dienst werden nur favicon/description aufgefrischt.
* Alles andere auch hostname/url/https, die früher bei jedem Scan
* überschrieben wurden bleibt unangetastet, damit manuelle Anpassungen
* (z. B. IP/Port/Protokoll korrigieren) einen erneuten Scan überleben.
* Falls der frische Scan einen anderen Namen/Kategorie vorschlagen würde,
* wird das über nameChanges zur manuellen Bestätigung zurückgegeben, statt
* entweder stillschweigend ignoriert oder automatisch übernommen zu werden.
*
* Ein abweichender Vorschlag wird ERST gemeldet, wenn er zwei Scans in Folge
* derselbe ist (siehe lastSuggestedDisplayName/lastSuggestedCategory in
* schema.ts) - Titel-Erkennung kann pro Scan leicht schwanken (z. B. eine
* SPA, deren <title> nur gelegentlich rechtzeitig geladen ist), ein
* einmaliger Ausrutscher soll nicht sofort als Änderung gemeldet werden.
*
* Wurde ein Feld bereits manuell über die Admin-UI gesetzt
* (displayNameEditedManually/categoryEditedManually), wird ein abweichender
* Scan-Vorschlag NIE als nameChange gemeldet - der Nutzer hat sich bewusst
* für seinen eigenen Wert entschieden, ein erneutes Nachfragen bei jedem
* Scan wäre nur nervig. Die Admin-UI zeigt stattdessen dauerhaft ein
* Hinweis-Icon am Dienst (siehe ServicesPage.tsx).
*
* suggestedDisplayName/suggestedCategory/suggestedIcon/suggestedVisible
* bestimmen den Anfangswert NUR beim erstmaligen Anlegen.
*/
export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult {
const existing = findByDeviceAndPort(input.deviceId, input.port);
const timestamp = nowIso();
if (existing) {
// Rohzeile inkl. der *EditedManually-/lastSuggested*-Felder laden, die
// mapRow() bewusst nicht nach außen gibt (nur intern hier relevant).
const existingRow = db.select().from(services).where(eq(services.id, existing.id)).get()!;
db.update(services)
.set({
hostname: input.hostname,
url: input.url,
https: input.https,
favicon: input.favicon ?? existing.favicon,
description: input.description ?? existing.description,
lastSuggestedDisplayName: input.suggestedDisplayName ?? existingRow.lastSuggestedDisplayName,
lastSuggestedCategory: input.suggestedCategory ?? existingRow.lastSuggestedCategory,
updatedAt: timestamp,
})
.where(eq(services.id, existing.id))
.run();
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();
try {
db.insert(services)
.values({
id,
@@ -180,17 +297,32 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
favorite: false,
order: 0,
alias: "[]",
icon: null,
icon: input.suggestedIcon ?? null,
hostname: input.hostname,
url: input.url,
https: input.https,
visible: input.suggestedVisible ?? true,
port: input.port,
favicon: input.favicon ?? null,
description: input.description ?? null,
lastSuggestedDisplayName: input.suggestedDisplayName,
lastSuggestedCategory: input.suggestedCategory ?? null,
createdAt: timestamp,
updatedAt: timestamp,
})
.run();
} catch (err) {
// UNIQUE-Constraint (device_id, port) hat zugeschlagen - zwei
// überlappende Scans desselben Geräts haben beide "existiert noch
// nicht" gesehen und wollten beide anlegen. Statt einen Fehler nach
// oben zu werfen: der jeweils andere Versuch hat gewonnen, also einfach
// DESSEN Zeile als Ergebnis zurückgeben (kein Duplikat, kein Datenverlust).
const winner = findByDeviceAndPort(input.deviceId, input.port);
if (winner) {
return { service: winner, created: false, nameChanges: [] };
}
throw err;
}
return { service: getService(id)!, created: true };
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),
source: text("source").notNull(), // fritzbox | dns | http | https | portscan | manual
lastScan: text("last_scan"), // ISO-8601
// Zeitpunkt des letzten Ping-Live-Status-Checks (siehe liveStatus.ts) -
// bewusst getrennt von lastScan, das an echte Scan-Funde gebunden ist und
// die 7-Tage-"nicht mehr gemeldet"-Schwelle beim FritzBox-Scan speist. Ein
// Ping ändert daran nichts, ist nur ein Erreichbarkeits-Heartbeat.
lastPing: text("last_ping"), // ISO-8601
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
});
/**
* Ein Dienst, der auf einem Gerät läuft (z. B. Frigate, Portainer, Gitea ...).
* Benutzeränderungen (displayName, category, favorite, order, alias, icon)
* dürfen von Scans NIEMALS überschrieben werden siehe README/Spezifikation.
* Benutzeränderungen (displayName, category, favorite, order, alias, icon,
* hostname, url, https, visible) dürfen von einem erneuten Scan eines bereits
* bekannten Dienstes NIEMALS überschrieben werden siehe README/Spezifikation.
* Nur favicon/description werden bei jedem Scan aufgefrischt.
*/
export const services = sqliteTable("services", {
id: text("id").primaryKey(),
@@ -28,18 +35,39 @@ export const services = sqliteTable("services", {
.notNull()
.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(),
category: text("category"),
favorite: integer("favorite", { mode: "boolean" }).notNull().default(false),
order: real("order").notNull().default(0),
alias: text("alias").notNull().default("[]"), // JSON-Array als String
icon: text("icon"),
// von Scans aktualisierbar
hostname: text("hostname").notNull(),
url: text("url").notNull(),
https: integer("https", { mode: "boolean" }).notNull().default(false),
// Ausgeblendete Dienste erscheinen nicht in der Suche, bleiben aber im Adminbereich.
visible: integer("visible", { mode: "boolean" }).notNull().default(true),
// Wird true, sobald displayName/category einmal manuell über die Admin-UI
// gesetzt wurden (siehe updateService). Dient dazu, bei einem erneuten Scan
// zu erkennen: weicht der Wert vom Scan-Vorschlag ab, WEIL der Nutzer ihn
// manuell angepasst hat (harmlos, nur zur Info) oder weil sich der
// tatsächlich erkannte Wert geändert hat (echte Änderung)?
displayNameEditedManually: integer("display_name_edited_manually", { mode: "boolean" })
.notNull()
.default(false),
categoryEditedManually: integer("category_edited_manually", { mode: "boolean" })
.notNull()
.default(false),
// Merkt sich den zuletzt vom Scanner berechneten Vorschlag (unabhängig
// davon, ob er zu einer Änderungsmeldung führte). Dient dazu, einmalige
// "Ausrutscher" abzufedern (z. B. eine SPA, deren <title> der Scanner nur
// gelegentlich rechtzeitig sieht): ein neuer, abweichender Vorschlag wird
// erst als Änderung gemeldet, wenn er beim ZWEITEN Mal in Folge wieder
// auftaucht - siehe upsertServiceFromScan.
lastSuggestedDisplayName: text("last_suggested_display_name"),
lastSuggestedCategory: text("last_suggested_category"),
// Bei jedem Scan aktualisierbar
port: integer("port").notNull(),
favicon: text("favicon"),
description: text("description"),
@@ -55,6 +83,7 @@ export const categories = sqliteTable("categories", {
id: text("id").primaryKey(),
name: text("name").notNull(),
order: real("order").notNull().default(0),
color: text("color"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
});
@@ -71,3 +100,94 @@ export const scanLogs = sqliteTable("scan_logs", {
message: text("message").notNull(),
createdAt: text("created_at").notNull(),
});
/**
* Manuell angelegtes Lesezeichen im Unterschied zu Diensten nicht an ein
* gescanntes Gerät gebunden, eigenständige Tabelle. Erscheint zusammen mit
* Diensten in der Suche (siehe packages/shared rankServices), wird aber
* separat verwaltet (Admin -> Lesezeichen).
*/
export const bookmarks = sqliteTable("bookmarks", {
id: text("id").primaryKey(),
url: text("url").notNull(),
displayName: text("display_name").notNull(),
hostname: text("hostname").notNull(), // aus der URL abgeleitet
description: text("description"),
category: text("category"),
icon: text("icon"),
favicon: text("favicon"),
favorite: integer("favorite", { mode: "boolean" }).notNull().default(false),
alias: text("alias").notNull().default("[]"),
order: real("order").notNull().default(0),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
});
/**
* Zuletzt aus der Suche geöffnete Dienste/Lesezeichen, für die
* "Zuletzt besucht"-Leiste auf der Startseite.
*/
export const recentVisits = sqliteTable("recent_visits", {
id: text("id").primaryKey(),
itemType: text("item_type").notNull(), // "service" | "bookmark"
itemId: text("item_id").notNull(),
visitedAt: text("visited_at").notNull(),
});
/** Freie Schlüssel-Wert-Einstellungen, z. B. Anzahl "Zuletzt besucht". */
export const appSettings = sqliteTable("app_settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
});
/**
* "Später lesen": schnell von der Startseite abgelegte Links, unabhängig
* von Lesezeichen/Diensten. Können später zu einem Lesezeichen befördert
* oder gelöscht werden.
*/
export const readLater = sqliteTable("read_later", {
id: text("id").primaryKey(),
url: text("url").notNull(),
displayName: text("display_name").notNull(),
favicon: text("favicon"),
savedAt: text("saved_at").notNull(),
updatedAt: text("updated_at").notNull(),
});
/**
* Von einem eigenen, separaten Scanner ("API-Scanner", siehe
* scanner/apiDetector.ts) gefundene API-Endpunkte bereits bekannter Dienste.
* Kein automatischer Teil des normalen Geräte-/Dienste-Scans - läuft nur auf
* ausdrücklichen Knopfdruck, genau wie die anderen Scanner.
*/
export const detectedApis = sqliteTable("detected_apis", {
id: text("id").primaryKey(),
serviceId: text("service_id")
.notNull()
.references(() => services.id, { onDelete: "cascade" }),
path: text("path").notNull(),
type: text("type").notNull(),
status: integer("status").notNull(),
detectedAt: text("detected_at").notNull(),
});
/**
* Merkt sich, welche Scan-Vorschläge (neuer Dienst, verschwundener Dienst,
* verschwundenes Gerät, Namens-/Kategorie-Änderung) bereits bestätigt ODER
* bewusst verworfen ("Behalten") wurden - SERVERSEITIG statt nur im
* sessionStorage des jeweiligen Browsers (siehe Bugreport: Bestätigen auf
* einem Gerät/Browser hatte auf einem anderen keine Wirkung, dort musste
* derselbe Vorschlag erneut bestätigt werden). "kind" unterscheidet die Art
* des Vorschlags ("new_service", "stale_service", "stale_device",
* "name_change"), "dedupeKey" identifiziert das konkrete Element (z. B. die
* Dienst-/Geräte-ID, bei Namensänderungen "<id>:<feld>"). Scan-Ergebnisse
* werden beim Ausliefern gegen diese Tabelle gefiltert (siehe routes/scan.ts)
* - ein einmal bestätigter/verworfener Vorschlag verschwindet dadurch für
* ALLE Browser/Geräte gleichzeitig, nicht nur lokal.
*/
export const scanDismissals = sqliteTable("scan_dismissals", {
id: text("id").primaryKey(),
kind: text("kind").notNull(),
dedupeKey: text("dedupe_key").notNull(),
dismissedAt: text("dismissed_at").notNull(),
});

View File

@@ -8,6 +8,21 @@ import { serviceRoutes } from "./routes/services.js";
import { categoryRoutes } from "./routes/categories.js";
import { scanRoutes } from "./routes/scan.js";
import { logRoutes } from "./routes/logs.js";
import { pluginRoutes } from "./routes/plugins.js";
import { resetRoutes } from "./routes/reset.js";
import { transferRoutes } from "./routes/transfer.js";
import { bookmarkRoutes } from "./routes/bookmarks.js";
import { recentVisitsRoutes } from "./routes/recentVisits.js";
import { settingsRoutes } from "./routes/settings.js";
import { readLaterRoutes } from "./routes/readLater.js";
import { faviconProxyRoutes } from "./routes/faviconProxy.js";
import { iconsRoutes } from "./routes/icons.js";
import { apiRoutes } from "./routes/apis.js";
import { loadPlugins } from "./plugins/loader.js";
import { startLiveStatusHeartbeat } from "./liveStatus.js";
import * as serviceRepo from "./db/repositories/services.js";
import * as bookmarkRepo from "./db/repositories/bookmarks.js";
import * as categoryRepo from "./db/repositories/categories.js";
const PORT = Number(process.env.PORT ?? 3001);
const HOST = process.env.HOST ?? "0.0.0.0";
@@ -29,12 +44,42 @@ async function main() {
ensureSchema();
// Kategorien, die bereits auf Diensten stehen (z. B. aus Scans vor diesem
// Fix, oder von Plugins), aber noch nicht in der categories-Tabelle sind,
// hier nachtragen. Damit zeigt Admin -> Kategorien immer das, was auch in
// der Suche als Kategorie auftaucht.
const existingCategoryNames = Array.from(
new Set(
[
...serviceRepo.listServices().map((s) => s.category),
...bookmarkRepo.listBookmarks().map((b) => b.category),
].filter((c): c is string => !!c)
)
);
const syncedCount = categoryRepo.syncCategoriesFromServiceValues(existingCategoryNames);
if (syncedCount > 0) {
app.log.info(`${syncedCount} Kategorie(n) aus bestehenden Diensten nachgetragen`);
}
const plugins = await loadPlugins();
app.log.info(`${plugins.length} Plugin(s) geladen`);
await app.register(healthRoutes);
await app.register(deviceRoutes);
await app.register(serviceRoutes);
await app.register(categoryRoutes);
await app.register(scanRoutes);
await app.register(logRoutes);
await app.register(pluginRoutes);
await app.register(resetRoutes);
await app.register(transferRoutes);
await app.register(bookmarkRoutes);
await app.register(recentVisitsRoutes);
await app.register(settingsRoutes);
await app.register(readLaterRoutes);
await app.register(faviconProxyRoutes);
await app.register(iconsRoutes);
await app.register(apiRoutes);
app.get("/", async () => {
return { name: "LaunchPad API", status: "running" };
@@ -43,6 +88,7 @@ async function main() {
try {
await app.listen({ port: PORT, host: HOST });
app.log.info(`LaunchPad backend läuft auf http://${HOST}:${PORT}`);
startLiveStatusHeartbeat();
} catch (err) {
app.log.error(err);
process.exit(1);

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 * as deviceRepo from "../db/repositories/devices.js";
import * as serviceRepo from "../db/repositories/services.js";
import * as categoryRepo from "../db/repositories/categories.js";
import * as logRepo from "../db/repositories/logs.js";
import { scanDeviceServices } from "../scanner/networkScanner.js";
import { fetchFritzBoxHosts } from "../scanner/fritzbox.js";
import * as settingsRepo from "../db/repositories/settings.js";
import * as dismissalRepo from "../db/repositories/scanDismissals.js";
import { scanDeviceServices, type PreFetchedScanData } from "../scanner/networkScanner.js";
import { fullPortRange } from "../scanner/ports.js";
import { scanHostsWithMasscan, getMasscanLastError } from "../scanner/masscan.js";
import { probeManyWithHttpx, getHttpxLastError, type HttpxResult } from "../scanner/httpx.js";
import { fetchFritzBoxHosts, type FritzBoxHost } from "../scanner/fritzbox.js";
import {
startJob,
updateJobProgress,
finishJob,
failJob,
getJobStatus,
isJobRunning,
isCancelRequested,
requestCancel,
tryLockDevice,
unlockDevice,
getScanningDeviceIds,
} from "../scanJobs.js";
import type { Device } from "@launchpad/shared";
/**
* 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.
* Formatiert den masscan/httpx-Status für die Scan-Log-Zeile. Zeigt bei
* masscan "nicht relevant" statt "nicht genutzt", wenn es bei diesem Scan
* gar nicht versucht wurde (masscan läuft nur beim vollständigen Portscan,
* siehe networkScanner.ts) - und hängt bei echten Fehlschlägen die konkrete
* Fehlermeldung an, statt nur "nicht genutzt/verfügbar" ohne Erklärung.
*/
export async function scanRoutes(app: FastifyInstance): Promise<void> {
app.post("/api/scan/devices/:id", async (request, reply) => {
const { id } = request.params as { id: string };
const device = deviceRepo.getDevice(id);
if (!device) {
return reply.code(404).send({ error: "Gerät nicht gefunden" });
}
function formatToolsUsed(toolsUsed: {
masscan: boolean;
masscanAttempted: boolean;
masscanError: string | null;
httpx: boolean;
httpxError: string | null;
}): string {
const masscanPart = !toolsUsed.masscanAttempted
? "masscan: nicht relevant (kein Volltest)"
: toolsUsed.masscan
? "masscan: ✓ genutzt"
: `masscan: ✗ fehlgeschlagen${toolsUsed.masscanError ? ` (${toolsUsed.masscanError})` : " (Grund unbekannt)"}`;
const httpxPart = toolsUsed.httpx
? "httpx: ✓ genutzt"
: `httpx: ✗ fehlgeschlagen${toolsUsed.httpxError ? ` (${toolsUsed.httpxError})` : " (Grund unbekannt)"}`;
return `${masscanPart}, ${httpxPart}`;
}
/**
* Löst FRITZBOX_HOST (Umgebungsvariable, siehe /api/scan/fritzbox weiter
* unten) zur IP-Adresse des Routers selbst auf - wird genutzt, um genau
* dieses eine Gerät vom vollständigen masscan-Portscan im Sammel-Scan
* auszunehmen (siehe Kommentar dort). Ist FRITZBOX_HOST bereits eine
* IP-Adresse, wird sie direkt übernommen, sonst per DNS aufgelöst. Liefert
* null, wenn nicht konfiguriert oder nicht auflösbar - dann greift einfach
* keine Ausnahme, wie vor round58.
*/
async function resolveRouterIp(): Promise<string | null> {
const host = process.env.FRITZBOX_HOST;
if (!host) return null;
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return host;
try {
const 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) =>
serviceRepo.upsertServiceFromScan({
@@ -33,6 +110,8 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
description: found.description,
suggestedDisplayName: found.suggestedDisplayName,
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 updated = results.filter((r) => !r.created).length;
const ports = discovered.map((d) => d.port).sort((a, b) => a - b);
// Dienste, die es vorher gab, aber diesmal nicht mehr gefunden wurden
// (Port nicht mehr offen) werden NICHT automatisch gelöscht, sondern
// erst nach dem konfigurierten Schwellenwert (gleiche Einstellung wie
// bei nicht mehr gemeldeten Geräten, siehe Einstellungen) zur
// manuellen Durchsicht zurückgegeben. Ein einzelner verpasster Scan
// (z. B. Gerät kurz neu gestartet) soll nicht sofort als "verschwunden"
// gemeldet werden.
const foundPorts = new Set(discovered.map((d) => d.port));
const staleThresholdDays = Number(settingsRepo.listSettings().staleDeviceThresholdDays ?? 7);
const staleThresholdMs = staleThresholdDays * 24 * 60 * 60 * 1000;
const now = Date.now();
const staleServiceCandidates = servicesBeforeScan.filter((s) => {
if (foundPorts.has(s.port)) return false;
const lastSeen = s.updatedAt ? new Date(s.updatedAt).getTime() : 0;
return now - lastSeen >= staleThresholdMs;
});
// Ein Dienst, der in DIESEM Scan wieder gefunden wurde, ist nicht mehr
// "verschwunden" - eine frühere Bestätigung/Ablehnung ("ist ok, weg damit"
// bzw. "nein, das war ein Fehlalarm") bezog sich auf DIESE Abwesenheits-
// Episode und soll eine KÜNFTIGE (neue) Abwesenheit nicht stumm
// unterdrücken. Alte Dismiss-Einträge für wiedergefundene Dienste daher
// hier auflösen.
dismissalRepo.clearDismissals(
"stale_service",
servicesBeforeScan.filter((s) => foundPorts.has(s.port)).map((s) => s.id)
);
const dismissedStaleServiceIds = dismissalRepo.getDismissedKeys("stale_service");
const staleServices = staleServiceCandidates.filter((s) => !dismissedStaleServiceIds.has(s.id));
// Vorschläge für abweichende Namen/Kategorien bereits bekannter Dienste
// einsammeln (siehe upsertServiceFromScan) - werden NICHT automatisch
// übernommen, nur zur Bestätigung zurückgegeben. dedupeKey enthält den
// vorgeschlagenen WERT mit, damit eine einmal verworfene Änderung nicht
// auch eine völlig ANDERE künftige Änderung an demselben Feld unterdrückt.
const dismissedNameChangeKeys = dismissalRepo.getDismissedKeys("name_change");
const nameChanges = results
.flatMap((r) =>
r.nameChanges.map((change) => ({
serviceId: r.service.id,
serviceHostname: r.service.hostname,
servicePort: r.service.port,
...change,
}))
)
.filter((c) => !dismissedNameChangeKeys.has(`${c.serviceId}:${c.field}:${c.suggested}`));
// Gerätename-Vorschlag per Reverse-DNS, falls er sich vom aktuell
// gespeicherten Hostnamen unterscheidet - ebenfalls nur zur Bestätigung.
const deviceNameSuggestion =
suggestedHostname && suggestedHostname !== device.hostname ? suggestedHostname : null;
logRepo.logScan({
type: "device",
targetId: device.id,
level: "info",
message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden, ${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 {
deviceId: device.id,
scannedPorts: discovered.length,
ports,
created,
updated,
services: results.map((r) => r.service),
newServices: results
.filter((r) => r.created)
.map((r) => r.service)
.filter((s) => !dismissedNewServiceIds.has(s.id)),
staleServices,
nameChanges,
deviceNameSuggestion,
};
}
/**
* Scan-Endpunkte. Werden ausschließlich manuell per Knopfdruck ("Jetzt
* scannen") aus der Admin-UI ausgelöst es gibt keinerlei automatischen/
* zeitgesteuerten Scan. Jeder Versuch (Erfolg oder Fehler) wird in scan_logs
* protokolliert und ist unter Admin -> Logs einsehbar.
*/
/**
* Filtert die Vorschlagslisten in einem (beim Scan-Abschluss eingefrorenen)
* "lastResult" NOCHMAL gegen den AKTUELLEN Dismiss-Stand, bei jedem
* Status-Abruf neu. Nötig, weil ein Bestätigen/Verwerfen NACH Scan-Ende
* passieren kann (der Normalfall) - ohne diese Live-Filterung würde ein
* anderer Browser/Gerät, der denselben eingefrorenen Snapshot abfragt,
* weiterhin den alten (unbestätigten) Stand sehen, obwohl der Vorschlag
* anderswo längst bestätigt wurde (siehe Bugreport).
*/
function reapplyDismissals(status: ReturnType<typeof getJobStatus>): ReturnType<typeof getJobStatus> {
if (!status.lastResult) return status;
const r = status.lastResult as Record<string, unknown>;
const filterByIdKind = (list: unknown, kind: dismissalRepo.DismissalKind) => {
if (!Array.isArray(list)) return list;
const dismissed = dismissalRepo.getDismissedKeys(kind);
return list.filter((item) => !dismissed.has((item as { id: string }).id));
};
const filterNameChangeLike = (
list: unknown,
kind: dismissalRepo.DismissalKind,
idField: "serviceId" | "deviceId"
) => {
if (!Array.isArray(list)) return list;
const dismissed = dismissalRepo.getDismissedKeys(kind);
return list.filter((item) => {
const c = item as Record<string, unknown>;
return !dismissed.has(`${c[idField]}:${c.field}:${c.suggested}`);
});
};
const patched: Record<string, unknown> = { ...r };
if ("newServices" in r) patched.newServices = filterByIdKind(r.newServices, "new_service");
if ("staleServices" in r) patched.staleServices = filterByIdKind(r.staleServices, "stale_service");
if ("nameChanges" in r) patched.nameChanges = filterNameChangeLike(r.nameChanges, "name_change", "serviceId");
if ("staleDevices" in r) patched.staleDevices = filterByIdKind(r.staleDevices, "stale_device");
if ("deviceChanges" in r) patched.deviceChanges = filterNameChangeLike(r.deviceChanges, "device_change", "deviceId");
return { ...status, lastResult: patched };
}
export async function scanRoutes(app: FastifyInstance): Promise<void> {
// Merkt einen Scan-Vorschlag (neuer Dienst, verschwundener Dienst/Gerät,
// Namens-/Kategorie-Änderung) SERVERSEITIG als erledigt - egal ob durch
// "Übernehmen" oder "Behalten" ausgelöst, beides heißt hier "nicht mehr
// anzeigen". Wird vom Frontend bei jeder Bestätigen/Verwerfen-Aktion
// aufgerufen (siehe ScannerPage.tsx) - vorher lief das rein lokal über
// sessionStorage, wodurch ein Bestätigen auf einem Gerät/Browser auf einem
// anderen wirkungslos blieb (siehe Bugreport).
app.post("/api/scan/dismiss", async (request, reply) => {
const { kind, key } = request.body as { kind?: string; key?: string };
const validKinds = ["new_service", "stale_service", "stale_device", "name_change", "device_change"];
if (!kind || !validKinds.includes(kind) || !key) {
return reply.code(400).send({ error: "kind (eine der bekannten Arten) und key sind erforderlich." });
}
dismissalRepo.dismiss(kind as dismissalRepo.DismissalKind, key);
return { dismissed: true };
});
// Geräteübergreifende Sichtbarkeit für manuelle Einzelgeräte-Scans (siehe
// getScanningDeviceIds() in scanJobs.ts) - wird von der Seitenleiste
// gepollt, damit "Ein Scan läuft gerade" auch für einen per Button auf der
// Geräteseite gestarteten Scan angezeigt wird, nicht nur für den
// Sammel-Scan.
app.get("/api/scan/devices/active", async () => {
return { deviceIds: getScanningDeviceIds() };
});
app.post("/api/scan/devices/:id", async (request, reply) => {
const { id } = request.params as { id: string };
const { full } = request.query as { full?: string };
const device = deviceRepo.getDevice(id);
if (!device) {
return reply.code(404).send({ error: "Gerät nicht gefunden" });
}
if (!tryLockDevice(device.id)) {
return reply.code(409).send({ error: "Für dieses Gerät läuft bereits ein Scan." });
}
try {
return await performDeviceScan(device, full === "true");
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
logRepo.logScan({
@@ -73,13 +305,468 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
});
request.log.error(err);
return reply.code(500).send({ error: "Geräte-Scan fehlgeschlagen", detail });
} finally {
unlockDevice(device.id);
}
});
// Sammel-Scan über ALLE Geräte - läuft als Hintergrund-Job im Backend
// (siehe scanJobs.ts), nicht mehr als eine lange, vom Frontend
// client-seitig orchestrierte Schleife aus Einzelaufrufen. Zwei Vorteile:
// 1) Der Fortschritt ist über GET .../status geräteübergreifend sichtbar -
// öffnet jemand den Adminbereich auf einem ANDEREN Gerät/Browser während
// ein Scan läuft, sieht er das trotzdem (der Zustand lebt im Backend,
// nicht im Browser-sessionStorage).
// 2) Ein einzelnes sehr langsames Gerät blockiert nicht die gesamte
// HTTP-Anfrage bis zum Timeout.
app.get("/api/scan/devices/all/status", async () => {
return reapplyDismissals(getJobStatus("devices"));
});
app.post("/api/scan/devices/all/cancel", async () => {
requestCancel("devices");
return { cancelling: true };
});
app.post("/api/scan/devices/all", async (_request, reply) => {
if (isJobRunning("devices")) {
return reply.code(409).send({ error: "Es läuft bereits ein Geräte-Scan." });
}
const devices = deviceRepo.listDevices();
startJob("devices", devices.length);
void (async () => {
type DeviceScanResult = Awaited<ReturnType<typeof performDeviceScan>>;
let created = 0;
let updated = 0;
let scannedCount = 0;
const allStale: DeviceScanResult["staleServices"] = [];
const allNameChanges: DeviceScanResult["nameChanges"] = [];
const allNew: DeviceScanResult["newServices"] = [];
let cancelled = false;
try {
// NEU (round50): Bis round49 startete JEDES Gerät seinen eigenen
// masscan- bzw. httpx-Prozess - bei bis zu 10 gleichzeitig
// gescannten Geräten (siehe CONCURRENT_DEVICES unten) liefen dadurch
// bis zu 10 masscan- und 10 httpx-Prozesse parallel, die sich
// gegenseitig die Netzwerkschnittstelle streitig machten und in den
// Timeout liefen (siehe Bugreport: "Flaschenhals").
//
// Jetzt läuft masscan EINMAL für ALLE Geräte dieses Scan-Laufs
// gemeinsam (masscan unterstützt mehrere Ziel-IPs nativ in einem
// Prozess) und httpx EINMAL für die Vereinigung aller dabei
// gefundenen offenen Ports über alle Geräte hinweg. Das Ergebnis
// wird unten pro Gerät als "preFetched" an performDeviceScan
// durchgereicht - der eigentliche Geräte-Scan macht dann selbst
// keine masscan-/httpx-Aufrufe mehr, nur noch die (leichtgewichtigen,
// reinen HTTP-)Detailabfragen pro Port (Titel, Favicon,
// Softwareerkennung).
// NEU (round58): Die FritzBox (der Router selbst, NICHT ein Repeater
// o. ä.) hat eine eingebaute Angriffs-/Portscan-Erkennung
// (Sicherheitseinstellungen -> Angriffserkennung). Ein voller
// masscan-Lauf über 1-65535 Ports gegen die eigene Router-IP sieht
// dafür genau wie ein klassischer Portscan-Angriff aus - die
// FritzBox blockt/ignoriert die Scan-Quelle daraufhin offenbar
// kurzzeitig, sodass selbst der direkt danach laufende normale
// 80/443-Retry (siehe scanDeviceServices) noch nichts findet (siehe
// Bugreport: "0 Dienst(e) gefunden (Ports: keine)" bei der FritzBox
// selbst, während ein FRITZ!Repeater im selben Lauf ganz normal alle
// Dienste liefert - Repeater haben typischerweise nicht dieselbe
// Absicherung wie der Router, der ja die Sicherheits-Gateway-Rolle
// fürs ganze Netz hat). Die Router-IP wird daher gezielt aus dem
// masscan-Batch ausgenommen - für sie greift stattdessen weiter
// unten ganz normal die schonende "übliche Portliste"-Prüfung
// (kein vollständiger Portscan, kein masscan), ihre echten Dienste
// kennen wir ohnehin schon zuverlässig über den eigenen
// TR-064-Scanner (siehe scanner/fritzbox.ts).
const routerIp = await resolveRouterIp();
const deviceIps = devices.map((d) => d.ip).filter((ip) => ip !== routerIp);
if (routerIp && devices.some((d) => d.ip === routerIp)) {
logRepo.logScan({
type: "device",
targetId: null,
level: "info",
message: `Sammel-Scan: Router-IP ${routerIp} vom vollständigen Portscan ausgenommen (FritzOS-Angriffserkennung reagiert empfindlich auf volle Portscans gegen sich selbst) - wird stattdessen mit der üblichen Portliste geprüft.`,
});
}
// Sichtbarer Fortschritt VOR dem eigentlichen Geräte-Scan: ohne das
// stand die Anzeige die ganze Vorabscan-Zeit über bei "1 von 120" und
// wirkte wie hängengeblieben, obwohl im Hintergrund der gemeinsame
// masscan-/httpx-Lauf arbeitete (siehe Bugreport).
updateJobProgress("devices", 0, `masscan läuft… (${deviceIps.length} Geräte, vollständiger Portscan)`);
const masscanStartedAt = Date.now();
// masscan meldet laufend Fortschritt (Prozent, Restzeit, bisherige
// Treffer) - wird hier live in die Fortschrittsanzeige gespiegelt,
// statt nur den statischen "masscan läuft…"-Text stehen zu lassen
// (siehe Bugreport: "ich sehe nicht, ob noch alles richtig läuft").
// Eine feste IP lässt sich dabei bewusst NICHT anzeigen - masscan
// arbeitet alle Ziel-Hosts gemeinsam/asynchron ab, es gibt keine
// sinnvolle "aktuell bearbeitete IP" wie bei einem sequenziellen
// Scanner.
let lastMasscanProgressUpdate = 0;
const masscanBatch = await scanHostsWithMasscan(deviceIps, "1-65535", undefined, (progress) => {
const now = Date.now();
if (now - lastMasscanProgressUpdate < 500) return; // nicht öfter als 2x/s aktualisieren
lastMasscanProgressUpdate = now;
const etaText =
progress.etaSeconds !== null
? progress.etaSeconds > 0
? `, ~${progress.etaSeconds}s verbleibend`
: ", wartet auf letzte Antworten"
: "";
updateJobProgress(
"devices",
0,
`masscan läuft… (${deviceIps.length} Geräte, vollständiger Portscan) ${progress.percent.toFixed(0)}% erledigt${etaText}, ${progress.foundSoFar} offene(r) Port(s) bisher`
);
});
const masscanDurationMs = Date.now() - masscanStartedAt;
const masscanBatchError = masscanBatch === null ? getMasscanLastError() : null;
const masscanPortsFound = masscanBatch
? Array.from(masscanBatch.values()).reduce((sum, ports) => sum + ports.length, 0)
: 0;
// Start- und Endzeit fest im Scan-Log protokollieren (nicht nur in
// der Konsole) - so ist die tatsächliche Dauer des Vorabscans auch im
// Nachhinein unter Admin -> Logs nachvollziehbar, nicht nur während
// eines laufenden Scans über die Fortschrittsanzeige.
logRepo.logScan({
type: "device",
targetId: null,
level: masscanBatch ? "info" : "error",
message: masscanBatch
? `Sammel-Scan: masscan-Vorabscan über ${deviceIps.length} Geräte abgeschlossen in ${(masscanDurationMs / 1000).toFixed(1)}s, ${masscanPortsFound} offene(r) Port(s) gefunden (Start: ${new Date(masscanStartedAt).toLocaleTimeString("de-DE")}, Ende: ${new Date().toLocaleTimeString("de-DE")})`
: `Sammel-Scan: masscan-Vorabscan über ${deviceIps.length} Geräte fehlgeschlagen nach ${(masscanDurationMs / 1000).toFixed(1)}s ${masscanBatchError} (jedes Gerät fällt einzeln auf den TCP-Fallback zurück)`,
});
// httpx nur für die Geräte vorab bündeln, für die masscan Ergebnisse
// geliefert hat - ist masscan für den GESAMTEN Batch fehlgeschlagen
// (masscanBatch === null), kennen wir die offenen Ports noch nicht
// und jedes Gerät ermittelt sie unten wie gewohnt selbst per
// TCP-Fallback (scanPortsInBatches) samt eigenem httpx-Aufruf -
// langsamer als der Idealfall, aber weiterhin korrekt.
let httpxBatch: Map<string, HttpxResult> | null = null;
let httpxBatchError: string | null = null;
if (masscanBatch) {
const httpxTargets: Array<{ ip: string; port: number }> = [];
for (const device of devices) {
const ports = masscanBatch.get(device.ip) ?? [];
for (const port of ports) httpxTargets.push({ ip: device.ip, port });
}
updateJobProgress("devices", 0, `httpx läuft… (${httpxTargets.length} Port(s) über alle Geräte)`);
const httpxStartedAt = Date.now();
// httpx meldet jeden Treffer sofort, sobald er bestätigt ist -
// wird hier live in die Fortschrittsanzeige gespiegelt (Anzahl
// bereits als HTTP/HTTPS bestätigter Ports von den insgesamt zu
// prüfenden Zielen). Eine Zwischenanzeige "noch nicht geprüfte"
// Ziele ist dabei nicht sinnvoll möglich - httpx meldet erfolglose
// Versuche gar nicht erst zurück (siehe httpx.ts).
let lastHttpxProgressUpdate = 0;
httpxBatch = await probeManyWithHttpx(httpxTargets, (confirmedSoFar) => {
const now = Date.now();
if (now - lastHttpxProgressUpdate < 500) return;
lastHttpxProgressUpdate = now;
updateJobProgress(
"devices",
0,
`httpx läuft… (${httpxTargets.length} Port(s) über alle Geräte) ${confirmedSoFar} bereits als HTTP/HTTPS bestätigt`
);
});
const httpxDurationMs = Date.now() - httpxStartedAt;
if (httpxBatch === null) httpxBatchError = getHttpxLastError();
logRepo.logScan({
type: "device",
targetId: null,
level: httpxBatch ? "info" : "error",
message: httpxBatch
? `Sammel-Scan: httpx-Vorabscan über ${httpxTargets.length} Ziel(e) (ip:port) abgeschlossen in ${(httpxDurationMs / 1000).toFixed(1)}s, ${httpxBatch.size} als HTTP/HTTPS bestätigt (Start: ${new Date(httpxStartedAt).toLocaleTimeString("de-DE")}, Ende: ${new Date().toLocaleTimeString("de-DE")})`
: `Sammel-Scan: httpx-Vorabscan über ${httpxTargets.length} Ziel(e) fehlgeschlagen nach ${(httpxDurationMs / 1000).toFixed(1)}s ${httpxBatchError} (jeder Port wird stattdessen einzeln von jedem Gerät selbst geprüft)`,
});
} else {
logRepo.logScan({
type: "device",
targetId: null,
level: "info",
message: `Sammel-Scan: httpx-Vorabscan übersprungen, da der masscan-Vorabscan fehlgeschlagen ist (keine Portliste zum Prüfen vorhanden)`,
});
}
// Mehrere Geräte GLEICHZEITIG scannen statt strikt nacheinander -
// bei z. B. 118 Geräten hätte ein rein sequenzieller Durchlauf der
// (jetzt viel schnelleren, weil rein HTTP-basierten) Detailabfragen
// trotzdem lange gedauert. Batch-Größe bewusst moderat (nicht alle
// Geräte auf einmal): jeder einzelne Geräte-Scan öffnet intern schon
// bis zu 1000 gleichzeitige Verbindungen (siehe ports.ts) - zu viele
// Geräte gleichzeitig könnte das eigene Netz/den Docker-Host
// überlasten.
const CONCURRENT_DEVICES = 10;
for (let i = 0; i < devices.length; i += CONCURRENT_DEVICES) {
if (isCancelRequested("devices")) {
cancelled = true;
break;
}
const batch = devices.slice(i, i + CONCURRENT_DEVICES);
const stillRunning = new Set(batch.map((d) => d.hostname));
const renderBatchLabel = () =>
`${batch.map((d) => (stillRunning.has(d.hostname) ? d.hostname : `${d.hostname}`)).join(", ")}`;
updateJobProgress("devices", scannedCount, renderBatchLabel());
await Promise.all(
batch.map(async (device) => {
if (!tryLockDevice(device.id)) {
// Für dieses eine Gerät läuft bereits ein separater Scan
// (z. B. über den "Jetzt scannen"-Button auf der
// Geräte-Seite) - überspringen statt zu überlappen, sonst
// könnten beide gleichzeitig denselben neuen Dienst anlegen
// wollen.
stillRunning.delete(device.hostname);
return;
}
try {
// Router-IP: siehe Kommentar oben bei resolveRouterIp() -
// bewusst NICHT preFetched/fullScan nutzen, sondern die
// schonende Standard-Portliste (kein voller 1-65535-Scan
// gegen den Router selbst, keine Angriffserkennung
// getriggert).
const isRouter = routerIp !== null && device.ip === routerIp;
if (isRouter) {
const result = await performDeviceScan(device, false);
created += result.created;
updated += result.updated;
allStale.push(...result.staleServices);
allNameChanges.push(...result.nameChanges);
allNew.push(...result.newServices);
return;
}
const masscanPorts = masscanBatch ? masscanBatch.get(device.ip) ?? [] : undefined;
const preFetched: PreFetchedScanData = {
masscanAttempted: true,
masscanPorts,
masscanError: masscanPorts ? null : masscanBatchError,
httpxResults: masscanPorts
? (() => {
// Aus der großen, geräteübergreifenden httpx-Map nur
// die Treffer DIESES Geräts herausfiltern.
if (httpxBatch === null) return null;
const map = new Map<number, HttpxResult>();
for (const port of masscanPorts) {
const hit = httpxBatch.get(`${device.ip}:${port}`);
if (hit) map.set(port, hit);
}
return map;
})()
: undefined,
httpxError: masscanPorts ? httpxBatchError : null,
};
const result = await performDeviceScan(device, true, preFetched);
created += result.created;
updated += result.updated;
allStale.push(...result.staleServices);
allNameChanges.push(...result.nameChanges);
allNew.push(...result.newServices);
} catch (err) {
// Einzelnes fehlgeschlagenes Gerät soll den Rest nicht
// abbrechen - ABER trotzdem persistent loggen, statt
// stillschweigend zu überspringen. Vorher war das ein
// stummer catch-Block: scheiterte ausgerechnet immer
// dasselbe Gerät (z. B. wegen eines kaputten Hostnamens),
// gab es dafür nie einen sichtbaren Hinweis.
const detail = err instanceof Error ? err.message : String(err);
logRepo.logScan({
type: "device",
targetId: device.id,
level: "error",
message: `${device.hostname} (${device.ip}): Scan im Sammel-Lauf fehlgeschlagen ${detail}`,
});
} finally {
unlockDevice(device.id);
// Live sichtbar machen, WELCHES Gerät im aktuellen Batch
// schon fertig ist ("✓ Name") und welche noch laufen -
// bisher aktualisierte sich die Anzeige erst wieder, wenn
// ALLE 10 Geräte des Batches fertig waren. Blieb eines davon
// hängen, sah der komplette Batch wie eingefroren aus, ohne
// erkennbar zu machen, WELCHES Gerät das eigentlich betraf
// (siehe Bugreport).
stillRunning.delete(device.hostname);
updateJobProgress("devices", scannedCount, renderBatchLabel());
}
})
);
scannedCount += batch.length;
updateJobProgress("devices", scannedCount, null);
}
// NEU (round60, korrigiert round61): Vergleich mit dem VORHERIGEN
// Sammel-Scan (siehe gleiches Prinzip beim FritzBox-Scan oben) -
// Mengenvergleich der Dienst-IDs statt reiner Zahlenvergleich.
//
// WICHTIG: Als "aktueller Stand" wird eine FRISCHE, VOLLSTÄNDIGE
// Abfrage aller Dienste aus der Datenbank genutzt (serviceRepo.
// listServices()) - NICHT die Liste der in DIESEM Lauf tatsächlich
// erfolgreich gescannten Dienste. Der Unterschied ist entscheidend:
// Geräte, die in diesem Lauf übersprungen wurden (z. B. weil parallel
// schon ein Einzelgeräte-Scan für sie lief, siehe tryLockDevice) oder
// bei denen der Scan mit einem Fehler abgebrochen ist, haben ihre
// bestehenden Dienste NIE in eine nur-aus-diesem-Lauf gesammelte
// Liste geschafft - beim NÄCHSTEN Lauf sahen deren ganz normalen,
// unveränderten Dienste dann fälschlich wie "neu" aus (siehe
// Bugreport: Zusammenfassung zeigte "3 neue Dienste", obwohl nur 1
// Dienst tatsächlich neu angelegt wurde). Eine frische DB-Abfrage hat
// dieses Problem nicht, sie spiegelt immer den WIRKLICHEN
// Gesamtstand wider, unabhängig davon, welche Geräte in diesem
// konkreten Lauf erfolgreich waren.
//
// Das ändert auch die Bedeutung von "nicht mehr da": Diese Dienste
// wurden nicht in diesem Scan einfach nicht gefunden (dafür gibt es
// ja bereits staleServices mit eigener Karenzzeit und eigenem
// Bestätigen/Verwerfen-Dialog weiter unten) - sie wurden
// TATSÄCHLICH AUS DER DATENBANK GELÖSCHT, entweder über genau diesen
// Bestätigen-Dialog oder manuell. Das ist eine reine Feststellung
// ("das ist seit dem letzten Mal passiert"), keine neue
// Entscheidung, die hier nochmal angezeigt/genehmigt werden müsste -
// die Genehmigung ist ja bereits an anderer Stelle erfolgt.
const previousServiceIdsRaw = settingsRepo.getSetting("deviceScanLastServiceIds");
const previousServiceIds: string[] | null = previousServiceIdsRaw ? JSON.parse(previousServiceIdsRaw) : null;
const currentServiceIds = serviceRepo.listServices().map((s) => s.id);
const currentServiceIdSet = new Set(currentServiceIds);
let changesSinceLastScan: string | null;
if (cancelled) {
changesSinceLastScan = "Abgebrochen - kein vollständiger Vergleich möglich.";
} else if (previousServiceIds === null) {
changesSinceLastScan = "Erster Scan - kein Vergleich zu einem vorherigen Lauf möglich.";
} else {
const previousSet = new Set(previousServiceIds);
const newlyAppeared = currentServiceIds.filter((id) => !previousSet.has(id));
const deletedSinceLastScan = previousServiceIds.filter((id) => !currentServiceIdSet.has(id));
// Der Satz ist reine Wiederholung, wenn er GENAU dasselbe aussagt
// wie die Standard-Zusammenfassung ("X neue(r) Dienst(e) gefunden")
// ohnehin schon (newlyAppeared === created, nichts gelöscht) - dann
// komplett weglassen statt zweimal dasselbe zu sagen. Weicht die
// Zahl dagegen ab (z. B. weil zwischen zwei Sammel-Scans ein
// Einzelgeräte-Scan lief, oder weil ein vorheriger Lauf
// abgebrochen wurde und die Vergleichsbasis dadurch älter ist),
// ist das selbst eine nützliche Information und bleibt sichtbar.
const isRedundant = newlyAppeared.length === created && deletedSinceLastScan.length === 0;
if (isRedundant) {
changesSinceLastScan = null;
} else if (newlyAppeared.length === 0 && deletedSinceLastScan.length === 0) {
changesSinceLastScan = "Keine Änderungen seit dem letzten Sammel-Scan.";
} else {
const parts: string[] = [];
if (newlyAppeared.length > 0) parts.push(`${newlyAppeared.length} neue(r) Dienst(e)`);
if (deletedSinceLastScan.length > 0)
parts.push(`${deletedSinceLastScan.length} Dienst(e) seither gelöscht`);
changesSinceLastScan = `Änderungen seit dem letzten Sammel-Scan: ${parts.join(", ")}.`;
}
}
// Bei einem abgebrochenen Lauf NICHT als Vergleichsbasis speichern -
// sonst würde ein unvollständiger Durchlauf fälschlich melden, dass
// Dienste "gelöscht" wurden, nur weil noch nicht alle Geräte an der
// Reihe waren.
if (!cancelled) {
settingsRepo.setSetting("deviceScanLastServiceIds", JSON.stringify(currentServiceIds));
}
finishJob("devices", {
totalDevices: devices.length,
scannedCount,
cancelled,
created,
updated,
staleServices: allStale,
nameChanges: allNameChanges,
newServices: allNew,
changesSinceLastScan,
});
} catch (err) {
// WICHTIG: Vorher wurde ein Abbruch des GESAMTEN Sammel-Scans nur im
// In-Memory-Jobstatus (lastError) vermerkt, nie in der persistenten
// scan_logs-Tabelle - Admin -> Logs blieb dadurch leer, obwohl der
// Scan sichtbar fehlgeschlagen war (Badge in der Seitenleiste
// verschwindet, weil running=false wird, aber ohne jede Erklärung
// warum). Jetzt zusätzlich persistent geloggt.
const detail = err instanceof Error ? err.message : String(err);
logRepo.logScan({
type: "device",
targetId: null,
level: "error",
message: `Sammel-Scan abgebrochen ${detail}`,
});
failJob("devices", detail);
}
})();
return reply.code(202).send({ started: true, totalDevices: devices.length });
});
/**
* Erkennt den von der FritzBox selbst generierten Platzhalter-Namen für
* Geräte ohne bekannten Hostnamen (Muster "PC-192-168-1-73"). Ein solcher
* Name ist nie einer echten Namensänderung wert, sondern nur ein Rückfall.
*/
function isGenericFritzboxHostname(hostname: string, ip: string): boolean {
return hostname === `PC-${ip.replace(/\./g, "-")}`;
}
/**
* Die FritzBox liefert für ein einzelnes physisches Gerät oft MEHRERE
* TR-064-Einträge (IPv4+IPv6, WLAN+Mesh, oder ein alter Platzhaltername neben
* dem aktuellen) - alle mit derselben MAC-Adresse. Werden diese einzeln
* nacheinander upgeserted, legt der erste Eintrag das Gerät an und der
* zweite wird sofort als "Änderung" gegen das gerade erst selbst angelegte
* Gerät vorgeschlagen - verwirrend, besonders bei einer leeren Datenbank
* (siehe Bugreport). Deshalb hier VOR dem Upsert je MAC zu einem Eintrag
* zusammenführen und dabei den aussagekräftigsten Hostnamen wählen (echter
* Name statt "PC-<ip>"-Platzhalter, sofern vorhanden).
*/
function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] {
const byMac = new Map<string, FritzBoxHost[]>();
const withoutMac: FritzBoxHost[] = [];
for (const host of hosts) {
if (!host.mac) {
withoutMac.push(host);
continue;
}
const key = host.mac.toLowerCase();
const group = byMac.get(key);
if (group) group.push(host);
else byMac.set(key, [host]);
}
const merged: FritzBoxHost[] = Array.from(byMac.values()).map((group) => {
const named = group.find((h) => !isGenericFritzboxHostname(h.hostname, h.ip));
const chosen = named ?? group[0];
const online = group.find((h) => h.online) ?? group[0];
return { ip: online.ip, mac: chosen.mac, hostname: chosen.hostname, online: online.online };
});
return [...merged, ...withoutMac];
}
// FritzBox-Scan: liest die Geräteliste per TR-064 und legt/aktualisiert Geräte.
// Erfordert FRITZBOX_HOST / FRITZBOX_USERNAME / FRITZBOX_PASSWORD (optional
// FRITZBOX_PORT, Default 49000) als Umgebungsvariablen.
app.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 username = process.env.FRITZBOX_USERNAME;
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 {
const devicesBeforeScan = deviceRepo.listDevices().filter((d) => d.source === "fritzbox");
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({
hostname: h.hostname,
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({
type: "fritzbox",
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) {
const detail = err instanceof Error ? err.message : String(err);
logRepo.logScan({
@@ -119,8 +930,10 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
level: "error",
message: `FritzBox-Scan fehlgeschlagen ${detail}`,
});
request.log.error(err);
return reply.code(502).send({ error: "FritzBox-Scan fehlgeschlagen", detail });
failJob("fritzbox", detail);
}
})();
return reply.code(202).send({ started: true });
});
}

View File

@@ -1,7 +1,8 @@
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 serviceRepo from "../db/repositories/services.js";
import { suggestIconsForServiceName } from "../scanner/iconDb.js";
interface ServiceListQuery {
deviceId?: string;
@@ -43,6 +44,15 @@ export async function serviceRoutes(app: FastifyInstance): Promise<void> {
return reply.code(201).send(service);
});
// Muss vor der /:id-Route stehen, damit "reorder" nicht als ID interpretiert wird.
app.patch("/api/services/reorder", async (request, reply) => {
const parsed = ServiceReorderSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
}
return serviceRepo.reorderServices(parsed.data);
});
app.patch("/api/services/:id", async (request, reply) => {
const { id } = request.params as { id: string };
const parsed = ServiceUpdateSchema.safeParse(request.body);
@@ -64,4 +74,20 @@ export async function serviceRoutes(app: FastifyInstance): Promise<void> {
}
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 {
hostname: string;
ip: string;
}
// GEFUNDENER FLASCHENHALS (round55): dns.lookup()/dns.reverse() aus
// node:dns/promises laufen NICHT direkt asynchron über den Netzwerk-Stack,
// sondern über getaddrinfo() im Betriebssystem - Node schickt das über den
// libuv-Threadpool ab, der standardmäßig nur 4 Threads hat
// (UV_THREADPOOL_SIZE=4), UNABHÄNGIG von Node's eigentlich nicht-blockierendem
// I/O-Modell. Ohne eigenen Timeout kann eine einzelne langsame/hängende
// Abfrage (z. B. für Handys/Smart-Geräte mit FritzBox-Spitznamen wie
// "S24-Ultra-von-Sebastian", die weder per normaler DNS noch per .home/.local
// auflösbar sind, aber je nach Resolver/Musl-libc im Alpine-Image erst nach
// langer Zeit "nicht gefunden" zurückmelden) einen der 4 Threads über
// Sekunden bis Minuten blockieren. Bei bis zu 10 Geräten gleichzeitig (siehe
// routes/scan.ts CONCURRENT_DEVICES) mit je bis zu 4 DNS-Abfragen (3x
// resolveHostname-Varianten + 1x reverseLookup) stauen sich die restlichen
// Abfragen dann VOR dem 4er-Nadelöhr - das erklärt einen Sammel-Scan, der
// nach einem erfolgreichen masscan-/httpx-Vorabscan plötzlich minutenlang in
// der eigentlichen Geräte-Schleife hängt, obwohl dort eigentlich nur noch
// leichte HTTP-Detailabfragen laufen sollten (siehe Bugreport).
const DNS_TIMEOUT_MS = 1500;
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("DNS-Zeitüberschreitung")), ms);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(err) => {
clearTimeout(timer);
reject(err);
}
);
});
}
/**
* Versucht der Spezifikation folgend: hostname, hostname.home, hostname.local.
* Gibt die erste erfolgreich aufgelöste Variante zurück, sonst null.
* Gibt die erste erfolgreich aufgelöste Variante zurück, sonst null. Jeder
* einzelne Versuch ist auf DNS_TIMEOUT_MS begrenzt (siehe Kommentar oben) -
* eine hängende Abfrage blockiert damit höchstens 3 × 1,5s statt potenziell
* unbegrenzt.
*/
export async function resolveHostname(shortName: string): Promise<DnsResolution | null> {
const candidates = [shortName, `${shortName}.home`, `${shortName}.local`];
for (const hostname of candidates) {
try {
const { address } = await lookup(hostname);
const { address } = await withTimeout(lookup(hostname), DNS_TIMEOUT_MS);
return { hostname, ip: address };
} catch {
// nächste Variante versuchen
// nächste Variante versuchen (Fehler ODER Timeout)
}
}
return null;
}
/**
* Umgekehrte DNS-Abfrage (PTR-Record): fragt anhand der IP nach einem Namen.
* Viele Router/DHCP-Server (FritzBox, Pi-hole, AdGuard Home, ...) tragen hier
* automatisch den vom Gerät gemeldeten DHCP-Hostnamen ein, auch wenn die
* normale Vorwärtsauflösung (Name -> IP) nichts findet. Nützlich als
* zusätzliche Quelle für Gerätenamen, siehe scanner/networkScanner.ts. Mit
* demselben Timeout abgesichert wie resolveHostname() oben.
*/
export async function reverseLookup(ip: string): Promise<string | null> {
try {
const names = await withTimeout(reverse(ip), DNS_TIMEOUT_MS);
return names[0] ?? null;
} catch {
return null;
}
}

View File

@@ -6,6 +6,7 @@ export interface HttpProbeResult {
status?: number;
title?: string;
faviconUrl?: string;
description?: string;
server?: string;
bodySnippet?: string;
}
@@ -46,6 +47,7 @@ export function probeHttp(baseUrl: string, timeoutMs = 2000): Promise<HttpProbeR
status: res.statusCode,
title: extractTitle(body),
faviconUrl: extractFaviconUrl(body, baseUrl),
description: extractDescription(body),
server: Array.isArray(serverHeader) ? serverHeader[0] : serverHeader,
bodySnippet: body,
});
@@ -75,10 +77,25 @@ function extractFaviconUrl(html: string, baseUrl: string): string | undefined {
/<link[^>]+rel=["'](?:shortcut icon|icon)["'][^>]*href=["']([^"']+)["']/i
);
const href = match?.[1];
// Kein <link rel="icon"> im HTML gefunden -> hier NICHT mehr blind auf
// "/favicon.ico" raten (das täuscht ein bestätigtes Favicon vor, das oft
// gar nicht existiert - 404). Der Aufrufer (scanDeviceServices) entscheidet
// die Fallback-Reihenfolge: erst ein Favicon eines anderen Dienstes auf
// demselben Gerät übernehmen, erst danach selbst raten.
if (!href) return undefined;
try {
return new URL(href ?? "/favicon.ico", baseUrl).toString();
return new URL(href, baseUrl).toString();
} catch {
return undefined;
}
}
function extractDescription(html: string): string | undefined {
// <meta name="description" content="..."> in beiden Attribut-Reihenfolgen
const match =
html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i) ??
html.match(/<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["']/i);
const description = match?.[1]?.trim();
return description ? description : undefined;
}

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 { isPortOpen, TYPICAL_PORTS } from "./ports.js";
import { resolveHostname, reverseLookup } from "./dns.js";
import { isPortOpen, scanPortsInBatches, fullPortRange, TYPICAL_PORTS } from "./ports.js";
import { scanWithMasscan, getMasscanLastError } from "./masscan.js";
import { probeWithHttpx, getHttpxLastError, type HttpxResult } from "./httpx.js";
import { probeHttp } from "./http.js";
import { detectSoftware } from "./softwareDetection.js";
import { findBestIconMatch } from "./iconDb.js";
export interface ScanTarget {
hostname: string;
@@ -17,6 +20,103 @@ export interface DiscoveredService {
description?: string;
suggestedDisplayName: string;
category?: string;
icon?: string;
/** Startwert für neu angelegte Dienste: false bei Fehlerseiten/generischen Ladetiteln. */
visible: boolean;
}
/**
* Erkennt generische/nichtssagende Titel, die keine sinnvolle Dienst-
* Bezeichnung abgeben: HTTP-Statuszeilen ("404 Not Found", "302 Found") und
* Lade-/Weiterleitungsplatzhalter (der Scanner führt kein JavaScript aus,
* sieht bei manchen Web-UIs also nur den Ladebildschirm statt des echten Titels).
*/
function isLikelyJunkTitle(title: string | undefined): boolean {
if (!title) return true;
const trimmed = title.trim();
if (trimmed.length === 0) return true;
if (/^\d{3}\b/.test(trimmed)) return true; // "404 Not Found", "400 ...", "302 Found"
if (/^(loading|lädt|please wait|redirecting|weiterleitung)/i.test(trimmed)) return true;
return false;
}
/** Prüft, ob ein String eine reine IPv4-Adresse ist (nicht als "Name" brauchbar). */
function isIpAddress(value: string): boolean {
return /^\d{1,3}(\.\d{1,3}){3}$/.test(value);
}
/**
* Macht aus einem technischen Hostnamen ("plug-shelly-heiz-links",
* "nas_synology-01") einen lesbaren Anzeigenamen ("Plug Shelly Heiz Links").
* Liefert null, wenn der Hostname selbst keine brauchbare Information trägt
* (leer oder reine IP) - dann bleibt nur der IP:Port-Fallback übrig.
*/
/**
* Generische Infrastruktur-Präfixe/-Suffixe, die in Proxmox/Docker-Umfeld
* üblich, aber inhaltlich bedeutungslos sind ("lxc-adguard" soll als
* "Adguard" vorgeschlagen werden, nicht als "Lxc Adguard") - dieselbe Logik
* wie die STOPWORDS in scanner/iconDb.ts, hier auf den Hostnamen angewendet.
*/
const GENERIC_INFRA_WORDS = new Set(["lxc", "vm", "ct", "docker", "container"]);
function humanizeHostname(hostname: string): string | null {
if (!hostname || isIpAddress(hostname)) return null;
const words = hostname
.replace(/\.(home|local|fritz\.box)$/i, "")
.split(/[-_.\s]+/)
.filter(Boolean)
.filter((w) => !GENERIC_INFRA_WORDS.has(w.toLowerCase()));
if (words.length === 0) return null;
return words.map((w) => (w.length > 0 ? w[0].toUpperCase() + w.slice(1) : w)).join(" ");
}
export interface DeviceScanResult {
services: DiscoveredService[];
/**
* Vorschlag für einen besseren Gerätenamen per Reverse-DNS (PTR), falls die
* normale Vorwärtsauflösung nichts Besseres als die reine IP gefunden hat.
* Wird NICHT automatisch übernommen - nur zur manuellen Bestätigung
* zurückgegeben (siehe routes/scan.ts).
*/
suggestedHostname: string | null;
/**
* Welche optionalen Beschleuniger-Werkzeuge bei diesem Scan tatsächlich
* genutzt wurden (siehe scanner/masscan.ts, scanner/httpx.ts) - landet im
* Scan-Log (Admin -> Logs), damit sichtbar ist, ob z. B. masscan wirklich
* gegriffen hat oder (mangels Installation/Rechten) auf die eingebaute
* Methode zurückgefallen wurde.
*/
toolsUsed: {
masscan: boolean;
/** Ob masscan bei diesem Scan überhaupt versucht wurde (nur beim
* vollständigen Portscan, siehe unten) - bei "false" ist "masscan:
* nicht genutzt" also KEIN Fehler, sondern schlicht nicht relevant. */
masscanAttempted: boolean;
/** Klartext-Fehlermeldung, falls masscan versucht wurde und fehlschlug. */
masscanError: string | null;
httpx: boolean;
/** Klartext-Fehlermeldung, falls httpx fehlschlug. */
httpxError: string | null;
};
}
/**
* Vorab (außerhalb dieser Funktion) ermittelte Scan-Daten für EIN Gerät -
* genutzt vom Sammel-Scan (siehe routes/scan.ts), der masscan/httpx nicht
* mehr pro Gerät einzeln aufruft, sondern EINMAL für alle Geräte des
* Scan-Laufs gemeinsam (siehe scanHostsWithMasscan/probeManyWithHttpx) und
* das Ergebnis dann pro Gerät hier einspeist. Bleibt dieser Parameter ganz
* weg (Einzelgeräte-Scan über den "Jetzt scannen"-Button), verhält sich die
* Funktion wie zuvor und macht die masscan-/httpx-Aufrufe selbst.
*/
export interface PreFetchedScanData {
masscanAttempted: boolean;
/** undefined = masscan wurde für dieses Gerät nicht vorab versucht (dann selbst versuchen). */
masscanPorts?: number[];
masscanError: string | null;
/** undefined = httpx wurde für dieses Gerät nicht vorab versucht (dann selbst versuchen). */
httpxResults?: Map<number, HttpxResult> | null;
httpxError: string | null;
}
/**
@@ -27,40 +127,298 @@ export interface DiscoveredService {
*/
export async function scanDeviceServices(
device: ScanTarget,
extraPorts: number[] = TYPICAL_PORTS
): Promise<DiscoveredService[]> {
extraPorts: number[] = TYPICAL_PORTS,
priorityPorts: number[] = [],
preFetched?: PreFetchedScanData
): Promise<DeviceScanResult> {
const dnsResult = await resolveHostname(device.hostname);
// Fällt auf die IP zurück, falls keine DNS-Variante auflösbar ist.
const address = dnsResult?.hostname ?? device.ip;
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[] = [];
for (const port of candidatePorts) {
const open = await isPortOpen(device.ip, port);
if (!open) continue;
// Die HTTP-Abfrage je offenem Port läuft PARALLEL, nicht mehr
// nacheinander - bei bis zu 3 HTTP-Versuchen (Protokoll-Fallback +
// Titel-Wiederholung) à 2 Sekunden Timeout summierte sich das bei einem
// Gerät mit vielen offenen Ports (seit dem vollständigen 1-65535-Portscan
// deutlich häufiger) schnell auf mehrere Minuten PRO GERÄT.
async function probePort(port: number): Promise<DiscoveredService | null> {
const knownScheme = usingHttpx ? httpxResults!.get(port)?.scheme : undefined;
let isHttps = knownScheme
? knownScheme === "https"
: port === 443 || port === 9443 || port === 8006 || port === 8443;
let baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
let probe = await probeHttp(baseUrl);
const isHttps = port === 443 || port === 9443;
const baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
const probe = await probeHttp(baseUrl);
// probe.status === undefined heißt: die Verbindung ist auf HTTP-Ebene
// gescheitert (z. B. TLS-Handshake-Fehler bei einem HTTPS-only-Dienst wie
// Proxmox auf Port 8006, den die Ports-Liste oben nicht kennt) - nicht
// bloß ein 4xx/5xx. In dem Fall lohnt sich ein zweiter Versuch mit dem
// jeweils anderen Protokoll, bevor der Port als "kein Web-Dienst"
// übersprungen wird. Der Port war laut isPortOpen offen, also lohnt sich
// der zusätzliche Versuch. Übersprungen, wenn httpx das Schema schon
// bestätigt hat - dann wissen wir es bereits sicher, ein Fehlschlag hier
// wäre ein einmaliger Ausrutscher, kein falsches Protokoll.
if (probe.status === undefined && !knownScheme) {
isHttps = !isHttps;
baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
const retryProbe = await probeHttp(baseUrl);
if (retryProbe.status !== undefined) probe = retryProbe;
}
// Weder als HTTP noch als HTTPS eine gültige Antwort bekommen (auch nach
// dem Protokoll-Fallback oben) - der Port ist zwar offen, spricht aber
// gar kein HTTP (z. B. SSH, eine Datenbank, ein roher TCP-Dienst). Seit
// der vollständige Portscan (1-65535) Standard ist, findet er sehr viele
// solcher Nicht-Web-Ports - die sollen NICHT als (dann meist
// ausgeblendeter) Dienst-Eintrag landen, sondern komplett übersprungen
// werden. "Mich interessieren doch nur Webifs."
if (probe.status === undefined) return null;
const software = detectSoftware({
server: probe.server,
body: probe.bodySnippet,
title: probe.title,
port,
});
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,
url: baseUrl,
https: isHttps,
port,
favicon: probe.faviconUrl,
description: software ? `${software.name} (automatisch erkannt)` : probe.title,
suggestedDisplayName: software?.name ?? probe.title ?? `${address}:${port}`,
category: software?.category,
});
description: softwareResolved ? `${softwareResolved.name} (automatisch erkannt)` : probe.title,
suggestedDisplayName:
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.
*/
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,
* 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> {
return new Promise((resolve) => {
export async function isPortOpen(host: string, port: number, timeoutMs = 800): Promise<boolean> {
await globalScanSemaphore.acquire();
try {
return await new Promise((resolve) => {
const socket = connect({ host, port, timeout: timeoutMs });
const finish = (result: boolean) => {
@@ -23,4 +81,42 @@ export function isPortOpen(host: string, port: number, timeoutMs = 800): Promise
socket.once("timeout", () => finish(false));
socket.once("error", () => finish(false));
});
} finally {
globalScanSemaphore.release();
}
}
/**
* Prüft eine (ggf. sehr lange) Liste von Ports - für den vollständigen
* Portscan (1-65535, siehe scanDeviceServices mit fullScan=true). Alle
* Verbindungsversuche werden sofort gestartet, die eigentliche Begrenzung
* der Gleichzeitigkeit übernimmt der prozessweite Semaphore in isPortOpen
* (siehe oben) - dadurch bleibt die Gesamtzahl gleichzeitiger Sockets sicher
* gedeckelt, auch wenn mehrere Geräte gleichzeitig gescannt werden.
*
* Timeout bewusst knapp, aber nicht zu knapp (500ms): im lokalen Netz
* (derselbe Subnetz-Bereich) liegt die Round-Trip-Zeit normalerweise im
* niedrigen einstelligen Millisekundenbereich, ABER per WLAN angebundene
* Geräte (Mesh-Repeater, IoT-Geräte) können spürbar länger brauchen. Ports
* 80/443 sowie bereits bekannte Dienst-Ports bekommen zusätzlich einen
* zweiten, noch großzügigeren Versuch (siehe scanDeviceServices), falls
* dieser erste, knappe Durchlauf sie verpasst.
*/
export async function scanPortsInBatches(
host: string,
ports: number[],
timeoutMs = 500
): Promise<number[]> {
const results = await Promise.all(
ports.map(async (port) => ({ port, open: await isPortOpen(host, port, timeoutMs) }))
);
return results.filter((r) => r.open).map((r) => r.port);
}
/** Alle Ports von 1 bis 65535 - für den vollständigen (statt nur "typische Ports") Scan eines einzelnen Geräts. */
export function fullPortRange(): number[] {
const ports: number[] = [];
for (let p = 1; p <= 65535; p++) ports.push(p);
return ports;
}

View File

@@ -1,6 +1,7 @@
export interface SoftwareSignatureInput {
server?: string;
body?: string;
title?: string;
port: number;
}
@@ -8,18 +9,37 @@ export interface SoftwareSignature {
name: string;
category: string;
matches: (input: SoftwareSignatureInput) => boolean;
/** Optional: Emoji oder Icon-URL, von Plugins bereitgestellt (siehe apps/backend/plugins). */
icon?: string;
}
function bodyContains(input: SoftwareSignatureInput, pattern: RegExp): boolean {
return !!input.body && pattern.test(input.body);
}
function titleMatches(input: SoftwareSignatureInput, pattern: RegExp): boolean {
return !!input.title && pattern.test(input.title.trim());
}
/**
* Automatische Softwareerkennung anhand von HTTP-Response-Merkmalen
* (Server-Header, HTML-Inhalt). Liste gemäß Spezifikation.
*
* Grundsatz: der `<title>`-Tag ist ein viel präziseres Signal als der
* komplette Seiteninhalt und wird deshalb wo möglich zuerst geprüft. Reine
* Volltextsuche im Body (bodyContains) besonders bei Marken-/Produktnamen
* aus generischen Alltagswörtern (z. B. "Home Assistant") kann in
* minifiziertem JavaScript anderer Web-UIs zufällig zutreffen - siehe
* Bugreport: FritzBox-Repeater wurden fälschlich als "Home Assistant"
* erkannt, weil beide Wörter irgendwo im ausgelieferten JS vorkamen.
*/
export const SOFTWARE_SIGNATURES: SoftwareSignature[] = [
{ name: "Home Assistant", category: "Smart Home", matches: (i) => 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",
category: "NAS",
@@ -40,10 +60,29 @@ export const SOFTWARE_SIGNATURES: SoftwareSignature[] = [
{ name: "Jellyfin", category: "Medien", matches: (i) => bodyContains(i, /jellyfin/i) },
{ name: "Nextcloud", category: "Cloud", matches: (i) => bodyContains(i, /nextcloud/i) },
{ name: "Pi-hole", category: "DNS", matches: (i) => bodyContains(i, /pi-?hole/i) },
{ name: "AdGuard Home", category: "DNS", matches: (i) => bodyContains(i, /adguard/i) },
{
name: "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) },
];
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 --------------------------------------------------------
FROM nginx:1.27-alpine AS runtime
RUN apk add --no-cache openssl
COPY apps/frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY apps/frontend/nginx-locations.conf /etc/nginx/launchpad-locations.conf
COPY apps/frontend/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
COPY --from=build /app/apps/frontend/dist /usr/share/nginx/html
EXPOSE 80
EXPOSE 80 443
ENTRYPOINT ["/docker-entrypoint.sh"]
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" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link
rel="search"
type="application/opensearchdescription+xml"
title="LaunchPad"
href="/opensearch.xml"
/>
<title>LaunchPad</title>
<script>
// Muss inline und vor jedem Bundle-Download laufen, sonst blitzt beim
// Direktaufruf einer Unterseite (z. B. /admin/dashboard) kurz das
// falsche Theme auf, bevor React mountet.
(function () {
var stored = localStorage.getItem("launchpad-theme");
var dark =
stored === "dark" ||
(stored !== "light" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", dark);
})();
</script>
</head>
<body class="bg-white dark:bg-black">
<div id="root"></div>

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

View File

@@ -10,9 +10,15 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-regular-svg-icons": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/react-fontawesome": "^3.5.0",
"@launchpad/shared": "workspace:*",
"@launchpad/ui": "workspace:*",
"@tanstack/react-query": "^5.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",
"react": "^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",
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 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 { router } from "./router.js";
import "./index.css";
// Muss VOR dem ersten Render laufen und unabhängig davon, welche Route
// zuerst geladen wird (z. B. Direktlink auf /admin/...) vorher hing das
// Anwenden der Dark-Mode-Klasse an useTheme(), das nur auf Home/Einstellungen
// aufgerufen wurde, wodurch andere Admin-Seiten beim Direktaufruf immer hell
// starteten, egal was gespeichert war.
function applyStoredTheme() {
const stored = window.localStorage.getItem("launchpad-theme");
const theme =
stored === "light" || stored === "dark"
? stored
: window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
document.documentElement.classList.toggle("dark", theme === "dark");
}
applyStoredTheme();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -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(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister, maxAge: 24 * 60 * 60 * 1000 }}
>
<RouterProvider router={router} />
</QueryClientProvider>
</PersistQueryClientProvider>
</React.StrictMode>
);

View File

@@ -1,11 +1,15 @@
import { Outlet, createRootRoute, createRoute, createRouter, redirect } from "@tanstack/react-router";
import { HomePage } from "./routes/HomePage.js";
import { SearchRedirectPage } from "./routes/SearchRedirectPage.js";
import { AdminLayout } from "./routes/admin/AdminLayout.js";
import { DashboardPage } from "./routes/admin/DashboardPage.js";
import { DevicesPage } from "./routes/admin/DevicesPage.js";
import { ServicesPage } from "./routes/admin/ServicesPage.js";
import { BookmarksPage } from "./routes/admin/BookmarksPage.js";
import { CategoriesPage } from "./routes/admin/CategoriesPage.js";
import { ScannerPage } from "./routes/admin/ScannerPage.js";
import { ApisPage } from "./routes/admin/ApisPage.js";
import { ReadLaterPage } from "./routes/admin/ReadLaterPage.js";
import { PluginsPage } from "./routes/admin/PluginsPage.js";
import { SettingsPage } from "./routes/admin/SettingsPage.js";
import { LogsPage } from "./routes/admin/LogsPage.js";
@@ -20,6 +24,12 @@ const indexRoute = createRoute({
component: HomePage,
});
const searchRedirectRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/search",
component: SearchRedirectPage,
});
const adminRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/admin",
@@ -53,6 +63,12 @@ const adminServicesRoute = createRoute({
component: ServicesPage,
});
const adminBookmarksRoute = createRoute({
getParentRoute: () => adminRoute,
path: "/bookmarks",
component: BookmarksPage,
});
const adminCategoriesRoute = createRoute({
getParentRoute: () => adminRoute,
path: "/categories",
@@ -65,6 +81,18 @@ const adminScannerRoute = createRoute({
component: ScannerPage,
});
const adminApisRoute = createRoute({
getParentRoute: () => adminRoute,
path: "/apis",
component: ApisPage,
});
const adminReadLaterRoute = createRoute({
getParentRoute: () => adminRoute,
path: "/read-later",
component: ReadLaterPage,
});
const adminPluginsRoute = createRoute({
getParentRoute: () => adminRoute,
path: "/plugins",
@@ -85,13 +113,17 @@ const adminLogsRoute = createRoute({
const routeTree = rootRoute.addChildren([
indexRoute,
searchRedirectRoute,
adminRoute.addChildren([
adminIndexRoute,
adminDashboardRoute,
adminDevicesRoute,
adminServicesRoute,
adminBookmarksRoute,
adminCategoriesRoute,
adminScannerRoute,
adminApisRoute,
adminReadLaterRoute,
adminPluginsRoute,
adminSettingsRoute,
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 { Link } from "@tanstack/react-router";
import { SearchInput, StatusBadge, ResultsList } from "@launchpad/ui";
import { rankServices, type Service } from "@launchpad/shared";
import { SearchInput, StatusBadge, ResultsList, FavoritesBar, Favicon, Button } from "@launchpad/ui";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGear, faPlus } from "@fortawesome/free-solid-svg-icons";
import { rankSearchResults, type SearchResult } from "@launchpad/shared";
import { useServices } from "../hooks/useServices.js";
import { useBookmarks } from "../hooks/useBookmarks.js";
import { useDevices } from "../hooks/useDevices.js";
import { useSettings } from "../hooks/useSettings.js";
import { useBackendHealth } from "../hooks/useBackendHealth.js";
import { 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) {
window.open(service.url, "_blank", "noopener,noreferrer");
}
async function toggleServiceFavorite(service: Service): Promise<Service> {
const res = await fetch(`/api/services/${service.id}`, {
async function toggleFavoriteRequest(item: SearchResult): Promise<void> {
const path = item.kind === "service" ? `/api/services/${item.id}` : `/api/bookmarks/${item.id}`;
const res = await fetch(path, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ favorite: !service.favorite }),
body: JSON.stringify({ favorite: !item.favorite }),
});
if (!res.ok) {
throw new Error(`Favorit konnte nicht aktualisiert werden (HTTP ${res.status})`);
}
}
async function reorderRequest(kind: "service" | "bookmark", orderedIds: string[]): Promise<void> {
const path = kind === "service" ? "/api/services/reorder" : "/api/bookmarks/reorder";
const res = await fetch(path, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(orderedIds.map((id, index) => ({ id, order: index }))),
});
if (!res.ok) {
throw new Error(`Reihenfolge konnte nicht gespeichert werden (HTTP ${res.status})`);
}
}
async function saveReadLaterRequest(url: string) {
const res = await fetch("/api/read-later", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `Konnte nicht gespeichert werden (HTTP ${res.status})`);
}
return res.json();
}
export function HomePage() {
const [theme, toggleTheme] = useTheme();
const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const { health, error: healthError } = useBackendHealth();
const { data: services, isLoading, isError } = useServices();
const inputRef = useRef<HTMLInputElement>(null);
function ReadLaterBox() {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [url, setUrl] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const toggleFavorite = useMutation({
mutationFn: toggleServiceFavorite,
const mutation = useMutation({
mutationFn: () => saveReadLaterRequest(url.trim()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["services"] });
setUrl("");
setOpen(false);
queryClient.invalidateQueries({ queryKey: ["read-later"] });
},
});
const results = useMemo(
() => rankServices(services ?? [], query),
[services, query]
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!url.trim()) return;
mutation.mutate();
}
if (!open) {
return (
<button
type="button"
onClick={() => {
setOpen(true);
setTimeout(() => inputRef.current?.focus(), 0);
}}
className="flex items-center gap-1.5 text-xs text-black/40 transition-colors
hover:text-black/70 dark:text-white/40 dark:hover:text-white/70"
>
<FontAwesomeIcon icon={faPlus} className="text-[10px]" />
Link merken
</button>
);
}
return (
<form onSubmit={handleSubmit} className="flex w-full gap-2">
<input
ref={inputRef}
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setUrl("");
setOpen(false);
}
}}
placeholder="Link zum Später-Lesen hier einfügen …"
className="flex-1 rounded-xl border border-black/10 bg-white/70 px-3 py-1.5 text-sm
text-black outline-none placeholder:text-black/30 focus:border-indigo-400/60
dark:border-white/10 dark:bg-white/5 dark:text-white dark:placeholder:text-white/30
dark:focus:border-indigo-400/40"
/>
<Button type="submit" variant="secondary" size="sm" disabled={mutation.isPending || !url.trim()}>
{mutation.isPending ? "…" : "Merken"}
</Button>
</form>
);
}
export function HomePage() {
const [query, setQuery] = useState(
() => new URLSearchParams(window.location.search).get("q") ?? ""
);
const [selectedIndex, setSelectedIndex] = useState(0);
const [resultsVisible, setResultsVisible] = useState(
() => new URLSearchParams(window.location.search).get("q") !== null
);
const { health, error: healthError } = useBackendHealth();
const { data: services, isLoading: servicesLoading, isError: servicesError } = useServices();
const { data: bookmarks, isLoading: bookmarksLoading } = useBookmarks();
const { data: devices } = useDevices();
const { data: settings, isLoading: settingsLoading } = useSettings();
const { data: categories, isLoading: categoriesLoading } = useCategories();
const { data: recentVisits, isLoading: recentVisitsLoading } = useRecentVisits();
const { data: readLaterItems } = useReadLater();
const inputRef = useRef<HTMLInputElement>(null);
const searchContainerRef = useRef<HTMLDivElement>(null);
const queryClient = useQueryClient();
const isSearching = query.trim().length > 0;
const isLoading = servicesLoading || bookmarksLoading;
function openItem(item: SearchResult | { url: string; id?: string; kind?: "service" | "bookmark" }) {
if ("kind" in item && item.kind === "device") return; // Geräte haben keine eigene URL, nicht anklickbar
window.open(item.url, "_blank", "noopener,noreferrer");
if ("kind" in item && (item.kind === "service" || item.kind === "bookmark") && item.id) {
recordVisit(item.kind, item.id).then(() => {
queryClient.invalidateQueries({ queryKey: ["recent-visits"] });
});
}
}
const categoryColors = useMemo(() => {
const map: Record<string, string> = {};
for (const c of categories ?? []) {
if (c.color) map[c.name] = c.color;
}
return map;
}, [categories]);
const toggleFavorite = useMutation({
mutationFn: toggleFavoriteRequest,
onSuccess: (_data, item) => {
queryClient.invalidateQueries({ queryKey: [item.kind === "service" ? "services" : "bookmarks"] });
},
});
const reorderServices = useMutation({
mutationFn: (ids: string[]) => reorderRequest("service", ids),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
});
const reorderBookmarks = useMutation({
mutationFn: (ids: string[]) => reorderRequest("bookmark", ids),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["bookmarks"] }),
});
// Ausgeblendete Dienste (z. B. Fehlerseiten/nicht erreichbare Scan-Treffer,
// siehe Admin -> Dienste) tauchen in der Suche nicht auf. Geräte und
// Später-lesen-Einträge sind jetzt ebenfalls durchsuchbar (siehe
// rankSearchResults: Dienste stehen dabei immer zuerst, Geräte immer
// zuletzt - Geräte haben keine eigene URL zum Öffnen, siehe ResultsList).
const allItems: SearchResult[] = useMemo(() => {
const deviceHostnameById = new Map((devices ?? []).map((d) => [d.id, d.hostname]));
const deviceOnlineById = new Map((devices ?? []).map((d) => [d.id, d.online]));
const visibleServices: SearchResult[] = (services ?? [])
.filter((s) => s.visible)
.map((s) => ({
...s,
kind: "service" as const,
deviceHostname: deviceHostnameById.get(s.deviceId) ?? null,
deviceOnline: deviceOnlineById.get(s.deviceId) ?? false,
}));
const bookmarkItems: SearchResult[] = (bookmarks ?? []).map((b) => ({
...b,
kind: "bookmark" as const,
}));
const deviceItems: SearchResult[] = (devices ?? []).map((d) => ({
kind: "device" as const,
id: d.id,
displayName: d.hostname,
hostname: d.hostname,
ip: d.ip,
mac: d.mac,
online: d.online,
favicon: null,
category: null,
favorite: false,
order: 0,
// NUR die IP als Alias, NICHT die MAC-Adresse (siehe Bugreport): eine
// MAC wie "AA:BB:CC:DD:EE:55" enthält rein zufällig oft kurze
// Ziffernfolgen, die dann Geräte treffen ließen, ohne dass irgendwo
// in der Ergebniskarte ersichtlich war, WARUM ("55" fand z. B.
// "cam-garage", dessen Name/IP gar keine "55" enthielt - nur die MAC,
// die gar nicht angezeigt wird). Die IP-Suche ("47" findet
// "192.168.1.47") bleibt bewusst erhalten, das ist die eigentlich
// gewünschte/dokumentierte Funktion.
alias: d.ip ? [d.ip] : [],
description: d.ip,
}));
const readLaterSearchItems: SearchResult[] = (readLaterItems ?? []).map((r) => ({
kind: "readlater" as const,
id: r.id,
displayName: r.displayName,
hostname: "",
url: r.url,
favicon: r.favicon,
category: null,
favorite: false,
order: 0,
alias: [],
description: null,
}));
return [...visibleServices, ...bookmarkItems, ...deviceItems, ...readLaterSearchItems];
}, [services, bookmarks, devices, readLaterItems]);
const results = useMemo(() => rankSearchResults(allItems, query), [allItems, query]);
const favoriteServices = useMemo(
() =>
(services ?? [])
.filter((s) => s.visible && s.favorite)
.sort((a, b) => a.order - b.order),
[services]
);
const favoriteBookmarks = useMemo(
() => (bookmarks ?? []).filter((b) => b.favorite).sort((a, b) => a.order - b.order),
[bookmarks]
);
// Auswahl zurücksetzen, sobald sich die Trefferliste ändert
@@ -49,6 +252,31 @@ export function HomePage() {
setSelectedIndex(0);
}, [results.length, query]);
// Beim Tippen wieder einblenden (z. B. nachdem per Klick-außerhalb
// zugeklappt wurde und man weitertippt).
useEffect(() => {
if (isSearching) setResultsVisible(true);
}, [isSearching]);
// Klick außerhalb von Suchfeld+Trefferliste klappt das Dropdown wieder ein
// (Desktop-Verhalten, Text bleibt erhalten). Bewusst gegen das tatsächlich
// gerenderte Listbox-Element geprüft (closest('[role="listbox"]')), nicht
// gegen dessen umgebende Scroll-Wrapper-Divs - die sind aus Layoutgründen
// auf volle Resthöhe gestreckt (siehe max-h-full-Trick in ResultsList),
// ein Ref darauf hätte fast den ganzen Bildschirm als "innerhalb" gezählt.
useEffect(() => {
function onPointerDown(e: MouseEvent) {
const target = e.target as HTMLElement;
const insideSearch = searchContainerRef.current?.contains(target) ?? false;
const insideResults = target.closest('[role="listbox"]') !== null;
if (!insideSearch && !insideResults) {
setResultsVisible(false);
}
}
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, []);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
const isSlash = e.key === "/" && document.activeElement !== inputRef.current;
@@ -61,6 +289,7 @@ export function HomePage() {
}
if (document.activeElement !== inputRef.current) return;
if (query.trim().length === 0) return; // keine sichtbare Liste, nichts zu steuern
if (e.key === "ArrowDown") {
e.preventDefault();
@@ -71,7 +300,7 @@ export function HomePage() {
} else if (e.key === "Enter") {
e.preventDefault();
const target = results[selectedIndex];
if (target) openService(target);
if (target) openItem(target);
} else if (e.key === "Escape") {
inputRef.current?.blur();
setQuery("");
@@ -80,75 +309,241 @@ export function HomePage() {
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [results, selectedIndex]);
}, [results, selectedIndex, query]);
const isOnline = !healthError && health?.status === "ok";
const hasFavorites = favoriteServices.length > 0 || favoriteBookmarks.length > 0;
const readLaterLimit = settings?.readLaterLimit ?? 5;
const hasReadLaterChips = readLaterItems && readLaterItems.length > 0 && readLaterLimit > 0;
const showShelf = !isSearching || !resultsVisible;
const showResults = isSearching && resultsVisible;
const isMobile = useIsMobile();
// WICHTIG: Bei einem ECHTEN Seiten-Reload (z. B. "mit Runterziehen
// aktualisieren" auf dem Handy) ist settings.mobileHomeStyle beim
// allerersten Render noch nicht geladen (React-Query-Cache ist nach einem
// vollständigen Reload leer, die Server-Antwort kommt erst asynchron
// nach) - bis dahin wäre "classic" der Standard-Fallback gewesen und kurz
// sichtbar aufgeblitzt, bevor auf "android" umgeschaltet wird (siehe
// Bugreport). Die zuletzt bekannte Wahl wird daher zusätzlich in
// localStorage gespiegelt (überlebt einen Reload, im Gegensatz zum
// React-Query-Cache) und beim allerersten Render schon genutzt, bevor die
// eigentliche Serverantwort da ist - dieselbe Technik wie beim
// Dark-Mode-Flash (siehe index.html).
const [cachedMobileHomeStyle, setCachedMobileHomeStyle] = useState<string | null>(() =>
typeof window !== "undefined" ? window.localStorage.getItem("launchpad:mobileHomeStyle") : null
);
useEffect(() => {
if (settings?.mobileHomeStyle && settings.mobileHomeStyle !== cachedMobileHomeStyle) {
window.localStorage.setItem("launchpad:mobileHomeStyle", settings.mobileHomeStyle);
setCachedMobileHomeStyle(settings.mobileHomeStyle);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings?.mobileHomeStyle]);
const effectiveMobileHomeStyle = settings?.mobileHomeStyle ?? cachedMobileHomeStyle ?? "classic";
const useAndroidHome = isMobile && effectiveMobileHomeStyle === "android";
// WICHTIG: Dienste/Lesezeichen/Kategorien/"Zuletzt besucht"/Einstellungen
// laden über getrennte, unabhängig voneinander abgeschlossene
// Netzwerk-Anfragen - kämen sie einzeln nacheinander im Raster an, würde
// z. B. die "Zuletzt besucht"-Kachel nachträglich auftauchen und alle
// Icons danach verschieben (genau das im Bugreport beschriebene
// "Verschieben, weil manche Icons schneller da sind als andere"). Daher
// wird das komplette Raster erst gerendert, wenn ALLE beteiligten
// Abfragen mindestens einmal durch sind - dann in einem Rutsch, ohne
// Nachrücken einzelner Kacheln.
const androidHomeDataReady =
!servicesLoading && !bookmarksLoading && !categoriesLoading && !recentVisitsLoading && !settingsLoading;
const addReadLaterMutation = useMutation({
mutationFn: (url: string) => saveReadLaterRequest(url),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["read-later"] }),
});
if (useAndroidHome) {
return (
<MobileAndroidHome
services={services ?? []}
bookmarks={bookmarks ?? []}
categories={categories ?? []}
categoryColors={categoryColors}
allSearchItems={allItems}
recentVisits={recentVisits ?? []}
readLaterItems={readLaterItems ?? []}
onOpen={openItem}
onToggleFavorite={(item) => toggleFavorite.mutate(item)}
onAddReadLater={(url) => addReadLaterMutation.mutate(url)}
isOnline={isOnline}
isReady={androidHomeDataReady}
/>
);
}
return (
<div className="flex min-h-screen flex-col items-center justify-start gap-8 bg-gradient-to-b from-white to-neutral-100 px-6 pt-[15vh] dark:from-black dark:to-neutral-950">
<div className="fixed right-6 top-6 flex items-center gap-2">
<div
className={`flex flex-col bg-gradient-to-b from-white to-neutral-100 dark:from-black
dark:to-neutral-950 ${showResults ? "h-dvh overflow-hidden" : "min-h-dvh"}`}
>
<div className="fixed right-6 top-6 z-10 flex items-center gap-2">
<Link
to="/admin"
aria-label="Adminbereich öffnen"
className="rounded-full border border-black/10 p-2 text-black/60 transition-colors
hover:bg-black/5 dark:border-white/10 dark:text-white/60 dark:hover:bg-white/5"
>
<FontAwesomeIcon icon={faGear} />
</Link>
<button
onClick={toggleTheme}
aria-label="Theme wechseln"
className="rounded-full border border-black/10 p-2 text-black/60 transition-colors
hover:bg-black/5 dark:border-white/10 dark:text-white/60 dark:hover:bg-white/5"
>
{theme === "dark" ? "☀️" : "🌙"}
</button>
</div>
<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-4xl font-semibold tracking-tight text-black dark:text-white">
{/* Kopfbereich: Titel, Suchfeld, Favoriten/Später-lesen/Zuletzt-besucht.
Nur im "aktiv suchend"-Zustand shrink-0 + scrollender Bereich
darunter - sonst normaler Dokumentfluss, damit der Footer direkt
nach dem Inhalt folgt statt mit einer riesigen Lücke ganz unten zu
kleben (siehe showResults-Fallunterscheidung oben). */}
<div className={`flex flex-col items-center gap-6 px-6 pb-6 pt-10 sm:pt-14 ${showResults ? "shrink-0" : ""}`}>
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-xl font-semibold tracking-tight text-black dark:text-white sm:text-2xl">
LaunchPad
</h1>
<p className="text-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.
</p>
</div>
<div className="w-full max-w-xl">
<div ref={searchContainerRef} className="w-full max-w-xl">
<SearchInput
ref={inputRef}
value={query}
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"
autoFocus
/>
</div>
{isLoading ? (
<p className="mt-4 text-center text-sm text-black/40 dark:text-white/40">
Lade Dienste
{showShelf ? (
<div
className="w-full max-w-xl divide-y divide-black/[0.06] rounded-2xl border
border-black/[0.06] bg-black/[0.015] dark:divide-white/[0.06] dark:border-white/[0.06]
dark:bg-white/[0.02]"
>
{hasFavorites ? (
<div className="flex flex-col gap-2 p-2.5">
{favoriteServices.length > 0 ? (
<FavoritesBar
items={favoriteServices}
label="Favoriten · Dienste"
categoryColors={categoryColors}
onOpen={(item) => {
const service = favoriteServices.find((s) => s.id === item.id);
if (service) openItem({ ...service, kind: "service" });
}}
onReorder={(ids) => reorderServices.mutate(ids)}
/>
) : null}
{favoriteBookmarks.length > 0 ? (
<FavoritesBar
items={favoriteBookmarks}
label="Favoriten · Lesezeichen"
categoryColors={categoryColors}
onOpen={(item) => {
const bookmark = favoriteBookmarks.find((b) => b.id === item.id);
if (bookmark) openItem({ ...bookmark, kind: "bookmark" });
}}
onReorder={(ids) => reorderBookmarks.mutate(ids)}
/>
) : null}
</div>
) : null}
{recentVisits && recentVisits.length > 0 ? (
<div className="p-2.5">
<FavoritesBar
items={recentVisits}
label="Zuletzt besucht"
categoryColors={categoryColors}
onOpen={(item) => {
const original = recentVisits.find((r) => r.id === item.id);
if (original) openItem(original);
}}
/>
</div>
) : null}
<div className="p-2.5">
<div className="mb-2.5 flex items-center justify-between gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-black/35 dark:text-white/35">
Später lesen
</span>
<ReadLaterBox />
</div>
{hasReadLaterChips ? (
<div className="flex flex-wrap gap-1.5">
{readLaterItems!.slice(0, readLaterLimit).map((item) => (
<button
key={item.id}
onClick={() => window.open(item.url, "_blank", "noopener,noreferrer")}
title={item.displayName}
className="group flex w-12 flex-col items-center gap-0.5 rounded-lg p-1
transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.06]"
>
<span
className="flex h-8 w-8 items-center justify-center rounded-lg border
border-black/10 bg-white/80 shadow-sm transition-transform
group-hover:scale-105 dark:border-white/10 dark:bg-white/5"
>
<Favicon src={item.favicon} fallbackLetter={item.displayName} size="md" />
</span>
<span className="line-clamp-2 w-full text-center text-[10px] leading-tight text-black/55 dark:text-white/55">
{item.displayName}
</span>
</button>
))}
</div>
) : (
<p className="text-xs text-black/30 dark:text-white/30">
Noch nichts gemerkt.
</p>
) : 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.
</p>
) : (
<ResultsList
services={results}
results={results}
selectedIndex={selectedIndex}
categoryColors={categoryColors}
onHover={setSelectedIndex}
onOpen={openService}
onToggleFavorite={(service) => toggleFavorite.mutate(service)}
onOpen={openItem}
onToggleFavorite={(item) => toggleFavorite.mutate(item)}
emptyLabel={
(services?.length ?? 0) === 0
? "Noch keine Dienste angelegt. Füge welche im Adminbereich hinzu."
allItems.length === 0
? "Noch nichts angelegt. Füge Dienste oder Lesezeichen im Adminbereich hinzu."
: "Keine Treffer für deine Suche."
}
/>
)}
</div>
</div>
) : null}
<div className="flex flex-col items-center gap-1 pb-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"} />
{health ? (
<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 { 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 = [
{ to: "/admin/dashboard", label: "Dashboard", icon: "📊" },
{ to: "/admin/devices", label: "Geräte", icon: "🖥️" },
{ to: "/admin/services", label: "Dienste", icon: "🔗" },
{ to: "/admin/scanner", label: "Scanner", icon: "🔍" },
{ to: "/admin/categories", label: "Kategorien", icon: "🏷️" },
{ to: "/admin/plugins", label: "Plugins", icon: "🧩" },
{ to: "/admin/settings", label: "Einstellungen", icon: "⚙️" },
{ to: "/admin/logs", label: "Logs", icon: "📜" },
] as const;
function ScanActivityBadge() {
const { running, pendingCount } = useScanActivity();
if (running) {
return (
<span
title="Ein Scan läuft gerade"
className="ml-auto h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-blue-500"
/>
);
}
if (pendingCount > 0) {
return (
<span
title={`${pendingCount} Scan-Ergebnis(se) warten auf Bestätigung`}
className="ml-auto flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full
bg-amber-500 px-1 text-[10px] font-medium text-white"
>
{pendingCount}
</span>
);
}
return null;
}
const NAV_ITEMS: { to: string; label: string; icon: IconDefinition }[] = [
{ to: "/", label: "Startseite", icon: faHouse },
{ to: "/admin/dashboard", label: "Dashboard", icon: faChartLine },
{ to: "/admin/devices", label: "Geräte", icon: faDesktop },
{ to: "/admin/services", label: "Dienste", icon: faLink },
{ to: "/admin/bookmarks", label: "Lesezeichen", icon: faBookmark },
{ to: "/admin/read-later", label: "Später lesen", icon: faThumbtack },
{ to: "/admin/scanner", label: "Scanner", icon: faMagnifyingGlass },
{ to: "/admin/apis", label: "APIs", icon: faPlug },
{ to: "/admin/categories", label: "Kategorien", icon: faTag },
{ to: "/admin/plugins", label: "Plugins", icon: faPuzzlePiece },
{ to: "/admin/settings", label: "Einstellungen", icon: faGear },
{ to: "/admin/logs", label: "Logs", icon: faScroll },
];
export function AdminLayout() {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const [mobileOpen, setMobileOpen] = useState(false);
const { health, error: healthError } = useBackendHealth();
const isOnline = !healthError && health?.status === "ok";
// Drawer schließen, wenn per Navigation die Seite wechselt
useEffect(() => {
setMobileOpen(false);
}, [pathname]);
return (
<div className="flex min-h-screen bg-neutral-50 dark:bg-neutral-950">
<aside className="flex w-56 shrink-0 flex-col border-r border-black/10 bg-white/70 dark:border-white/10 dark:bg-white/5">
<div className="flex items-center gap-2 border-b border-black/10 px-5 py-4 dark:border-white/10">
<Link to="/" className="text-lg font-semibold text-black dark:text-white">
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-950">
{/* Mobile-Kopfleiste mit Hamburger-Button, nur unterhalb des sm-Breakpoints
sichtbar. Fixiert am oberen Rand, damit sie bei langen Seiten (z. B.
Dienste-Tabelle) beim Scrollen sichtbar bleibt statt mit dem Inhalt
mitzuwandern - main hat dafür unten zusätzliches pt-16 auf Mobil. */}
<div
className="fixed inset-x-0 top-0 z-20 flex items-center gap-3 border-b border-black/10
bg-white/80 px-4 py-3 backdrop-blur-md dark:border-white/10 dark:bg-neutral-950/80 sm:hidden"
>
<button
onClick={() => setMobileOpen(true)}
aria-label="Menü öffnen"
className="rounded-lg p-1.5 text-black/70 hover:bg-black/5 dark:text-white/70 dark:hover:bg-white/10"
>
<span className="block text-xl leading-none" aria-hidden>
<FontAwesomeIcon icon={faBars} />
</span>
</button>
<Link to="/" className="text-base font-semibold text-black dark:text-white">
LaunchPad
</Link>
</div>
<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) => {
const active = pathname.startsWith(item.to);
const active = item.to === "/" ? pathname === "/" : pathname.startsWith(item.to);
return (
<Link
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"
}`}
>
<span aria-hidden>{item.icon}</span>
<span className="w-4 text-center" aria-hidden>
<FontAwesomeIcon icon={item.icon} />
</span>
{item.label}
{item.to === "/admin/scanner" ? <ScanActivityBadge /> : null}
</Link>
);
})}
</nav>
<div className="border-t border-black/10 p-3 dark:border-white/10">
<Link
to="/"
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium
text-black/60 transition-colors hover:bg-black/5 hover:text-black
dark:text-white/60 dark:hover:bg-white/5 dark:hover:text-white"
>
<span aria-hidden></span>
Zurück zur Suche
</Link>
<div className="shrink-0 border-t border-black/10 p-3 dark:border-white/10">
<div className="px-3 py-1.5">
<StatusBadge
online={isOnline}
label={isOnline ? "Backend verbunden" : "Backend nicht erreichbar"}
/>
</div>
</div>
</aside>
<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 />
</main>
<ScrollToTopButton />
</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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFloppyDisk, faPen, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@launchpad/ui";
import type { Category } from "@launchpad/shared";
import { useCategories } from "../../hooks/useCategories.js";
import { AdminPageHeader } from "./AdminPageHeader.js";
async function createCategory(name: string): Promise<Category> {
async function createCategory(name: string, color?: string): Promise<Category> {
const res = await fetch("/api/categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
body: JSON.stringify({ name, color }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
@@ -18,14 +20,15 @@ async function createCategory(name: string): Promise<Category> {
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}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
body: JSON.stringify(patch),
});
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();
}
@@ -67,13 +70,18 @@ function CategoryRow({
const [name, setName] = useState(category.name);
const renameMutation = useMutation({
mutationFn: () => renameCategory(category.id, name.trim()),
mutationFn: () => patchCategory(category.id, { name: name.trim() }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
setEditing(false);
},
});
const colorMutation = useMutation({
mutationFn: (color: string) => patchCategory(category.id, { color }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["categories"] }),
});
const deleteMutation = useMutation({
mutationFn: () => deleteCategoryRequest(category.id),
onSuccess: () => {
@@ -96,6 +104,20 @@ function CategoryRow({
</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 ? (
<>
<input
@@ -105,22 +127,14 @@ function CategoryRow({
className="flex-1 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
dark:border-white/10 dark:bg-white/10 dark:text-white"
/>
<Button size="sm" variant="primary" onClick={() => renameMutation.mutate()} disabled={renameMutation.isPending}>
Speichern
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
Abbrechen
</Button>
<Button size="icon" variant="primary" onClick={() => renameMutation.mutate()} disabled={renameMutation.isPending} title="Speichern" aria-label="Speichern"><FontAwesomeIcon icon={faFloppyDisk} /></Button>
<Button size="icon" variant="ghost" onClick={() => setEditing(false)} title="Abbrechen" aria-label="Abbrechen"><FontAwesomeIcon icon={faXmark} /></Button>
</>
) : (
<>
<span className="flex-1 text-sm font-medium text-black dark:text-white">{category.name}</span>
<Button size="sm" onClick={() => setEditing(true)}>
Umbenennen
</Button>
<Button size="sm" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending}>
Löschen
</Button>
<Button size="icon" onClick={() => setEditing(true)} title="Umbenennen" aria-label="Umbenennen"><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>
</>
)}
</li>
@@ -131,13 +145,15 @@ export function CategoriesPage() {
const { data: categories, isLoading, isError } = useCategories();
const queryClient = useQueryClient();
const [newName, setNewName] = useState("");
const [newColor, setNewColor] = useState("#94a3b8");
const [draggedId, setDraggedId] = useState<string | null>(null);
const [localOrder, setLocalOrder] = useState<Category[] | null>(null);
const createMutation = useMutation({
mutationFn: () => createCategory(newName.trim()),
mutationFn: () => createCategory(newName.trim(), newColor),
onSuccess: () => {
setNewName("");
setNewColor("#94a3b8");
queryClient.invalidateQueries({ queryKey: ["categories"] });
},
});
@@ -193,10 +209,22 @@ export function CategoriesPage() {
<div>
<AdminPageHeader
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>
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
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 { useDevices } from "../../hooks/useDevices.js";
import { useCategories } from "../../hooks/useCategories.js";
import { useBookmarks } from "../../hooks/useBookmarks.js";
import { useReadLater } from "../../hooks/useReadLater.js";
import { AdminPageHeader } from "./AdminPageHeader.js";
function StatCard({ label, value }: { label: string; value: number | string }) {
@@ -16,9 +21,12 @@ export function DashboardPage() {
const { data: services } = useServices();
const { data: devices } = useDevices();
const { data: categories } = useCategories();
const { data: bookmarks } = useBookmarks();
const { data: readLaterItems } = useReadLater();
const onlineDevices = devices?.filter((d) => d.online).length ?? 0;
const favoriteServices = services?.filter((s) => s.favorite).length ?? 0;
const favoriteBookmarks = bookmarks?.filter((b) => b.favorite).length ?? 0;
const recentlyScanned = [...(devices ?? [])]
.filter((d) => d.lastScan)
@@ -30,13 +38,27 @@ export function DashboardPage() {
<AdminPageHeader
title="Dashboard"
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="davon online" value={onlineDevices} />
<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 className="mt-8">
@@ -49,6 +71,7 @@ export function DashboardPage() {
</p>
) : (
<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">
<tbody>
{recentlyScanned.map((device) => (
@@ -56,11 +79,11 @@ export function DashboardPage() {
key={device.id}
className="border-b border-black/5 last:border-0 dark:border-white/5"
>
<td className="px-4 py-3 font-medium text-black dark:text-white">
<td className="whitespace-nowrap px-4 py-3 font-medium text-black dark:text-white">
{device.hostname}
</td>
<td className="px-4 py-3 text-black/40 dark:text-white/40">{device.ip}</td>
<td className="px-4 py-3 text-black/40 dark:text-white/40">
<td className="whitespace-nowrap 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.lastScan ? new Date(device.lastScan).toLocaleString("de-DE") : ""}
</td>
</tr>
@@ -68,16 +91,8 @@ export function DashboardPage() {
</tbody>
</table>
</div>
)}
</div>
<div className="mt-8">
<h2 className="mb-3 text-sm font-semibold text-black/60 dark:text-white/60">
Kategorien
</h2>
<p className="text-sm text-black/40 dark:text-white/40">
{categories?.length ?? 0} Kategorie(n) angelegt.
</p>
)}
</div>
</div>
);

View File

@@ -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 { 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 { AdminPageHeader } from "./AdminPageHeader.js";
@@ -17,6 +20,18 @@ async function createDevice(input: { hostname: string; ip: string }) {
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) {
const res = await fetch(`/api/devices/${id}`, { method: "DELETE" });
if (!res.ok && res.status !== 404) {
@@ -24,14 +39,48 @@ async function deleteDevice(id: string) {
}
}
interface ScanResult {
scannedPorts: number;
created: number;
updated: number;
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})`);
}
}
async function scanDevice(id: string): Promise<ScanResult> {
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST" });
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();
}
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();
if (!res.ok) {
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;
}
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 }) {
const queryClient = useQueryClient();
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({
mutationFn: () => scanDevice(device.id),
mutationFn: (full: boolean) => {
const controller = new AbortController();
setScanAbort(controller);
return scanDevice(device.id, controller.signal, full);
},
onSuccess: (result) => {
const portsText = result.ports.length > 0 ? result.ports.join(", ") : "keine";
const pending = result.nameChanges.length + result.staleServices.length;
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: ["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({
@@ -60,12 +362,29 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["devices"] }),
});
if (editing) {
return <EditDeviceForm device={device} onDone={() => setEditing(false)} />;
}
return (
<>
<tr className="border-b border-black/5 last:border-0 dark:border-white/5">
<td className="px-4 py-3">
<div className="font-medium text-black dark:text-white">{device.hostname}</div>
<div className="text-xs text-black/40 dark:text-white/40">{device.ip}</div>
<button
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 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">
<span
className={`inline-flex items-center gap-1.5 text-xs ${
@@ -77,39 +396,109 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
/>
{device.online ? "Online" : "Offline"}
</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 className="px-4 py-3 text-black/60 dark:text-white/60">{device.services.length}</td>
<td className="px-4 py-3 text-xs text-black/40 dark:text-white/40">
{device.lastScan ? new Date(device.lastScan).toLocaleString("de-DE") : "nie gescannt"}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
{scanMessage ? (
<span className="max-w-[16rem] truncate text-xs text-black/40 dark:text-white/40" title={scanMessage}>
{scanMessage}
</span>
) : null}
<Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"><FontAwesomeIcon icon={faPen} /></Button>
<Button
size="sm"
onClick={() => {
setScanMessage(null);
scanMutation.mutate();
setScanMessage("Portscan läuft (alle 65535 Ports) ");
scanMutation.mutate(true);
}}
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"}
</Button>
{scanMutation.isPending && scanAbort ? (
<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"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
>
Löschen
</Button>
disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
</div>
</td>
</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 (
<form onSubmit={handleSubmit} className="flex items-end gap-2">
<form onSubmit={handleSubmit} className="flex flex-wrap items-end gap-2">
<div>
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
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() {
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 (
<div>
<AdminPageHeader
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">
@@ -189,26 +653,31 @@ export function DevicesPage() {
<p className="text-sm text-black/40 dark:text-white/40">Lade Geräte …</p>
) : isError ? (
<p className="text-sm text-red-500">Geräte konnten nicht geladen werden.</p>
) : devices && devices.length > 0 ? (
) : list.length > 0 ? (
<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>
<tr className="border-b border-black/10 bg-black/[0.02] text-left text-xs
uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
<th className="px-4 py-2 font-medium">Gerät</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium">Dienste</th>
<th className="px-4 py-2 font-medium">Letzter Scan</th>
<SortableHeader label="Gerät" column="hostname" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<SortableHeader label="IP" column="ip" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<SortableHeader label="MAC" column="mac" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<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" />
</tr>
</thead>
<tbody>
{devices.map((device) => (
{list.map((device) => (
<DeviceRow key={device.id} device={device} />
))}
</tbody>
</table>
</div>
</div>
) : (
<p className="text-sm text-black/40 dark:text-white/40">
Noch keine Geräte angelegt. Füge oben ein Gerät hinzu oder nutze den FritzBox-Scan

View File

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

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";
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 (
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<div className="flex items-start justify-between gap-3">
<div>
<AdminPageHeader title="Plugins" />
<div className="rounded-2xl border border-dashed border-black/15 p-8 text-center dark:border-white/15">
<p className="text-black/60 dark:text-white/60">
Das Plugin-System (Scanner registrieren, Geräte importieren, Menüs erweitern,
Icons bereitstellen) ist noch nicht gebaut.
</p>
<p className="mt-2 text-sm text-black/40 dark:text-white/40">
Geplant als eigener Commit siehe <code className="rounded bg-black/5 px-1 dark:bg-white/10">docs/ROADMAP.md</code>.
</p>
<h2 className="font-medium text-black dark:text-white">
{plugin.name} <span className="text-black/40 dark:text-white/40">v{plugin.version}</span>
</h2>
{plugin.description ? (
<p className="mt-1 text-sm text-black/50 dark:text-white/50">{plugin.description}</p>
) : null}
</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 { 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 type { Device, Service } from "@launchpad/shared";
import { useDevices } from "../../hooks/useDevices.js";
import type { DetectedApiEntry } from "../../hooks/useDetectedApis.js";
import { usePersistedState } from "../../hooks/usePersistedState.js";
import { AdminPageHeader } from "./AdminPageHeader.js";
async function scanFritzBox() {
const res = await fetch("/api/scan/fritzbox", { method: "POST" });
const body = await res.json();
if (!res.ok) {
throw new Error(body.error ?? `FritzBox-Scan fehlgeschlagen (HTTP ${res.status})`);
}
return body as { found: number };
interface DeviceChange {
deviceId: string;
deviceHostname: string;
field: "hostname" | "ip";
current: string;
suggested: string;
}
async function scanDeviceById(id: string) {
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST" });
const body = await 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(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() {
const queryClient = useQueryClient();
const { data: devices } = useDevices();
const [bulkStatus, setBulkStatus] = useState<string | null>(null);
const [bulkRunning, setBulkRunning] = useState(false);
const fritzboxMutation = useMutation({
mutationFn: scanFritzBox,
onSuccess: (result) => {
const [bulkStatus, setBulkStatus] = usePersistedState<string | null>("scanner:bulkStatus", null);
const [bulkRunning, setBulkRunning] = useState(false);
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: ["logs"] });
return result;
},
});
async function scanAllDevices() {
if (!devices || devices.length === 0) return;
setBulkRunning(true);
let created = 0;
let updated = 0;
for (const device of devices) {
try {
const result = await scanDeviceById(device.id);
created += result.created;
updated += result.updated;
setBulkStatus(`Scanne ${device.hostname} … (${created} neu, ${updated} aktualisiert bisher)`);
} else if (!s.running && s.lastError && s.startedAt !== getHandledResultId("scanner:fritzboxLastHandledAt")) {
setHandledResultId("scanner:fritzboxLastHandledAt", s.startedAt);
setFritzboxError(s.lastError);
}
} 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.`);
setBulkRunning(false);
poll();
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: ["services"] });
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 (
@@ -81,40 +729,106 @@ export function ScannerPage() {
<Button
variant="primary"
className="mt-4"
onClick={() => fritzboxMutation.mutate()}
disabled={fritzboxMutation.isPending}
onClick={startFritzboxScan}
disabled={fritzboxRunning}
>
{fritzboxMutation.isPending ? "Scanne …" : "FritzBox jetzt scannen"}
{fritzboxRunning ? "Scanne …" : "FritzBox jetzt scannen"}
</Button>
{fritzboxMutation.isSuccess ? (
<p className="mt-2 text-sm text-emerald-600 dark:text-emerald-400">
{fritzboxMutation.data.found} Gerät(e) gefunden.
{fritzboxSummary ? (
<div className="mt-2 text-sm">
{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>
) : null}
{fritzboxMutation.isError ? (
<p className="mt-2 text-sm text-red-500">{(fritzboxMutation.error as Error).message}</p>
<p className="text-black/50 dark:text-white/50">
{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}
{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 className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<h2 className="font-medium text-black dark:text-white">Alle Geräte scannen</h2>
<p className="mt-1 text-sm text-black/50 dark:text-white/50">
Führt den Netzwerk-Scan (DNS, Ports, Titel/Favicon, Softwareerkennung) nacheinander
für alle {devices?.length ?? 0} bekannten Geräte aus. Für ein einzelnes Gerät lieber
den Button in der Geräte-Tabelle nutzen.
Führt einen vollständigen Portscan (alle 65535 Ports, nicht nur die üblichen) samt
DNS/Titel/Favicon/Softwareerkennung nacheinander für alle {devices?.length ?? 0} bekannten
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>
<div className="mt-4 flex items-center gap-2">
<Button
variant="primary"
className="mt-4"
onClick={scanAllDevices}
disabled={bulkRunning || !devices || devices.length === 0}
>
{bulkRunning ? "Scanne …" : "Alle Geräte jetzt scannen"}
</Button>
{bulkRunning ? (
<Button
size="icon"
variant="ghost"
onClick={() => cancelBulkScan()}
title="Scan abbrechen"
aria-label="Scan abbrechen"
>
<FontAwesomeIcon icon={faXmark} />
</Button>
) : null}
</div>
{bulkStatus ? (
<p className="mt-2 text-sm text-black/50 dark:text-white/50">{bulkStatus}</p>
) : 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>
<ApiScannerCard />
</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 { Button } from "@launchpad/ui";
import { Button, Favicon, FaviconPicker } from "@launchpad/ui";
import type { Service } from "@launchpad/shared";
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";
interface ServicePatch {
@@ -11,6 +17,12 @@ interface ServicePatch {
alias?: string[];
order?: number;
favorite?: boolean;
visible?: boolean;
hostname?: string;
port?: number;
https?: boolean;
url?: string;
favicon?: string | null;
}
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),
});
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();
}
@@ -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 }) {
const queryClient = useQueryClient();
const { data: allServices } = useServices();
const { data: allBookmarks } = useBookmarks();
const [displayName, setDisplayName] = useState(service.displayName);
const [category, setCategory] = useState(service.category ?? "");
const [alias, setAlias] = useState(service.alias.join(", "));
const [order, setOrder] = useState(String(service.order));
const [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({
mutationFn: () =>
patchService(service.id, {
displayName: displayName.trim(),
category: category.trim() || null,
mutationFn: () => {
const patch: Record<string, unknown> = {
alias: alias
.split(",")
.map((a) => a.trim())
.filter(Boolean),
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: () => {
queryClient.invalidateQueries({ queryKey: ["services"] });
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={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>
<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
@@ -71,12 +235,7 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
</div>
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Kategorie</label>
<input
value={category}
onChange={(e) => setCategory(e.target.value)}
className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
dark:border-white/10 dark:bg-white/10 dark:text-white"
/>
<CategorySelect value={category} onChange={setCategory} />
</div>
<div>
<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"
/>
</div>
<Button size="sm" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending}>
Speichern
</Button>
<Button size="sm" variant="ghost" onClick={onDone}>
Abbrechen
</Button>
<div className="w-full border-t border-black/10 pt-3 dark:border-white/10" />
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">
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>
</td>
</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 [editing, setEditing] = useState(false);
@@ -120,6 +341,11 @@ function ServiceRow({ service }: { service: Service }) {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
});
const visibleMutation = useMutation({
mutationFn: () => patchService(service.id, { visible: !service.visible }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
});
const deleteMutation = useMutation({
mutationFn: () => deleteServiceRequest(service.id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
@@ -130,24 +356,98 @@ function ServiceRow({ service }: { service: Service }) {
}
return (
<tr className="border-b border-black/5 last:border-0 dark:border-white/5">
<td className="px-4 py-3">
<tr
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
onClick={() => favoriteMutation.mutate()}
aria-label={service.favorite ? "Favorit entfernen" : "Als Favorit markieren"}
className={`text-lg ${service.favorite ? "text-amber-500" : "text-black/15 hover:text-amber-400 dark:text-white/15"}`}
>
<FontAwesomeIcon icon={service.favorite ? faStarSolid : faStar} />
</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 className="px-4 py-3">
<div className="font-medium text-black dark:text-white">{service.displayName}</div>
<div className="text-xs text-black/40 dark:text-white/40">{service.hostname}</div>
<div className="flex items-center gap-2">
<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 className="px-4 py-3 text-black/60 dark:text-white/60">{service.category ?? ""}</td>
<td className="px-4 py-3 text-black/60 dark:text-white/60">
{service.alias.length > 0 ? service.alias.join(", ") : ""}
</td>
<td className="px-4 py-3 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">
<a
href={service.url}
@@ -160,57 +460,360 @@ function ServiceRow({ service }: { service: Service }) {
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<Button size="sm" onClick={() => setEditing(true)}>
Bearbeiten
</Button>
<Button size="sm" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending}>
Löschen
</Button>
<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" | "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() {
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 (
<div>
<AdminPageHeader
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 ? (
<p className="text-sm text-black/40 dark:text-white/40">Lade Dienste </p>
) : isError ? (
<p className="text-sm text-red-500">Dienste konnten nicht geladen werden.</p>
) : services && services.length > 0 ? (
) : list.length > 0 ? (
<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>
<tr className="border-b border-black/10 bg-black/[0.02] text-left text-xs
uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
<th className="px-4 py-2 font-medium" />
<th className="px-4 py-2 font-medium">Dienst</th>
<th className="px-4 py-2 font-medium">Kategorie</th>
<th className="px-4 py-2 font-medium">Alias</th>
<th className="px-2 py-2" />
<th className="px-2 py-2" />
<SortableHeader label="Dienst" column="displayName" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<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" />
</tr>
</thead>
<tbody>
{services.map((service) => (
<ServiceRow key={service.id} service={service} />
{list.map((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>
</table>
</div>
</div>
) : (
<p className="text-sm text-black/40 dark:text-white/40">
Noch keine Dienste vorhanden. Scanne ein Gerät unter Geräte, um automatisch welche
zu finden.
{activeTab === "hidden"
? "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>
)}
</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 { useTheme } from "../../hooks/useTheme.js";
import { useSettings } from "../../hooks/useSettings.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 }) {
return (
<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() {
const { health, error } = useBackendHealth();
const [theme, toggleTheme] = useTheme();
const { data: settings } = useSettings();
return (
<div>
<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">
<h2 className="mb-2 font-medium text-black dark:text-white">Darstellung</h2>
<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
dark:hover:bg-white/10"
>
{theme === "dark" ? "🌙 Dunkel" : "☀️ Hell"} wechseln
<FontAwesomeIcon icon={theme === "dark" ? faMoon : faSun} /> {theme === "dark" ? "Dunkel" : "Hell"} wechseln
</button>
</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 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">
<h2 className="mb-2 font-medium text-black dark:text-white">Backend</h2>
{error ? (
@@ -43,12 +570,14 @@ export function SettingsPage() {
<div>
<InfoRow label="Status" value={health.status} />
<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>
) : (
<p className="text-sm text-black/40 dark:text-white/40">Lade </p>
)}
</div>
<DangerZone />
</div>
</div>
);

View File

@@ -18,6 +18,19 @@ export default {
"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: [],

View File

@@ -35,8 +35,15 @@ export default defineConfig({
],
},
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",
navigateFallbackDenylist: [/^\/api\//, /^\/ca\.crt$/],
runtimeCaching: [
{
// Zuletzt geladene Geräte/Dienste/Kategorien bleiben offline verfügbar

View File

@@ -15,8 +15,17 @@ services:
- NODE_ENV=production
volumes:
- launchpad-data:/data
- ./apps/backend/plugins:/app/plugins:ro
ports:
- "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:
build:
@@ -24,11 +33,23 @@ services:
dockerfile: apps/frontend/Dockerfile
container_name: launchpad-frontend
restart: unless-stopped
env_file:
- path: ./.env
required: false
depends_on:
- backend
ports:
- "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:
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"-
> 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,
Erweitern von Menüs, Bereitstellen von Icons
- Plugins liegen als Ordner unter `apps/backend/plugins/*`, jeweils mit einer
`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.
*/
import { deviceSourceValues } from "./schemas.js";
export * from "./schemas.js";
@@ -18,9 +20,11 @@ export interface Device {
online: boolean;
source: DeviceSource;
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 {
id: string;
@@ -28,6 +32,8 @@ export interface Service {
displayName: string;
hostname: string;
url: string;
/** Zuletzt geändert (auch durch reinen Scan-Fund, siehe upsertServiceFromScan) - ISO-8601. */
updatedAt: string;
https: boolean;
port: number;
category: string | null;
@@ -37,6 +43,29 @@ export interface Service {
favorite: boolean;
alias: string[];
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 {
@@ -50,17 +79,107 @@ export interface Category {
id: string;
name: string;
order: number;
/** Hex-Farbe (z. B. "#3b82f6"), zeigt sich als Punkt in Suche/Favoriten. */
color: string | null;
}
export interface ScanLogEntry {
id: string;
type: "device" | "fritzbox";
type: "device" | "fritzbox" | "api";
targetId: string | null;
level: "info" | "error";
message: 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:
* 1. Displayname beginnt mit Suchtext
@@ -68,50 +187,122 @@ export interface ScanLogEntry {
* 3. Hostname beginnt mit Suchtext
* 4. Displayname 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.
* 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();
if (q.length === 0) return null;
const displayName = service.displayName.toLowerCase();
const hostname = service.hostname.toLowerCase();
const description = (service.description ?? "").toLowerCase();
const alias = service.alias.map((a) => a.toLowerCase());
const displayName = item.displayName.toLowerCase();
const hostname = item.hostname.toLowerCase();
const deviceHostname = (item.deviceHostname ?? "").toLowerCase();
const description = (item.description ?? "").toLowerCase();
const alias = item.alias.map((a) => a.toLowerCase());
if (displayName.startsWith(q)) return 1;
if (alias.some((a) => a.startsWith(q))) return 2;
if (hostname.startsWith(q)) return 3;
if (hostname.startsWith(q) || deviceHostname.startsWith(q)) return 3;
if (displayName.includes(q)) return 4;
if (alias.some((a) => a.includes(q))) return 5;
if (description.includes(q)) return 6;
if (hostname.includes(q) || deviceHostname.includes(q)) return 6;
if (description.includes(q)) return 7;
return null;
}
/**
* Sortiert und filtert eine Liste von Diensten anhand des Suchtexts.
* Favoriten werden bei gleichem Rang bevorzugt, danach die definierte Reihenfolge.
* Sortiert und filtert eine Liste (Dienste, Lesezeichen oder eine Mischung
* ü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();
if (q.length === 0) {
return [...services].sort((a, b) => {
return [...items].sort((a, b) => {
if (a.favorite !== b.favorite) return a.favorite ? -1 : 1;
return a.order - b.order;
});
}
return services
.map((service) => ({ service, rank: rankService(service, q) }))
.filter((entry): entry is { service: Service; rank: number } => entry.rank !== null)
return items
.map((item) => ({ item, rank: rankService(item, q) }))
.filter((entry): entry is { item: T; rank: number } => entry.rank !== null)
.sort((a, b) => {
if (a.rank !== b.rank) return a.rank - b.rank;
if (a.service.favorite !== b.service.favorite) return a.service.favorite ? -1 : 1;
return a.service.order - b.service.order;
if (a.item.favorite !== b.item.favorite) return a.item.favorite ? -1 : 1;
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",
"portscan",
"manual",
"plugin",
] as const;
export const DeviceSourceSchema = z.enum(deviceSourceValues);
@@ -39,16 +40,50 @@ export const ServiceCreateSchema = z.object({
favorite: z.boolean().optional(),
alias: z.array(z.string()).optional(),
order: z.number().optional(),
visible: z.boolean().optional(),
});
export type ServiceCreateInput = z.infer<typeof ServiceCreateSchema>;
export const ServiceUpdateSchema = ServiceCreateSchema.omit({
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>;
/** 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({
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>;
@@ -65,3 +100,39 @@ export const CategoryReorderSchema = z
)
.min(1, "mindestens ein Eintrag erforderlich");
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"
},
"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:*",
"react": "^18.3.1"
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"typescript": "^5.5.4"
}
}

View File

@@ -2,7 +2,7 @@ import { forwardRef, type ButtonHTMLAttributes } from "react";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md";
size?: "sm" | "md" | "icon";
}
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> = {
sm: "px-3 py-1.5 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>(

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 type { Service } from "@launchpad/shared";
import { useState, type KeyboardEvent } from "react";
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 {
services: Service[];
selectedIndex: number;
emptyLabel?: string;
onHover: (index: number) => void;
onOpen: (service: Service) => void;
onToggleFavorite?: (service: Service) => void;
function hexToRgba(hex: string, alpha: number): string | null {
const clean = hex.replace("#", "");
if (clean.length !== 6) return null;
const r = parseInt(clean.slice(0, 2), 16);
const g = parseInt(clean.slice(2, 4), 16);
const b = parseInt(clean.slice(4, 6), 16);
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
* 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({
services,
results,
selectedIndex,
emptyLabel = "Keine Dienste gefunden.",
emptyLabel = "Keine Treffer gefunden.",
categoryColors,
onHover,
onOpen,
onToggleFavorite,
}: ResultsListProps) {
if (services.length === 0) {
if (results.length === 0) {
return (
<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"
>
{emptyLabel}
@@ -37,74 +79,100 @@ export function ResultsList({
return (
<ul
role="listbox"
className="mt-4 flex max-h-[60vh] flex-col overflow-y-auto rounded-2xl border
border-black/10 bg-white/80 shadow-lg backdrop-blur-md dark:border-white/10
dark:bg-white/5"
className="flex max-h-full flex-col overflow-y-auto rounded-2xl border border-black/10
bg-white/80 shadow-lg backdrop-blur-md dark:border-white/10 dark:bg-white/5"
>
{services.map((service, index) => {
{results.map((item, index) => {
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 (
<li key={service.id} role="option" aria-selected={active}>
<li key={`${item.kind}-${item.id}`} role="option" aria-selected={active}>
<div
tabIndex={-1}
onMouseEnter={() => onHover(index)}
onClick={() => onOpen(service)}
onClick={() => !isDevice && onOpen(item)}
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
transition-colors ${
active
style={
categoryColor && !isDevice
? { 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"
: "hover:bg-black/[0.03] dark:hover:bg-white/5"
}`
}`}
>
{service.favicon ? (
<img src={service.favicon} alt="" className="h-5 w-5 shrink-0 rounded" />
) : (
<span
className="flex h-5 w-5 shrink-0 items-center justify-center rounded
bg-black/10 text-[10px] font-medium text-black/50 dark:bg-white/10
dark:text-white/50"
>
{service.displayName.charAt(0).toUpperCase()}
</span>
)}
<Favicon src={item.favicon} fallbackLetter={item.displayName} />
<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">
{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 className="block truncate text-xs text-black/40 dark:text-white/40">
{service.hostname}
{subtitle}
</span>
</span>
{service.category ? (
<span className="shrink-0 text-xs text-black/30 dark:text-white/30">
{service.category}
{item.category ? (
<span className="flex shrink-0 items-center gap-1 text-xs text-black/30 dark:text-white/30">
{categoryColor ? (
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: categoryColor }}
aria-hidden
/>
) : null}
{item.category}
</span>
) : null}
{!isDevice ? <CopyButton url={item.url} /> : null}
{item.kind === "service" || item.kind === "bookmark" ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleFavorite?.(service);
onToggleFavorite?.(item);
}}
disabled={!onToggleFavorite}
aria-pressed={service.favorite}
aria-label={
service.favorite ? "Als Favorit entfernen" : "Als Favorit markieren"
}
aria-pressed={item.favorite}
aria-label={item.favorite ? "Als Favorit entfernen" : "Als Favorit markieren"}
className={`shrink-0 text-lg leading-none transition-colors ${
service.favorite
item.favorite
? "text-amber-500"
: "text-black/15 hover:text-amber-400 dark:text-white/15 dark:hover:text-amber-400"
} ${onToggleFavorite ? "" : "cursor-default"}`}
>
<FontAwesomeIcon icon={item.favorite ? faStarSolid : faStar} />
</button>
) : null}
</div>
</li>
);
@@ -112,3 +180,31 @@ export function ResultsList({
</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"> {
/** Wird links im Suchfeld angezeigt, z. B. ein Tastaturkürzel-Hinweis. */
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.
*/
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 (
<div
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"
{...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 ? (
<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">

View File

@@ -9,3 +9,12 @@ export type { ResultsListProps } from "./ResultsList.js";
export { Button } 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",
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler"
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src"]
}

132
pnpm-lock.yaml generated
View File

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