generated from Dicken/dickendock
Compare commits
74 Commits
6a7eb3dfe5
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 50cab7ff0c | |||
| ee7bd9fcbf | |||
| 1e90eb2905 | |||
| 0a1052f217 | |||
| 2ed39d6865 | |||
| 3e27c54199 | |||
| 8046120548 | |||
| ff87f9812c | |||
| 538fba5fc3 | |||
| c05562c4cd | |||
| 9abfdba707 | |||
| fb5874d7d0 | |||
| eef4ddc775 | |||
| a7036849b1 | |||
| 403a052a0f | |||
| 0a1f3143d7 | |||
| 75bc5725e4 | |||
| ca8c8c1d3b | |||
| c7398ce2ba | |||
| edc4acaf3b | |||
| 2bc5d9550c | |||
| 8dd3115353 | |||
| 0287a7cc5c | |||
| cf31ea4402 | |||
| 28fedb2572 | |||
| 2e15669098 | |||
| eed5089a56 | |||
| 6792f5b7ea | |||
| 59bb4a30b8 | |||
| d4ff497c8c | |||
| 6457475e26 | |||
| 301c25571a | |||
| 505353cd26 | |||
| 5deeeaec1d | |||
| 38f09e862e | |||
| 45a01bd32e | |||
| b32a60be64 | |||
| 645dcf3af8 | |||
| fedb3e9127 | |||
| 70da526921 | |||
| ba057e7911 | |||
| e4cc6987be | |||
| 87fed21348 | |||
| 2106c17301 | |||
| f5845ec762 | |||
| 430b79763c | |||
| 0fc063b2a2 | |||
| 458f58af6f | |||
| 6a2bf24498 | |||
| d87885f16f | |||
| b098007742 | |||
| 6a66128172 | |||
| 68ca9ec5e1 | |||
| ec49534b60 | |||
| bae9e48fc1 | |||
| 050616a5cf | |||
| 7035d4ebda | |||
| 70edeeed38 | |||
| 2772c29036 | |||
| 1f8218dff1 | |||
| 54f224b650 | |||
| 5f150119b2 | |||
| 7cf64404c5 | |||
| 724f076c8c | |||
| 0580d46000 | |||
| cc4b285aea | |||
| 96cf29fef4 | |||
| dc91a9aba9 | |||
| 31e17ff77b | |||
| a695159b1d | |||
| fc1ea4fa70 | |||
| 20ea4d382d | |||
| d65897b8e1 | |||
| 986de50963 |
11
.env.example
11
.env.example
@@ -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
209
README.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
78
apps/backend/plugins/README.md
Normal file
78
apps/backend/plugins/README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# LaunchPad-Plugins
|
||||
|
||||
Jeder Unterordner hier ist ein Plugin. Erkannt wird alles, was eine
|
||||
`index.js` mit einem passenden Default-Export enthält – kein Rebuild des
|
||||
Docker-Images nötig, dieser Ordner wird zur Laufzeit als Volume eingebunden
|
||||
(siehe `docker-compose.yml`). Nach dem Hinzufügen/Ändern eines Plugins
|
||||
reicht ein Neustart des Backend-Containers:
|
||||
|
||||
```bash
|
||||
docker compose restart backend
|
||||
```
|
||||
|
||||
## Minimalstruktur
|
||||
|
||||
```
|
||||
apps/backend/plugins/mein-plugin/
|
||||
└── index.js
|
||||
```
|
||||
|
||||
```js
|
||||
export default {
|
||||
name: "mein-plugin",
|
||||
version: "1.0.0",
|
||||
description: "Kurze Beschreibung, erscheint unter Admin -> Plugins",
|
||||
|
||||
// optional: Scanner erweitern
|
||||
setup(ctx) {
|
||||
ctx.registerSoftwareSignature({
|
||||
name: "Meine Software",
|
||||
category: "Sonstiges",
|
||||
icon: "🔧", // Emoji oder Icon-URL
|
||||
matches: (input) => !!input.body && /meine-software/i.test(input.body),
|
||||
});
|
||||
},
|
||||
|
||||
// optional: eigene Geräte-Importquelle
|
||||
async importDevices() {
|
||||
return [
|
||||
{ hostname: "beispiel", ip: "192.168.1.99", source: "plugin" },
|
||||
];
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Beide Funktionen (`setup`, `importDevices`) sind optional – ein Plugin kann
|
||||
nur die eine, nur die andere, oder beide implementieren. Reines JavaScript
|
||||
(ESM), kein Build-Schritt nötig.
|
||||
|
||||
## Was Plugins aktuell können
|
||||
|
||||
- **Scanner registrieren**: `ctx.registerSoftwareSignature(...)` in `setup()`
|
||||
fügt der automatischen Softwareerkennung (siehe `apps/backend/src/scanner/softwareDetection.ts`)
|
||||
eine zusätzliche Signatur hinzu, inklusive eigenem Icon. Wird bei jedem
|
||||
Geräte-Scan berücksichtigt.
|
||||
- **Geräte importieren**: `importDevices()` liefert eine Liste von Geräten,
|
||||
die per Knopfdruck unter Admin -> Plugins importiert werden
|
||||
(`POST /api/plugins/:name/import`). Abgeglichen wird wie bei Scans über
|
||||
MAC/IP – ein erneuter Import überschreibt keine Gerätefelder, die der
|
||||
Nutzer zwischenzeitlich geändert hat, außer den scan-eigenen (siehe
|
||||
`upsertDeviceFromScan`).
|
||||
- **Icons bereitstellen**: über `icon` an einer registrierten Signatur. Wird
|
||||
als Startwert für neu angelegte Dienste übernommen (wie `category` –
|
||||
niemals nachträglich überschrieben, siehe README Hauptprojekt).
|
||||
|
||||
## Was (noch) nicht geht
|
||||
|
||||
- Eigene Admin-Menüpunkte/Unterseiten (aktuell erscheinen alle Plugins
|
||||
gesammelt auf einer Seite unter Admin -> Plugins, keine eigene Route pro
|
||||
Plugin).
|
||||
- Kein Sandboxing: Plugin-Code läuft mit vollem Zugriff im Backend-Prozess.
|
||||
Nur Plugins aus vertrauenswürdiger Quelle einbinden.
|
||||
|
||||
## Beispiel-Plugins
|
||||
|
||||
- `example-signatures/` – registriert zwei zusätzliche Software-Signaturen
|
||||
(Homebridge, Uptime Kuma).
|
||||
- `static-import/` – importiert Geräte aus einer lokalen `devices.json`
|
||||
(standardmäßig leer, siehe `devices.example.json` für das Format).
|
||||
29
apps/backend/plugins/example-signatures/index.js
Normal file
29
apps/backend/plugins/example-signatures/index.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Beispiel-Plugin: registriert zwei zusätzliche Softwareerkennungen, die
|
||||
* nicht in der eingebauten Liste enthalten sind, inklusive eigenem Icon.
|
||||
*
|
||||
* Zeigt das setup()-Pattern: ein Plugin bekommt beim Laden einen Kontext mit
|
||||
* registerSoftwareSignature() und kann darüber den Scanner erweitern.
|
||||
*/
|
||||
export default {
|
||||
name: "example-signatures",
|
||||
version: "1.0.0",
|
||||
description:
|
||||
"Erkennt Homebridge und Uptime Kuma zusätzlich zu den eingebauten Signaturen.",
|
||||
|
||||
setup(ctx) {
|
||||
ctx.registerSoftwareSignature({
|
||||
name: "Homebridge",
|
||||
category: "Smart Home",
|
||||
icon: "🏠",
|
||||
matches: (input) => !!input.body && /homebridge/i.test(input.body),
|
||||
});
|
||||
|
||||
ctx.registerSoftwareSignature({
|
||||
name: "Uptime Kuma",
|
||||
category: "Monitoring",
|
||||
icon: "📈",
|
||||
matches: (input) => !!input.body && /uptime\s*kuma/i.test(input.body),
|
||||
});
|
||||
},
|
||||
};
|
||||
9
apps/backend/plugins/static-import/devices.example.json
Normal file
9
apps/backend/plugins/static-import/devices.example.json
Normal file
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"hostname": "nas",
|
||||
"ip": "192.168.1.20",
|
||||
"mac": "AA:BB:CC:DD:EE:FF",
|
||||
"manufacturer": "Synology",
|
||||
"model": "DS920+"
|
||||
}
|
||||
]
|
||||
1
apps/backend/plugins/static-import/devices.json
Normal file
1
apps/backend/plugins/static-import/devices.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
40
apps/backend/plugins/static-import/index.js
Normal file
40
apps/backend/plugins/static-import/index.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Beispiel-Plugin: importiert Geräte aus einer lokalen devices.json neben
|
||||
* diesem Plugin. Zeigt das importDevices()-Pattern für eigene Datenquellen
|
||||
* (z. B. ein Inventar-Export oder eine externe API) – im Unterschied zu den
|
||||
* Netzwerk-Scannern rein datengetrieben, kein eigener Netzwerkzugriff nötig.
|
||||
*
|
||||
* Standardmäßig ist devices.json leer, damit beim ersten Start keine
|
||||
* Fantasiegeräte auftauchen. Zum Ausprobieren einfach Einträge ergänzen und
|
||||
* unter Admin -> Plugins auf "Jetzt importieren" klicken.
|
||||
*/
|
||||
export default {
|
||||
name: "static-import",
|
||||
version: "1.0.0",
|
||||
description: "Importiert Geräte aus plugins/static-import/devices.json.",
|
||||
|
||||
async importDevices() {
|
||||
try {
|
||||
const raw = readFileSync(join(currentDir, "devices.json"), "utf-8");
|
||||
const entries = JSON.parse(raw);
|
||||
|
||||
return entries.map((entry) => ({
|
||||
hostname: entry.hostname,
|
||||
ip: entry.ip,
|
||||
mac: entry.mac ?? undefined,
|
||||
manufacturer: entry.manufacturer ?? undefined,
|
||||
model: entry.model ?? undefined,
|
||||
online: false,
|
||||
source: "plugin",
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
77
apps/backend/src/db/repositories/apis.ts
Normal file
77
apps/backend/src/db/repositories/apis.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import { detectedApis } from "../schema.js";
|
||||
|
||||
export interface DetectedApiEntry {
|
||||
id: string;
|
||||
serviceId: string;
|
||||
path: string;
|
||||
type: string;
|
||||
status: number;
|
||||
detectedAt: string;
|
||||
}
|
||||
|
||||
function mapRow(row: typeof detectedApis.$inferSelect): DetectedApiEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
serviceId: row.serviceId,
|
||||
path: row.path,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
detectedAt: row.detectedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function listDetectedApis(): DetectedApiEntry[] {
|
||||
return db.select().from(detectedApis).all().map(mapRow);
|
||||
}
|
||||
|
||||
export function getApisForService(serviceId: string): DetectedApiEntry[] {
|
||||
return db.select().from(detectedApis).where(eq(detectedApis.serviceId, serviceId)).all().map(mapRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersetzt die gespeicherten API-Funde eines Dienstes komplett durch die
|
||||
* neuen Ergebnisse eines Scan-Laufs - kein Anhäufen von veralteten
|
||||
* Einträgen, wenn sich z. B. der API-Pfad einer Software mal ändert.
|
||||
*
|
||||
* Liefert zusätzlich `added` zurück: die Einträge, die VORHER noch nicht
|
||||
* gespeichert waren (nach Pfad+Typ verglichen) - damit der Scanner auf der
|
||||
* Scanner-Seite nach dem ersten Mal nur noch NEUE/geänderte Funde anzeigen
|
||||
* kann, statt bei jedem erneuten Scan die komplette (meist unveränderte)
|
||||
* Liste erneut runterzurattern. Die vollständige Liste bleibt jederzeit
|
||||
* unter Admin -> APIs einsehbar.
|
||||
*/
|
||||
export function replaceApisForService(
|
||||
serviceId: string,
|
||||
found: { path: string; type: string; status: number }[]
|
||||
): { saved: DetectedApiEntry[]; added: DetectedApiEntry[] } {
|
||||
const before = getApisForService(serviceId);
|
||||
const beforeKeys = new Set(before.map((e) => `${e.path}::${e.type}`));
|
||||
|
||||
db.delete(detectedApis).where(eq(detectedApis.serviceId, serviceId)).run();
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const rows = found.map((f) => ({
|
||||
id: randomUUID(),
|
||||
serviceId,
|
||||
path: f.path,
|
||||
type: f.type,
|
||||
status: f.status,
|
||||
detectedAt: timestamp,
|
||||
}));
|
||||
|
||||
if (rows.length > 0) {
|
||||
db.insert(detectedApis).values(rows).run();
|
||||
}
|
||||
|
||||
const saved = rows.map(mapRow);
|
||||
const added = saved.filter((e) => !beforeKeys.has(`${e.path}::${e.type}`));
|
||||
|
||||
return { saved, added };
|
||||
}
|
||||
|
||||
export function deleteApisForService(serviceId: string): void {
|
||||
db.delete(detectedApis).where(eq(detectedApis.serviceId, serviceId)).run();
|
||||
}
|
||||
116
apps/backend/src/db/repositories/bookmarks.ts
Normal file
116
apps/backend/src/db/repositories/bookmarks.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Bookmark, BookmarkCreateInput, BookmarkUpdateInput } from "@launchpad/shared";
|
||||
import { db } from "../client.js";
|
||||
import { bookmarks } from "../schema.js";
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function mapRow(row: typeof bookmarks.$inferSelect): Bookmark {
|
||||
return {
|
||||
id: row.id,
|
||||
url: row.url,
|
||||
displayName: row.displayName,
|
||||
hostname: row.hostname,
|
||||
description: row.description,
|
||||
category: row.category,
|
||||
icon: row.icon,
|
||||
favicon: row.favicon,
|
||||
favorite: row.favorite,
|
||||
alias: JSON.parse(row.alias) as string[],
|
||||
order: row.order,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractHostname(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export function listBookmarks(): Bookmark[] {
|
||||
return db.select().from(bookmarks).all().map(mapRow);
|
||||
}
|
||||
|
||||
export function getBookmark(id: string): Bookmark | null {
|
||||
const row = db.select().from(bookmarks).where(eq(bookmarks.id, id)).get();
|
||||
return row ? mapRow(row) : null;
|
||||
}
|
||||
|
||||
export interface CreateBookmarkOptions {
|
||||
displayName: string;
|
||||
favicon?: string | null;
|
||||
}
|
||||
|
||||
export function createBookmark(
|
||||
input: BookmarkCreateInput,
|
||||
resolved: CreateBookmarkOptions
|
||||
): Bookmark {
|
||||
const id = randomUUID();
|
||||
const timestamp = nowIso();
|
||||
|
||||
db.insert(bookmarks)
|
||||
.values({
|
||||
id,
|
||||
url: input.url,
|
||||
displayName: resolved.displayName,
|
||||
hostname: extractHostname(input.url),
|
||||
description: input.description ?? null,
|
||||
category: input.category ?? null,
|
||||
icon: input.icon ?? null,
|
||||
favicon: resolved.favicon ?? null,
|
||||
favorite: input.favorite ?? false,
|
||||
alias: JSON.stringify(input.alias ?? []),
|
||||
order: input.order ?? 0,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
.run();
|
||||
|
||||
return getBookmark(id)!;
|
||||
}
|
||||
|
||||
export function updateBookmark(id: string, input: BookmarkUpdateInput): Bookmark | null {
|
||||
const existing = getBookmark(id);
|
||||
if (!existing) return null;
|
||||
|
||||
db.update(bookmarks)
|
||||
.set({
|
||||
...(input.url !== undefined && { url: input.url, hostname: extractHostname(input.url) }),
|
||||
...(input.displayName !== undefined && { displayName: input.displayName }),
|
||||
...(input.description !== undefined && { description: input.description }),
|
||||
...(input.category !== undefined && { category: input.category }),
|
||||
...(input.icon !== undefined && { icon: input.icon }),
|
||||
...(input.favicon !== undefined && { favicon: input.favicon }),
|
||||
...(input.favorite !== undefined && { favorite: input.favorite }),
|
||||
...(input.alias !== undefined && { alias: JSON.stringify(input.alias) }),
|
||||
...(input.order !== undefined && { order: input.order }),
|
||||
updatedAt: nowIso(),
|
||||
})
|
||||
.where(eq(bookmarks.id, id))
|
||||
.run();
|
||||
|
||||
return getBookmark(id);
|
||||
}
|
||||
|
||||
export function deleteBookmark(id: string): boolean {
|
||||
const result = db.delete(bookmarks).where(eq(bookmarks.id, id)).run();
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function reorderBookmarks(input: { id: string; order: number }[]): Bookmark[] {
|
||||
db.transaction((tx) => {
|
||||
for (const entry of input) {
|
||||
tx.update(bookmarks)
|
||||
.set({ order: entry.order, updatedAt: nowIso() })
|
||||
.where(eq(bookmarks.id, entry.id))
|
||||
.run();
|
||||
}
|
||||
});
|
||||
|
||||
return listBookmarks();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
77
apps/backend/src/db/repositories/readLater.ts
Normal file
77
apps/backend/src/db/repositories/readLater.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import { readLater } from "../schema.js";
|
||||
|
||||
export interface ReadLaterItem {
|
||||
id: string;
|
||||
url: string;
|
||||
displayName: string;
|
||||
favicon: string | null;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
function mapRow(row: typeof readLater.$inferSelect): ReadLaterItem {
|
||||
return {
|
||||
id: row.id,
|
||||
url: row.url,
|
||||
displayName: row.displayName,
|
||||
favicon: row.favicon,
|
||||
savedAt: row.savedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function listReadLater(): ReadLaterItem[] {
|
||||
return db.select().from(readLater).orderBy(desc(readLater.savedAt)).all().map(mapRow);
|
||||
}
|
||||
|
||||
export function getReadLaterItem(id: string): ReadLaterItem | null {
|
||||
const row = db.select().from(readLater).where(eq(readLater.id, id)).get();
|
||||
return row ? mapRow(row) : null;
|
||||
}
|
||||
|
||||
export function createReadLaterItem(input: {
|
||||
url: string;
|
||||
displayName: string;
|
||||
favicon?: string | null;
|
||||
}): ReadLaterItem {
|
||||
const id = randomUUID();
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
db.insert(readLater)
|
||||
.values({
|
||||
id,
|
||||
url: input.url,
|
||||
displayName: input.displayName,
|
||||
favicon: input.favicon ?? null,
|
||||
savedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
.run();
|
||||
|
||||
return getReadLaterItem(id)!;
|
||||
}
|
||||
|
||||
export function updateReadLaterItem(
|
||||
id: string,
|
||||
input: { url?: string; displayName?: string }
|
||||
): ReadLaterItem | null {
|
||||
const existing = getReadLaterItem(id);
|
||||
if (!existing) return null;
|
||||
|
||||
db.update(readLater)
|
||||
.set({
|
||||
...(input.url !== undefined && { url: input.url }),
|
||||
...(input.displayName !== undefined && { displayName: input.displayName }),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(readLater.id, id))
|
||||
.run();
|
||||
|
||||
return getReadLaterItem(id);
|
||||
}
|
||||
|
||||
export function deleteReadLaterItem(id: string): boolean {
|
||||
const result = db.delete(readLater).where(eq(readLater.id, id)).run();
|
||||
return result.changes > 0;
|
||||
}
|
||||
78
apps/backend/src/db/repositories/recentVisits.ts
Normal file
78
apps/backend/src/db/repositories/recentVisits.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import type { Bookmark, Service } from "@launchpad/shared";
|
||||
import { db } from "../client.js";
|
||||
import { recentVisits } from "../schema.js";
|
||||
import * as serviceRepo from "./services.js";
|
||||
import * as bookmarkRepo from "./bookmarks.js";
|
||||
import { getSetting } from "./settings.js";
|
||||
|
||||
export type VisitedItem = (Service & { kind: "service" }) | (Bookmark & { kind: "bookmark" });
|
||||
|
||||
export function recordVisit(itemType: "service" | "bookmark", itemId: string): void {
|
||||
db.insert(recentVisits)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
itemType,
|
||||
itemId,
|
||||
visitedAt: new Date().toISOString(),
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT = 5;
|
||||
|
||||
export function getRecentVisitsLimit(): number {
|
||||
const stored = getSetting("recentVisitsLimit");
|
||||
const parsed = stored ? Number(stored) : DEFAULT_LIMIT;
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LIMIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die zuletzt besuchten Dienste/Lesezeichen, jedes Element nur
|
||||
* einmal (neuester Besuch zählt), aufgelöst zu den echten Objekten. Bereits
|
||||
* gelöschte Ziele werden übersprungen. Limit kommt aus den Einstellungen
|
||||
* (Admin -> Einstellungen), Default 5.
|
||||
*/
|
||||
export function listRecentVisits(limitOverride?: number): VisitedItem[] {
|
||||
const limit = limitOverride ?? getRecentVisitsLimit();
|
||||
|
||||
// Etwas mehr als das Limit an Rohzeilen lesen, falls Duplikate/gelöschte
|
||||
// Ziele darunter sind – reicht in der Praxis (Homelab-Größenordnung) locker.
|
||||
const rows = db
|
||||
.select()
|
||||
.from(recentVisits)
|
||||
.orderBy(desc(recentVisits.visitedAt))
|
||||
.limit(limit * 5 + 20)
|
||||
.all();
|
||||
|
||||
const seen = new Set<string>();
|
||||
const result: VisitedItem[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const key = `${row.itemType}:${row.itemId}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
if (row.itemType === "service") {
|
||||
const service = serviceRepo.getService(row.itemId);
|
||||
if (service) result.push({ ...service, kind: "service" });
|
||||
} else if (row.itemType === "bookmark") {
|
||||
const bookmark = bookmarkRepo.getBookmark(row.itemId);
|
||||
if (bookmark) result.push({ ...bookmark, kind: "bookmark" });
|
||||
}
|
||||
|
||||
if (result.length >= limit) break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function clearRecentVisits(): void {
|
||||
db.delete(recentVisits).run();
|
||||
}
|
||||
|
||||
/** Räumt Verweise auf eine gelöschte Ressource auf (verhindert totes Wachstum). */
|
||||
export function pruneVisitsFor(itemId: string): void {
|
||||
db.delete(recentVisits).where(eq(recentVisits.itemId, itemId)).run();
|
||||
}
|
||||
76
apps/backend/src/db/repositories/scanDismissals.ts
Normal file
76
apps/backend/src/db/repositories/scanDismissals.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import { scanDismissals } from "../schema.js";
|
||||
|
||||
/**
|
||||
* Art des bestätigten/verworfenen Scan-Vorschlags. "dedupeKey" identifiziert
|
||||
* das konkrete Element innerhalb dieser Art:
|
||||
* - "new_service" / "stale_service": Dienst-ID
|
||||
* - "stale_device": Geräte-ID
|
||||
* - "name_change": "<serviceId>:<feld>" (feld = "displayName" | "category")
|
||||
* - "device_change": "<deviceId>:<feld>" (feld = "hostname" | "ip")
|
||||
*/
|
||||
export type DismissalKind =
|
||||
| "new_service"
|
||||
| "stale_service"
|
||||
| "stale_device"
|
||||
| "name_change"
|
||||
| "device_change";
|
||||
|
||||
/**
|
||||
* Merkt einen Vorschlag als erledigt (bestätigt ODER bewusst verworfen -
|
||||
* beides bedeutet "nicht mehr anzeigen"). "INSERT OR IGNORE" über den
|
||||
* UNIQUE-Index (kind, dedupe_key, siehe db/client.ts) macht das
|
||||
* idempotent - ein zweifach gesendeter Dismiss (z. B. Doppelklick, oder zwei
|
||||
* Browser fast gleichzeitig) legt keinen doppelten Eintrag an.
|
||||
*/
|
||||
export function dismiss(kind: DismissalKind, dedupeKey: string): void {
|
||||
db.insert(scanDismissals)
|
||||
.values({ id: randomUUID(), kind, dedupeKey, dismissedAt: new Date().toISOString() })
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert alle bereits erledigten dedupeKeys für eine Vorschlags-Art - zum
|
||||
* Herausfiltern aus einem frischen Scan-Ergebnis (siehe routes/scan.ts),
|
||||
* bevor es an den Browser geschickt wird. Ein Set für O(1)-Lookups, da diese
|
||||
* Funktion typischerweise direkt vor einem .filter() über potenziell viele
|
||||
* Vorschläge aufgerufen wird.
|
||||
*/
|
||||
export function getDismissedKeys(kind: DismissalKind): Set<string> {
|
||||
const rows = db
|
||||
.select({ dedupeKey: scanDismissals.dedupeKey })
|
||||
.from(scanDismissals)
|
||||
.where(eq(scanDismissals.kind, kind))
|
||||
.all();
|
||||
return new Set(rows.map((r) => r.dedupeKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* Räumt alte Dismiss-Einträge für einen Vorschlag auf, sobald er durch einen
|
||||
* NEUEN Scan wieder aktuell werden könnte - z. B. wird ein Dienst, der einmal
|
||||
* als "verschwunden" bestätigt/verworfen wurde, bei einem künftigen Scan
|
||||
* erneut gefunden: dann soll ein danach ERNEUT verschwindender Dienst wieder
|
||||
* normal vorgeschlagen werden, nicht durch einen Jahre alten Dismiss-Eintrag
|
||||
* stumm bleiben. Wird von den Reposities (services.ts/devices.ts) beim
|
||||
* erneuten Auftauchen aufgerufen.
|
||||
*/
|
||||
export function clearDismissal(kind: DismissalKind, dedupeKey: string): void {
|
||||
db.delete(scanDismissals)
|
||||
.where(and(eq(scanDismissals.kind, kind), eq(scanDismissals.dedupeKey, dedupeKey)))
|
||||
.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie clearDismissal(), aber für mehrere dedupeKeys derselben Art auf einmal
|
||||
* (z. B. alle Ports eines Geräts, die im aktuellen Scan wieder gefunden
|
||||
* wurden) - vermeidet N einzelne DELETE-Statements.
|
||||
*/
|
||||
export function clearDismissals(kind: DismissalKind, dedupeKeys: string[]): void {
|
||||
if (dedupeKeys.length === 0) return;
|
||||
db.delete(scanDismissals)
|
||||
.where(and(eq(scanDismissals.kind, kind), inArray(scanDismissals.dedupeKey, dedupeKeys)))
|
||||
.run();
|
||||
}
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
22
apps/backend/src/db/repositories/settings.ts
Normal file
22
apps/backend/src/db/repositories/settings.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import { appSettings } from "../schema.js";
|
||||
|
||||
export function getSetting(key: string): string | null {
|
||||
const row = db.select().from(appSettings).where(eq(appSettings.key, key)).get();
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export function setSetting(key: string, value: string): void {
|
||||
const existing = getSetting(key);
|
||||
if (existing === null) {
|
||||
db.insert(appSettings).values({ key, value }).run();
|
||||
} else {
|
||||
db.update(appSettings).set({ value }).where(eq(appSettings.key, key)).run();
|
||||
}
|
||||
}
|
||||
|
||||
export function listSettings(): Record<string, string> {
|
||||
const rows = db.select().from(appSettings).all();
|
||||
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
52
apps/backend/src/liveStatus.ts
Normal file
52
apps/backend/src/liveStatus.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as deviceRepo from "./db/repositories/devices.js";
|
||||
import * as settingsRepo from "./db/repositories/settings.js";
|
||||
import { pingHost } from "./scanner/ping.js";
|
||||
|
||||
const CHECK_INTERVAL_MS = 60 * 1000;
|
||||
let lastRunAt = 0;
|
||||
let running = false;
|
||||
|
||||
/**
|
||||
* Optionaler Live-Status-Heartbeat: pingt (ICMP, siehe scanner/ping.ts) alle
|
||||
* bekannten Geräte in konfigurierbarem Abstand an und aktualisiert NUR deren
|
||||
* online-Feld + lastPing-Zeitstempel.
|
||||
*
|
||||
* Bewusst STRIKT getrennt vom "Scans laufen ausschließlich manuell"-Prinzip
|
||||
* (siehe Scanner-Seite): hier werden NIE Dienste neu angelegt, NIE Namen/
|
||||
* Kategorien vorgeschlagen, NIE etwas zur Bestätigung vorgelegt - einzig der
|
||||
* Online-Punkt neben einem Gerät wird aktualisiert. Standardmäßig
|
||||
* AUSGESCHALTET (liveStatusEnabled=false), muss in den Einstellungen bewusst
|
||||
* aktiviert werden.
|
||||
*
|
||||
* Statt eines echten setInterval mit dynamischer Laufzeit wird jede Minute
|
||||
* geprüft, ob genug Zeit seit dem letzten Lauf vergangen ist - so greift eine
|
||||
* Änderung des Intervalls in den Einstellungen beim nächsten Tick sofort,
|
||||
* ohne den Timer neu aufsetzen zu müssen.
|
||||
*/
|
||||
export function startLiveStatusHeartbeat(): void {
|
||||
setInterval(async () => {
|
||||
if (running) return; // vorheriger Lauf noch nicht fertig - überspringen statt zu stapeln
|
||||
|
||||
const settings = settingsRepo.listSettings();
|
||||
if (settings.liveStatusEnabled !== "true") return;
|
||||
|
||||
const intervalMinutes = Number(settings.liveStatusIntervalMinutes ?? 5);
|
||||
const intervalMs = Math.max(1, intervalMinutes) * 60 * 1000;
|
||||
if (Date.now() - lastRunAt < intervalMs) return;
|
||||
|
||||
running = true;
|
||||
lastRunAt = Date.now();
|
||||
try {
|
||||
const devices = deviceRepo.listDevices();
|
||||
// Nacheinander statt Promise.all, um das Netzwerk (und ggf. langsame
|
||||
// Router/IoT-Geräte) nicht mit vielen gleichzeitigen Pings zu fluten.
|
||||
for (const device of devices) {
|
||||
const online = await pingHost(device.ip);
|
||||
deviceRepo.updateDeviceOnlineStatus(device.id, online);
|
||||
}
|
||||
settingsRepo.setSetting("liveStatusLastRunAt", new Date().toISOString());
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}, CHECK_INTERVAL_MS);
|
||||
}
|
||||
71
apps/backend/src/plugins/loader.ts
Normal file
71
apps/backend/src/plugins/loader.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { registerSoftwareSignature } from "../scanner/softwareDetection.js";
|
||||
import type { LaunchPadPlugin } from "./types.js";
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// apps/backend/src/plugins/loader.ts (bzw. dist/plugins/loader.js) -> das
|
||||
// Plugin-Verzeichnis liegt direkt neben src/dist. Im Docker-Image entspricht
|
||||
// das /app/plugins (siehe docker-compose.yml Bind-Mount), lokal
|
||||
// apps/backend/plugins – Plugins lassen sich damit ohne Rebuild hinzufügen.
|
||||
const PLUGINS_DIR = path.resolve(currentDir, "../../plugins");
|
||||
|
||||
export interface LoadedPlugin {
|
||||
plugin: LaunchPadPlugin;
|
||||
capabilities: string[];
|
||||
}
|
||||
|
||||
const loadedPlugins: LoadedPlugin[] = [];
|
||||
|
||||
export async function loadPlugins(): Promise<LoadedPlugin[]> {
|
||||
loadedPlugins.length = 0;
|
||||
|
||||
if (!existsSync(PLUGINS_DIR)) {
|
||||
return loadedPlugins;
|
||||
}
|
||||
|
||||
const entries = readdirSync(PLUGINS_DIR).filter((entry) => {
|
||||
const full = path.join(PLUGINS_DIR, entry);
|
||||
return statSync(full).isDirectory();
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPoint = path.join(PLUGINS_DIR, entry, "index.js");
|
||||
if (!existsSync(entryPoint)) continue;
|
||||
|
||||
try {
|
||||
const imported = await import(pathToFileURL(entryPoint).href);
|
||||
const plugin: LaunchPadPlugin | undefined = imported.default ?? imported.plugin;
|
||||
|
||||
if (!plugin || !plugin.name || !plugin.version) {
|
||||
console.warn(`Plugin-Ordner "${entry}" liefert kein gültiges Plugin-Objekt, wird übersprungen.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (plugin.setup) {
|
||||
await plugin.setup({ registerSoftwareSignature });
|
||||
}
|
||||
|
||||
const capabilities: string[] = [];
|
||||
if (plugin.setup) capabilities.push("scanner");
|
||||
if (plugin.importDevices) capabilities.push("device-import");
|
||||
|
||||
loadedPlugins.push({ plugin, capabilities });
|
||||
console.log(`Plugin geladen: ${plugin.name}@${plugin.version} [${capabilities.join(", ") || "keine Capabilities"}]`);
|
||||
} catch (err) {
|
||||
console.error(`Plugin "${entry}" konnte nicht geladen werden:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return loadedPlugins;
|
||||
}
|
||||
|
||||
export function getLoadedPlugins(): LoadedPlugin[] {
|
||||
return loadedPlugins;
|
||||
}
|
||||
|
||||
export function getPlugin(name: string): LoadedPlugin | undefined {
|
||||
return loadedPlugins.find((p) => p.plugin.name === name);
|
||||
}
|
||||
22
apps/backend/src/plugins/types.ts
Normal file
22
apps/backend/src/plugins/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { DeviceCreateInput } from "@launchpad/shared";
|
||||
import type { SoftwareSignature } from "../scanner/softwareDetection.js";
|
||||
|
||||
export interface PluginContext {
|
||||
/** Erweitert die Softwareerkennung des Scanners um eine eigene Signatur. */
|
||||
registerSoftwareSignature: (signature: SoftwareSignature) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertrag, den jedes Plugin erfüllt. Ein Plugin ist ein Ordner unter
|
||||
* apps/backend/plugins/<name>/ mit einer index.js, deren Default-Export
|
||||
* dieses Objekt ist. Siehe apps/backend/plugins/README.md.
|
||||
*/
|
||||
export interface LaunchPadPlugin {
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
/** Wird einmal beim Start aufgerufen, z. B. um Scanner-Signaturen zu registrieren. */
|
||||
setup?: (ctx: PluginContext) => void | Promise<void>;
|
||||
/** Optional: eigene Geräte-Importquelle (z. B. Inventarliste, externe API). */
|
||||
importDevices?: () => Promise<DeviceCreateInput[]>;
|
||||
}
|
||||
94
apps/backend/src/routes/apis.ts
Normal file
94
apps/backend/src/routes/apis.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import * as serviceRepo from "../db/repositories/services.js";
|
||||
import * as apiRepo from "../db/repositories/apis.js";
|
||||
import * as logRepo from "../db/repositories/logs.js";
|
||||
import { detectApis } from "../scanner/apiDetector.js";
|
||||
import { startJob, updateJobProgress, finishJob, failJob, getJobStatus, isJobRunning } from "../scanJobs.js";
|
||||
|
||||
/**
|
||||
* API-Scanner: eigener, von den Geräte-/FritzBox-Scannern komplett
|
||||
* unabhängiger manueller Scan (siehe Scanner-Seite) - durchsucht bereits
|
||||
* bekannte Dienste nach üblichen API-Pfaden (siehe scanner/apiDetector.ts)
|
||||
* und speichert die Funde. Läuft NIE automatisch, nur auf Knopfdruck.
|
||||
*
|
||||
* Läuft als Hintergrund-Job im Backend (siehe scanJobs.ts), nicht mehr
|
||||
* innerhalb einer einzigen HTTP-Anfrage: bei vielen Diensten (jeweils ~40
|
||||
* geprüfte Pfade) kann das insgesamt länger dauern, als ein
|
||||
* Reverse-Proxy/Browser auf eine Antwort wartet (führte zu HTTP 504). Der
|
||||
* POST-Aufruf startet den Job nur und antwortet sofort, der Fortschritt wird
|
||||
* über GET .../status abgefragt - das macht den laufenden Scan zudem
|
||||
* geräteübergreifend sichtbar (der Status lebt im Backend, nicht im Browser).
|
||||
*/
|
||||
export async function apiRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/detected-apis", async () => {
|
||||
return apiRepo.listDetectedApis();
|
||||
});
|
||||
|
||||
app.get("/api/scan/apis/status", async () => {
|
||||
return getJobStatus("apis");
|
||||
});
|
||||
|
||||
app.post("/api/scan/apis", async (_request, reply) => {
|
||||
if (isJobRunning("apis")) {
|
||||
return reply.code(409).send({ error: "Es läuft bereits ein API-Scan." });
|
||||
}
|
||||
|
||||
const services = serviceRepo.listServices();
|
||||
startJob("apis", services.length);
|
||||
|
||||
// Bewusst NICHT awaited - der Job läuft im Hintergrund weiter, die
|
||||
// HTTP-Antwort kommt sofort zurück (siehe Doku oben).
|
||||
void (async () => {
|
||||
let servicesWithApi = 0;
|
||||
let totalFound = 0;
|
||||
const newFindings: { serviceId: string; serviceName: string; apis: apiRepo.DetectedApiEntry[] }[] = [];
|
||||
|
||||
try {
|
||||
for (let i = 0; i < services.length; i++) {
|
||||
const service = services[i];
|
||||
updateJobProgress("apis", i, service.displayName);
|
||||
|
||||
const found = await detectApis(service.url);
|
||||
if (found.length > 0) {
|
||||
const { added } = apiRepo.replaceApisForService(service.id, found);
|
||||
servicesWithApi++;
|
||||
totalFound += found.length;
|
||||
if (added.length > 0) {
|
||||
newFindings.push({ serviceId: service.id, serviceName: service.displayName, apis: added });
|
||||
}
|
||||
} else {
|
||||
apiRepo.deleteApisForService(service.id);
|
||||
}
|
||||
}
|
||||
|
||||
logRepo.logScan({
|
||||
type: "api",
|
||||
targetId: null,
|
||||
level: "info",
|
||||
message: `API-Scan: ${services.length} Dienst(e) geprüft, bei ${servicesWithApi} Dienst(en) ${totalFound} API-Endpunkt(e) gefunden.`,
|
||||
});
|
||||
|
||||
finishJob("apis", { checked: services.length, servicesWithApi, totalFound, newFindings });
|
||||
} catch (err) {
|
||||
failJob("apis", err instanceof Error ? err.message : "Unbekannter Fehler");
|
||||
}
|
||||
})();
|
||||
|
||||
return reply.code(202).send({ started: true });
|
||||
});
|
||||
|
||||
app.post("/api/scan/apis/:serviceId", async (request, reply) => {
|
||||
const { serviceId } = request.params as { serviceId: string };
|
||||
const service = serviceRepo.getService(serviceId);
|
||||
if (!service) {
|
||||
return reply.code(404).send({ error: "Dienst nicht gefunden" });
|
||||
}
|
||||
|
||||
const found = await detectApis(service.url);
|
||||
const { saved, added } =
|
||||
found.length > 0 ? apiRepo.replaceApisForService(service.id, found) : { saved: [], added: [] };
|
||||
if (found.length === 0) apiRepo.deleteApisForService(service.id);
|
||||
|
||||
return { serviceId, apis: saved, added };
|
||||
});
|
||||
}
|
||||
93
apps/backend/src/routes/bookmarks.ts
Normal file
93
apps/backend/src/routes/bookmarks.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { BookmarkCreateSchema, BookmarkReorderSchema, BookmarkUpdateSchema } from "@launchpad/shared";
|
||||
import * as bookmarkRepo from "../db/repositories/bookmarks.js";
|
||||
import * as categoryRepo from "../db/repositories/categories.js";
|
||||
import { probeHttp } from "../scanner/http.js";
|
||||
|
||||
export async function bookmarkRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/bookmarks", async () => {
|
||||
return bookmarkRepo.listBookmarks();
|
||||
});
|
||||
|
||||
app.get("/api/bookmarks/:id", async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const bookmark = bookmarkRepo.getBookmark(id);
|
||||
if (!bookmark) {
|
||||
return reply.code(404).send({ error: "Lesezeichen nicht gefunden" });
|
||||
}
|
||||
return bookmark;
|
||||
});
|
||||
|
||||
app.post("/api/bookmarks", async (request, reply) => {
|
||||
const parsed = BookmarkCreateSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
let displayName = parsed.data.displayName;
|
||||
let favicon: string | null = null;
|
||||
let description = parsed.data.description;
|
||||
|
||||
// Titel/Favicon/Beschreibung automatisch ziehen, falls nicht angegeben
|
||||
// (derselbe Mechanismus wie beim Netzwerk-Scanner, siehe
|
||||
// apps/backend/src/scanner/http.ts).
|
||||
try {
|
||||
const probe = await probeHttp(parsed.data.url, 5000);
|
||||
if (!displayName) {
|
||||
displayName = probe.title ?? bookmarkRepo.extractHostname(parsed.data.url);
|
||||
}
|
||||
if (!description) {
|
||||
description = probe.description;
|
||||
}
|
||||
favicon = probe.faviconUrl ?? null;
|
||||
} catch {
|
||||
if (!displayName) {
|
||||
displayName = bookmarkRepo.extractHostname(parsed.data.url);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.data.category) {
|
||||
categoryRepo.ensureCategory(parsed.data.category);
|
||||
}
|
||||
|
||||
const bookmark = bookmarkRepo.createBookmark(
|
||||
{ ...parsed.data, description },
|
||||
{ displayName, favicon }
|
||||
);
|
||||
return reply.code(201).send(bookmark);
|
||||
});
|
||||
|
||||
// Muss vor der /:id-Route stehen, damit "reorder" nicht als ID interpretiert wird.
|
||||
app.patch("/api/bookmarks/reorder", async (request, reply) => {
|
||||
const parsed = BookmarkReorderSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||
}
|
||||
return bookmarkRepo.reorderBookmarks(parsed.data);
|
||||
});
|
||||
|
||||
app.patch("/api/bookmarks/:id", async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const parsed = BookmarkUpdateSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||
}
|
||||
if (parsed.data.category) {
|
||||
categoryRepo.ensureCategory(parsed.data.category);
|
||||
}
|
||||
const bookmark = bookmarkRepo.updateBookmark(id, parsed.data);
|
||||
if (!bookmark) {
|
||||
return reply.code(404).send({ error: "Lesezeichen nicht gefunden" });
|
||||
}
|
||||
return bookmark;
|
||||
});
|
||||
|
||||
app.delete("/api/bookmarks/:id", async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const deleted = bookmarkRepo.deleteBookmark(id);
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({ error: "Lesezeichen nicht gefunden" });
|
||||
}
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
90
apps/backend/src/routes/faviconProxy.ts
Normal file
90
apps/backend/src/routes/faviconProxy.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
|
||||
const MAX_BYTES = 2 * 1024 * 1024; // 2 MB reicht für jedes realistische Favicon
|
||||
const TIMEOUT_MS = 4000;
|
||||
|
||||
function fetchImage(
|
||||
targetUrl: string
|
||||
): Promise<{ statusCode: number; contentType: string; body: Buffer }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(targetUrl);
|
||||
} catch {
|
||||
reject(new Error("Ungültige URL"));
|
||||
return;
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
reject(new Error("Nur http/https erlaubt"));
|
||||
return;
|
||||
}
|
||||
|
||||
const client = parsed.protocol === "https:" ? https : http;
|
||||
const req = client.get(
|
||||
parsed,
|
||||
{
|
||||
timeout: TIMEOUT_MS,
|
||||
// Homelab-Geräte haben oft selbstsignierte Zertifikate.
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
(res) => {
|
||||
const statusCode = res.statusCode ?? 0;
|
||||
if (statusCode >= 300 && statusCode < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
fetchImage(new URL(res.headers.location, targetUrl).toString())
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
res.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BYTES) {
|
||||
req.destroy();
|
||||
reject(new Error("Bild zu groß"));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
statusCode,
|
||||
contentType: res.headers["content-type"] ?? "image/x-icon",
|
||||
body: Buffer.concat(chunks),
|
||||
});
|
||||
});
|
||||
res.on("error", reject);
|
||||
}
|
||||
);
|
||||
|
||||
req.on("timeout", () => req.destroy(new Error("Zeitüberschreitung")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
export async function faviconProxyRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/favicon-proxy", async (request, reply) => {
|
||||
const { url } = request.query as { url?: string };
|
||||
if (!url) {
|
||||
return reply.code(400).send({ error: "url erforderlich" });
|
||||
}
|
||||
|
||||
try {
|
||||
const image = await fetchImage(url);
|
||||
if (image.statusCode >= 400 || image.body.length === 0) {
|
||||
return reply.code(404).send();
|
||||
}
|
||||
reply.header("Cache-Control", "public, max-age=86400");
|
||||
reply.type(image.contentType);
|
||||
return reply.send(image.body);
|
||||
} catch {
|
||||
// Icon nicht ladbar (Gerät offline, kaputte URL, ...) - 404 statt 500,
|
||||
// damit das Frontend sauber auf den Buchstaben-Fallback zurückfällt.
|
||||
return reply.code(404).send();
|
||||
}
|
||||
});
|
||||
}
|
||||
10
apps/backend/src/routes/icons.ts
Normal file
10
apps/backend/src/routes/icons.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { searchIconDb } from "../scanner/iconDb.js";
|
||||
|
||||
export async function iconsRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/icons/search", async (request) => {
|
||||
const { q } = request.query as { q?: string };
|
||||
const results = await searchIconDb(q ?? "");
|
||||
return { results };
|
||||
});
|
||||
}
|
||||
65
apps/backend/src/routes/plugins.ts
Normal file
65
apps/backend/src/routes/plugins.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import * as deviceRepo from "../db/repositories/devices.js";
|
||||
import * as logRepo from "../db/repositories/logs.js";
|
||||
import { getLoadedPlugins, getPlugin } from "../plugins/loader.js";
|
||||
|
||||
export async function pluginRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/plugins", async () => {
|
||||
return getLoadedPlugins().map(({ plugin, capabilities }) => ({
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
description: plugin.description,
|
||||
capabilities,
|
||||
}));
|
||||
});
|
||||
|
||||
// Manueller Geräte-Import über ein Plugin – wie Scans nie automatisch,
|
||||
// nur per Knopfdruck (Admin -> Plugins).
|
||||
app.post("/api/plugins/:name/import", async (request, reply) => {
|
||||
const { name } = request.params as { name: string };
|
||||
const loaded = getPlugin(name);
|
||||
|
||||
if (!loaded) {
|
||||
return reply.code(404).send({ error: "Plugin nicht gefunden" });
|
||||
}
|
||||
if (!loaded.plugin.importDevices) {
|
||||
return reply.code(400).send({ error: "Dieses Plugin unterstützt keinen Geräte-Import" });
|
||||
}
|
||||
|
||||
try {
|
||||
const devices = await loaded.plugin.importDevices();
|
||||
const results = devices.map((d) =>
|
||||
deviceRepo.upsertDeviceFromScan({
|
||||
hostname: d.hostname,
|
||||
ip: d.ip,
|
||||
mac: d.mac,
|
||||
manufacturer: d.manufacturer,
|
||||
model: d.model,
|
||||
online: d.online ?? false,
|
||||
source: "plugin",
|
||||
})
|
||||
);
|
||||
const imported = results.map((r) => r.device);
|
||||
const deviceChanges = results.flatMap((r) =>
|
||||
r.changes.map((c) => ({ deviceId: r.device.id, ...c }))
|
||||
);
|
||||
|
||||
logRepo.logScan({
|
||||
type: "device",
|
||||
level: "info",
|
||||
message: `Plugin "${name}": ${imported.length} Gerät(e) importiert${deviceChanges.length > 0 ? `, ${deviceChanges.length} Änderung(en) vorgeschlagen` : ""}`,
|
||||
});
|
||||
|
||||
return { imported: imported.length, devices: imported, deviceChanges };
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
logRepo.logScan({
|
||||
type: "device",
|
||||
level: "error",
|
||||
message: `Plugin "${name}": Import fehlgeschlagen – ${detail}`,
|
||||
});
|
||||
request.log.error(err);
|
||||
return reply.code(500).send({ error: "Import fehlgeschlagen", detail });
|
||||
}
|
||||
});
|
||||
}
|
||||
71
apps/backend/src/routes/readLater.ts
Normal file
71
apps/backend/src/routes/readLater.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import * as readLaterRepo from "../db/repositories/readLater.js";
|
||||
import * as bookmarkRepo from "../db/repositories/bookmarks.js";
|
||||
import { probeHttp } from "../scanner/http.js";
|
||||
|
||||
export async function readLaterRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/read-later", async () => {
|
||||
return readLaterRepo.listReadLater();
|
||||
});
|
||||
|
||||
app.post("/api/read-later", async (request, reply) => {
|
||||
const body = request.body as { url?: string } | undefined;
|
||||
if (!body?.url) {
|
||||
return reply.code(400).send({ error: "url erforderlich" });
|
||||
}
|
||||
|
||||
let name: string;
|
||||
let favicon: string | null = null;
|
||||
try {
|
||||
const probe = await probeHttp(body.url, 5000);
|
||||
name = probe.title ?? new URL(body.url).hostname;
|
||||
favicon = probe.faviconUrl ?? null;
|
||||
} catch {
|
||||
try {
|
||||
name = new URL(body.url).hostname;
|
||||
} catch {
|
||||
name = body.url;
|
||||
}
|
||||
}
|
||||
|
||||
const item = readLaterRepo.createReadLaterItem({ url: body.url, displayName: name, favicon });
|
||||
return reply.code(201).send(item);
|
||||
});
|
||||
|
||||
app.patch("/api/read-later/:id", async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as { url?: string; displayName?: string } | undefined;
|
||||
const item = readLaterRepo.updateReadLaterItem(id, body ?? {});
|
||||
if (!item) {
|
||||
return reply.code(404).send({ error: "Eintrag nicht gefunden" });
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
app.delete("/api/read-later/:id", async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const deleted = readLaterRepo.deleteReadLaterItem(id);
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({ error: "Eintrag nicht gefunden" });
|
||||
}
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// Verschiebt einen "Später lesen"-Eintrag in die Lesezeichen (als Favorit)
|
||||
// und entfernt ihn aus der Liste.
|
||||
app.post("/api/read-later/:id/promote", async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const item = readLaterRepo.getReadLaterItem(id);
|
||||
if (!item) {
|
||||
return reply.code(404).send({ error: "Eintrag nicht gefunden" });
|
||||
}
|
||||
|
||||
const bookmark = bookmarkRepo.createBookmark(
|
||||
{ url: item.url, favorite: true },
|
||||
{ displayName: item.displayName, favicon: item.favicon }
|
||||
);
|
||||
readLaterRepo.deleteReadLaterItem(id);
|
||||
|
||||
return reply.code(201).send(bookmark);
|
||||
});
|
||||
}
|
||||
27
apps/backend/src/routes/recentVisits.ts
Normal file
27
apps/backend/src/routes/recentVisits.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import * as recentVisitsRepo from "../db/repositories/recentVisits.js";
|
||||
|
||||
export async function recentVisitsRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/recent-visits", async (request) => {
|
||||
const query = request.query as { limit?: string };
|
||||
const limit = query.limit ? Number(query.limit) : undefined;
|
||||
return recentVisitsRepo.listRecentVisits(limit);
|
||||
});
|
||||
|
||||
app.post("/api/recent-visits", async (request, reply) => {
|
||||
const body = request.body as { itemType?: string; itemId?: string } | undefined;
|
||||
if (body?.itemType !== "service" && body?.itemType !== "bookmark") {
|
||||
return reply.code(400).send({ error: "itemType muss 'service' oder 'bookmark' sein" });
|
||||
}
|
||||
if (!body.itemId) {
|
||||
return reply.code(400).send({ error: "itemId erforderlich" });
|
||||
}
|
||||
recentVisitsRepo.recordVisit(body.itemType, body.itemId);
|
||||
return reply.code(201).send({ ok: true });
|
||||
});
|
||||
|
||||
app.delete("/api/recent-visits", async () => {
|
||||
recentVisitsRepo.clearRecentVisits();
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
34
apps/backend/src/routes/reset.ts
Normal file
34
apps/backend/src/routes/reset.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import * as deviceRepo from "../db/repositories/devices.js";
|
||||
import * as logRepo from "../db/repositories/logs.js";
|
||||
|
||||
/**
|
||||
* Löscht ALLE Geräte und (per FK-Cascade) alle zugehörigen Dienste.
|
||||
* Kategorien und Logs bleiben unangetastet.
|
||||
*
|
||||
* Erfordert { confirm: true } im Body – zusätzlich zur Bestätigung im
|
||||
* Frontend eine zweite Sicherung gegen versehentliche Aufrufe (z. B. per
|
||||
* Skript oder Browser-Erweiterung).
|
||||
*/
|
||||
export async function resetRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post("/api/reset", async (request, reply) => {
|
||||
const body = request.body as { confirm?: boolean } | undefined;
|
||||
|
||||
if (body?.confirm !== true) {
|
||||
return reply.code(400).send({
|
||||
error: "Bestätigung erforderlich. Bitte { \"confirm\": true } im Body senden.",
|
||||
});
|
||||
}
|
||||
|
||||
const deviceCount = deviceRepo.listDevices().length;
|
||||
const deletedCount = deviceRepo.deleteAllDevices();
|
||||
|
||||
logRepo.logScan({
|
||||
type: "device",
|
||||
level: "info",
|
||||
message: `Reset: ${deletedCount} Gerät(e) und alle zugehörigen Dienste gelöscht`,
|
||||
});
|
||||
|
||||
return { deletedDevices: deletedCount, previousDeviceCount: deviceCount };
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
109
apps/backend/src/routes/settings.ts
Normal file
109
apps/backend/src/routes/settings.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import * as settingsRepo from "../db/repositories/settings.js";
|
||||
|
||||
function serializeSettings() {
|
||||
const all = settingsRepo.listSettings();
|
||||
return {
|
||||
recentVisitsLimit: Number(all.recentVisitsLimit ?? 5),
|
||||
readLaterLimit: Number(all.readLaterLimit ?? 5),
|
||||
staleDeviceThresholdDays: Number(all.staleDeviceThresholdDays ?? 7),
|
||||
liveStatusEnabled: all.liveStatusEnabled === "true",
|
||||
liveStatusIntervalMinutes: Number(all.liveStatusIntervalMinutes ?? 5),
|
||||
liveStatusLastRunAt: all.liveStatusLastRunAt ?? null,
|
||||
// NEU (round64): Wahl zwischen dem bisherigen (Listen-)Design und einem
|
||||
// neuen, Android-Startbildschirm-artigen Design für die MOBILE Ansicht
|
||||
// (Desktop bleibt davon unberührt). Bewusst hier in den serverseitigen
|
||||
// Einstellungen statt nur im sessionStorage/localStorage des jeweiligen
|
||||
// Browsers, damit die Wahl wirklich app-weit gilt, nicht nur pro Gerät.
|
||||
mobileHomeStyle: all.mobileHomeStyle === "android" ? "android" : "classic",
|
||||
// NEU (round65): Frei angeordnetes Layout des Android-Startbildschirms
|
||||
// (welche Favoriten wo liegen, selbst angelegte Ordner, Aufteilung
|
||||
// zwischen Hauptbereich und angehefteter Dock-Leiste unten) - als JSON
|
||||
// gespeichert, roh durchgereicht. Der genaue Aufbau ist reine
|
||||
// Frontend-Angelegenheit (siehe useHomeLayout.ts) - das Backend
|
||||
// validiert hier nur, dass es sich überhaupt um ein Objekt mit den
|
||||
// erwarteten Top-Level-Schlüsseln handelt, nicht den vollen Inhalt.
|
||||
mobileHomeLayout: all.mobileHomeLayout || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/settings", async () => {
|
||||
return serializeSettings();
|
||||
});
|
||||
|
||||
app.patch("/api/settings", async (request, reply) => {
|
||||
const body = request.body as
|
||||
| {
|
||||
recentVisitsLimit?: number;
|
||||
readLaterLimit?: number;
|
||||
staleDeviceThresholdDays?: number;
|
||||
liveStatusEnabled?: boolean;
|
||||
liveStatusIntervalMinutes?: number;
|
||||
mobileHomeStyle?: string;
|
||||
mobileHomeLayout?: unknown;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (body?.recentVisitsLimit !== undefined) {
|
||||
const value = Number(body.recentVisitsLimit);
|
||||
if (!Number.isFinite(value) || value < 0 || value > 50) {
|
||||
return reply.code(400).send({ error: "recentVisitsLimit muss zwischen 0 und 50 liegen" });
|
||||
}
|
||||
settingsRepo.setSetting("recentVisitsLimit", String(Math.round(value)));
|
||||
}
|
||||
|
||||
if (body?.readLaterLimit !== undefined) {
|
||||
const value = Number(body.readLaterLimit);
|
||||
if (!Number.isFinite(value) || value < 0 || value > 50) {
|
||||
return reply.code(400).send({ error: "readLaterLimit muss zwischen 0 und 50 liegen" });
|
||||
}
|
||||
settingsRepo.setSetting("readLaterLimit", String(Math.round(value)));
|
||||
}
|
||||
|
||||
if (body?.staleDeviceThresholdDays !== undefined) {
|
||||
const value = Number(body.staleDeviceThresholdDays);
|
||||
if (!Number.isFinite(value) || value < 0 || value > 90) {
|
||||
return reply.code(400).send({ error: "staleDeviceThresholdDays muss zwischen 0 und 90 liegen" });
|
||||
}
|
||||
settingsRepo.setSetting("staleDeviceThresholdDays", String(Math.round(value)));
|
||||
}
|
||||
|
||||
if (body?.liveStatusEnabled !== undefined) {
|
||||
settingsRepo.setSetting("liveStatusEnabled", body.liveStatusEnabled ? "true" : "false");
|
||||
}
|
||||
|
||||
if (body?.liveStatusIntervalMinutes !== undefined) {
|
||||
const value = Number(body.liveStatusIntervalMinutes);
|
||||
if (!Number.isFinite(value) || value < 1 || value > 1440) {
|
||||
return reply.code(400).send({ error: "liveStatusIntervalMinutes muss zwischen 1 und 1440 liegen" });
|
||||
}
|
||||
settingsRepo.setSetting("liveStatusIntervalMinutes", String(Math.round(value)));
|
||||
}
|
||||
|
||||
if (body?.mobileHomeStyle !== undefined) {
|
||||
if (body.mobileHomeStyle !== "classic" && body.mobileHomeStyle !== "android") {
|
||||
return reply.code(400).send({ error: "mobileHomeStyle muss 'classic' oder 'android' sein" });
|
||||
}
|
||||
settingsRepo.setSetting("mobileHomeStyle", body.mobileHomeStyle);
|
||||
}
|
||||
|
||||
if (body?.mobileHomeLayout !== undefined) {
|
||||
const layout = body.mobileHomeLayout;
|
||||
const looksValid =
|
||||
layout === null ||
|
||||
(typeof layout === "object" &&
|
||||
layout !== null &&
|
||||
Array.isArray((layout as Record<string, unknown>).grid) &&
|
||||
Array.isArray((layout as Record<string, unknown>).dock));
|
||||
if (!looksValid) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: "mobileHomeLayout muss null oder ein Objekt mit 'grid' und 'dock' als Arrays sein" });
|
||||
}
|
||||
settingsRepo.setSetting("mobileHomeLayout", layout === null ? "" : JSON.stringify(layout));
|
||||
}
|
||||
|
||||
return serializeSettings();
|
||||
});
|
||||
}
|
||||
250
apps/backend/src/routes/transfer.ts
Normal file
250
apps/backend/src/routes/transfer.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import * as XLSX from "xlsx";
|
||||
import * as deviceRepo from "../db/repositories/devices.js";
|
||||
import * as serviceRepo from "../db/repositories/services.js";
|
||||
import * as categoryRepo from "../db/repositories/categories.js";
|
||||
|
||||
interface TransferRow {
|
||||
type: "device" | "service";
|
||||
displayName: string;
|
||||
hostname: string;
|
||||
ip: string;
|
||||
mac: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
category: string;
|
||||
alias: string;
|
||||
favorite: string;
|
||||
visible: string;
|
||||
order: number | string;
|
||||
port: string | number;
|
||||
https: string;
|
||||
url: string;
|
||||
deviceHostname: string;
|
||||
deviceIp: string;
|
||||
}
|
||||
|
||||
function buildExportRows(): TransferRow[] {
|
||||
const devices = deviceRepo.listDevices();
|
||||
const services = serviceRepo.listServices();
|
||||
const deviceById = new Map(devices.map((d) => [d.id, d]));
|
||||
|
||||
const deviceRows: TransferRow[] = devices.map((d) => ({
|
||||
type: "device",
|
||||
displayName: d.hostname,
|
||||
hostname: d.hostname,
|
||||
ip: d.ip,
|
||||
mac: d.mac ?? "",
|
||||
manufacturer: d.manufacturer ?? "",
|
||||
model: d.model ?? "",
|
||||
category: "",
|
||||
alias: "",
|
||||
favorite: "",
|
||||
visible: "",
|
||||
order: "",
|
||||
port: "",
|
||||
https: "",
|
||||
url: "",
|
||||
deviceHostname: "",
|
||||
deviceIp: "",
|
||||
}));
|
||||
|
||||
const serviceRows: TransferRow[] = services.map((s) => {
|
||||
const device = deviceById.get(s.deviceId);
|
||||
return {
|
||||
type: "service",
|
||||
displayName: s.displayName,
|
||||
hostname: s.hostname,
|
||||
ip: "",
|
||||
mac: "",
|
||||
manufacturer: "",
|
||||
model: "",
|
||||
category: s.category ?? "",
|
||||
alias: s.alias.join(";"),
|
||||
favorite: s.favorite ? "true" : "false",
|
||||
visible: s.visible ? "true" : "false",
|
||||
order: s.order,
|
||||
port: s.port,
|
||||
https: s.https ? "true" : "false",
|
||||
url: s.url,
|
||||
deviceHostname: device?.hostname ?? "",
|
||||
deviceIp: device?.ip ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
return [...deviceRows, ...serviceRows];
|
||||
}
|
||||
|
||||
export async function transferRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/transfer/export", async (request, reply) => {
|
||||
const query = request.query as { format?: string };
|
||||
const format = query.format === "xlsx" ? "xlsx" : query.format === "json" ? "json" : "csv";
|
||||
const rows = buildExportRows();
|
||||
|
||||
if (format === "json") {
|
||||
reply.header("Content-Disposition", 'attachment; filename="launchpad-export.json"');
|
||||
reply.type("application/json");
|
||||
return rows;
|
||||
}
|
||||
|
||||
const worksheet = XLSX.utils.json_to_sheet(rows);
|
||||
|
||||
if (format === "xlsx") {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "LaunchPad");
|
||||
const buffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }) as Buffer;
|
||||
reply.header("Content-Disposition", 'attachment; filename="launchpad-export.xlsx"');
|
||||
reply.type("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
return reply.send(buffer);
|
||||
}
|
||||
|
||||
const csv = XLSX.utils.sheet_to_csv(worksheet);
|
||||
reply.header("Content-Disposition", 'attachment; filename="launchpad-export.csv"');
|
||||
reply.type("text/csv; charset=utf-8");
|
||||
return reply.send(csv);
|
||||
});
|
||||
|
||||
app.post("/api/transfer/import", async (request, reply) => {
|
||||
const body = request.body as { format?: string; content?: string } | undefined;
|
||||
if (!body?.content) {
|
||||
return reply.code(400).send({ error: "Kein Dateiinhalt übermittelt" });
|
||||
}
|
||||
|
||||
let rows: Record<string, unknown>[];
|
||||
try {
|
||||
if (body.format === "json") {
|
||||
const text = Buffer.from(body.content, "base64").toString("utf-8");
|
||||
rows = JSON.parse(text);
|
||||
} else {
|
||||
const buffer = Buffer.from(body.content, "base64");
|
||||
const workbook = XLSX.read(buffer, { type: "buffer" });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { defval: "" });
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.code(400).send({
|
||||
error: "Datei konnte nicht gelesen werden",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
let devicesImported = 0;
|
||||
let devicesSkipped = 0;
|
||||
let servicesImported = 0;
|
||||
let servicesSkipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
// Geräte-Zeilen zuerst verarbeiten, damit Dienst-Zeilen aus derselben
|
||||
// Datei sie bereits über deviceHostname/deviceIp finden können.
|
||||
const deviceRows = rows.filter((r) => String(r.type ?? "").trim() === "device");
|
||||
const serviceRows = rows.filter((r) => String(r.type ?? "").trim() === "service");
|
||||
|
||||
for (const row of deviceRows) {
|
||||
try {
|
||||
const hostname = String(row.hostname ?? row.displayName ?? "").trim();
|
||||
const ip = String(row.ip ?? "").trim();
|
||||
if (!hostname || !ip) {
|
||||
errors.push(`Geräte-Zeile übersprungen (hostname/ip fehlt): ${JSON.stringify(row)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const allDevices = deviceRepo.listDevices();
|
||||
const existing =
|
||||
allDevices.find((d) => d.ip === ip) ?? allDevices.find((d) => d.hostname === hostname);
|
||||
|
||||
if (existing) {
|
||||
devicesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
deviceRepo.createDevice({
|
||||
hostname,
|
||||
ip,
|
||||
mac: row.mac ? String(row.mac).trim() : undefined,
|
||||
manufacturer: row.manufacturer ? String(row.manufacturer).trim() : undefined,
|
||||
model: row.model ? String(row.model).trim() : undefined,
|
||||
});
|
||||
devicesImported++;
|
||||
} catch (err) {
|
||||
errors.push(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of serviceRows) {
|
||||
try {
|
||||
const deviceHostname = String(row.deviceHostname ?? "").trim();
|
||||
const deviceIp = String(row.deviceIp ?? "").trim();
|
||||
const hostname = String(row.hostname ?? deviceHostname).trim();
|
||||
const port = Number(row.port);
|
||||
const displayName = String(row.displayName ?? "").trim();
|
||||
const url = String(row.url ?? "").trim();
|
||||
|
||||
if (!displayName || !hostname || !port || !url) {
|
||||
errors.push(`Dienst-Zeile übersprungen (Pflichtfelder fehlen): ${JSON.stringify(row)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const allDevices = deviceRepo.listDevices();
|
||||
let device = deviceIp ? allDevices.find((d) => d.ip === deviceIp) : undefined;
|
||||
if (!device && deviceHostname) {
|
||||
device = allDevices.find((d) => d.hostname === deviceHostname);
|
||||
}
|
||||
if (!device) {
|
||||
device = deviceRepo.createDevice({
|
||||
hostname: deviceHostname || hostname,
|
||||
ip: deviceIp || hostname,
|
||||
});
|
||||
devicesImported++;
|
||||
}
|
||||
|
||||
const existingService = serviceRepo
|
||||
.listServicesByDevice(device.id)
|
||||
.find((s) => s.port === port);
|
||||
if (existingService) {
|
||||
servicesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const category = row.category ? String(row.category).trim() : undefined;
|
||||
|
||||
serviceRepo.createService({
|
||||
deviceId: device.id,
|
||||
displayName,
|
||||
hostname,
|
||||
url,
|
||||
https: String(row.https ?? "").toLowerCase() === "true",
|
||||
port,
|
||||
category: category || undefined,
|
||||
alias: row.alias
|
||||
? String(row.alias)
|
||||
.split(";")
|
||||
.map((a) => a.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
favorite: String(row.favorite ?? "").toLowerCase() === "true",
|
||||
order: row.order !== undefined && row.order !== "" ? Number(row.order) : undefined,
|
||||
visible:
|
||||
row.visible !== undefined && row.visible !== ""
|
||||
? String(row.visible).toLowerCase() === "true"
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (category) {
|
||||
categoryRepo.ensureCategory(category);
|
||||
}
|
||||
|
||||
servicesImported++;
|
||||
} catch (err) {
|
||||
errors.push(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
devicesImported,
|
||||
devicesSkipped,
|
||||
servicesImported,
|
||||
servicesSkipped,
|
||||
errors,
|
||||
};
|
||||
});
|
||||
}
|
||||
117
apps/backend/src/scanJobs.ts
Normal file
117
apps/backend/src/scanJobs.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
export type ScanJobType = "devices" | "apis" | "fritzbox";
|
||||
|
||||
export interface ScanJobProgress {
|
||||
current: number;
|
||||
total: number;
|
||||
currentLabel: string | null;
|
||||
}
|
||||
|
||||
export interface ScanJobStatus {
|
||||
running: boolean;
|
||||
startedAt: string | null;
|
||||
progress: ScanJobProgress | null;
|
||||
lastResult: Record<string, unknown> | null;
|
||||
lastError: string | null;
|
||||
cancelRequested: boolean;
|
||||
}
|
||||
|
||||
const jobs = new Map<ScanJobType, ScanJobStatus>();
|
||||
|
||||
function emptyStatus(): ScanJobStatus {
|
||||
return {
|
||||
running: false,
|
||||
startedAt: null,
|
||||
progress: null,
|
||||
lastResult: null,
|
||||
lastError: null,
|
||||
cancelRequested: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Zentraler, server-seitiger Status je Scanner-Typ ("devices" = "Alle
|
||||
* Geräte scannen", "apis" = API-Scanner). Bewusst NICHT in der Datenbank,
|
||||
* nur im Arbeitsspeicher des Backend-Prozesses - der Zustand "ein Scan läuft
|
||||
* gerade" muss keinen Neustart überleben, aber er MUSS geräteübergreifend
|
||||
* sichtbar sein (jeder Browser/jedes Gerät, das den Adminbereich öffnet,
|
||||
* soll denselben laufenden Scan sehen). Ein rein browserseitiger Zustand
|
||||
* (sessionStorage) kann das prinzipiell nicht leisten.
|
||||
*/
|
||||
export function getJobStatus(type: ScanJobType): ScanJobStatus {
|
||||
return jobs.get(type) ?? emptyStatus();
|
||||
}
|
||||
|
||||
export function isJobRunning(type: ScanJobType): boolean {
|
||||
return getJobStatus(type).running;
|
||||
}
|
||||
|
||||
export function startJob(type: ScanJobType, total: number): void {
|
||||
jobs.set(type, {
|
||||
running: true,
|
||||
startedAt: new Date().toISOString(),
|
||||
progress: { current: 0, total, currentLabel: null },
|
||||
lastResult: null,
|
||||
lastError: null,
|
||||
cancelRequested: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateJobProgress(type: ScanJobType, current: number, currentLabel: string | null): void {
|
||||
const job = jobs.get(type);
|
||||
if (!job) return;
|
||||
job.progress = { current, total: job.progress?.total ?? current, currentLabel };
|
||||
}
|
||||
|
||||
export function isCancelRequested(type: ScanJobType): boolean {
|
||||
return jobs.get(type)?.cancelRequested ?? false;
|
||||
}
|
||||
|
||||
export function requestCancel(type: ScanJobType): void {
|
||||
const job = jobs.get(type);
|
||||
if (job) job.cancelRequested = true;
|
||||
}
|
||||
|
||||
export function finishJob(type: ScanJobType, result: Record<string, unknown>): void {
|
||||
const job = jobs.get(type) ?? emptyStatus();
|
||||
jobs.set(type, { ...job, running: false, lastResult: result, cancelRequested: false });
|
||||
}
|
||||
|
||||
export function failJob(type: ScanJobType, error: string): void {
|
||||
const job = jobs.get(type) ?? emptyStatus();
|
||||
jobs.set(type, { ...job, running: false, lastError: error, cancelRequested: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sperrt ein einzelnes Gerät gegen ÜBERLAPPENDE Scans - unabhängig davon, ob
|
||||
* der Scan über den Einzelgerät-Endpunkt oder als Teil des Sammel-Scans
|
||||
* ("Alle Geräte scannen") läuft. Ohne das könnten zwei gleichzeitige Scans
|
||||
* desselben Geräts beide unabhängig voneinander denselben neuen Dienst
|
||||
* anlegen wollen (siehe UNIQUE-Index in client.ts als zusätzliches
|
||||
* Sicherheitsnetz auf Datenbankebene).
|
||||
*/
|
||||
const devicesCurrentlyScanning = new Set<string>();
|
||||
|
||||
export function tryLockDevice(deviceId: string): boolean {
|
||||
if (devicesCurrentlyScanning.has(deviceId)) return false;
|
||||
devicesCurrentlyScanning.add(deviceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function unlockDevice(deviceId: string): void {
|
||||
devicesCurrentlyScanning.delete(deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die IDs aller Geräte, die GERADE JETZT gescannt werden - egal ob
|
||||
* über den Einzelgerät-Endpunkt (manueller "Jetzt scannen"-Button auf der
|
||||
* Geräteseite) oder als Teil eines laufenden Sammel-Scans. Genutzt für die
|
||||
* geräteübergreifende Anzeige "Ein Scan läuft gerade" in der Seitenleiste
|
||||
* (siehe AdminLayout.tsx/useScanActivity.ts) - vorher war ein einzelner
|
||||
* manueller Geräte-Scan NUR im Browser sichtbar, der ihn ausgelöst hat (der
|
||||
* Einzelgerät-Endpunkt ist eine simple, blockierende Anfrage ohne eigenen
|
||||
* Job-Status wie der Sammel-Scan), auf jedem anderen Browser/Gerät gab es
|
||||
* dafür überhaupt keine Anzeige.
|
||||
*/
|
||||
export function getScanningDeviceIds(): string[] {
|
||||
return Array.from(devicesCurrentlyScanning);
|
||||
}
|
||||
193
apps/backend/src/scanner/apiDetector.ts
Normal file
193
apps/backend/src/scanner/apiDetector.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
|
||||
export interface DetectedApi {
|
||||
path: string;
|
||||
type: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
interface RawProbeResult {
|
||||
status: number;
|
||||
contentType: string | null;
|
||||
wwwAuthenticate: string | null;
|
||||
bodySnippet: string;
|
||||
}
|
||||
|
||||
const MAX_BODY_BYTES = 8_192;
|
||||
|
||||
function fetchRaw(url: string, timeoutMs = 2500): Promise<RawProbeResult | null> {
|
||||
return new Promise((resolve) => {
|
||||
const isHttps = url.startsWith("https://");
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const req = client.get(
|
||||
url,
|
||||
{ timeout: timeoutMs, rejectUnauthorized: false, headers: { Accept: "application/json, */*" } },
|
||||
(res) => {
|
||||
let body = "";
|
||||
let received = 0;
|
||||
res.on("data", (chunk: Buffer) => {
|
||||
received += chunk.length;
|
||||
if (received <= MAX_BODY_BYTES) body += chunk.toString("utf-8");
|
||||
});
|
||||
res.on("end", () => {
|
||||
const contentType = res.headers["content-type"] ?? null;
|
||||
const wwwAuthenticate = res.headers["www-authenticate"] ?? null;
|
||||
resolve({
|
||||
status: res.statusCode ?? 0,
|
||||
contentType,
|
||||
wwwAuthenticate: Array.isArray(wwwAuthenticate) ? wwwAuthenticate[0] : wwwAuthenticate,
|
||||
bodySnippet: body,
|
||||
});
|
||||
});
|
||||
res.on("error", () => resolve(null));
|
||||
}
|
||||
);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
resolve(null);
|
||||
});
|
||||
req.on("error", () => resolve(null));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wohlbekannte Pfade, unter denen selbstgehostete Software üblicherweise
|
||||
* ihre API bzw. deren Dokumentation/Schema anbietet - sowohl generische
|
||||
* REST/OpenAPI/GraphQL-Konventionen als auch Pfade konkreter, in Homelabs
|
||||
* verbreiteter Software (Home Assistant, Proxmox, Portainer, die *arr-Reihe,
|
||||
* Grafana, Jellyfin/Plex, Pi-hole, Unifi, ...). Bewusst eine kuratierte
|
||||
* Liste statt eines vollständigen Wortlisten-Bruteforce - das hier ist ein
|
||||
* Hinweis-Scanner, kein Sicherheits-/Pentesting-Werkzeug.
|
||||
*/
|
||||
const CANDIDATE_PATHS: { path: string; type: string }[] = [
|
||||
// Generische REST-/OpenAPI-/GraphQL-Konventionen - bewusst nur Pfade, die
|
||||
// auf eine tatsächlich NUTZBARE/dokumentierte API hindeuten. Reine
|
||||
// Status-/Health-/Metrik-Endpunkte (/health, /metrics, /actuator, ...)
|
||||
// wurden bewusst rausgenommen: die bestätigen nur "hier läuft Software",
|
||||
// sind aber keine API, mit der man tatsächlich etwas anfangen kann.
|
||||
{ path: "/openapi.json", type: "OpenAPI" },
|
||||
{ path: "/openapi.yaml", type: "OpenAPI" },
|
||||
{ path: "/swagger.json", type: "OpenAPI (Swagger)" },
|
||||
{ path: "/swagger.yaml", type: "OpenAPI (Swagger)" },
|
||||
{ path: "/api-docs", type: "OpenAPI (Swagger)" },
|
||||
{ path: "/v2/api-docs", type: "OpenAPI (Swagger)" },
|
||||
{ path: "/swagger/index.html", type: "Swagger-UI" },
|
||||
{ path: "/swagger-ui", type: "Swagger-UI" },
|
||||
{ path: "/redoc", type: "OpenAPI (ReDoc)" },
|
||||
{ path: "/graphql", type: "GraphQL" },
|
||||
{ path: "/graphiql", type: "GraphQL" },
|
||||
{ path: "/api/graphql", type: "GraphQL" },
|
||||
{ path: "/.well-known/openapi.json", type: "OpenAPI" },
|
||||
{ path: "/api/v3", type: "REST-API" },
|
||||
{ path: "/api/v2", type: "REST-API" },
|
||||
{ path: "/api/v1", type: "REST-API" },
|
||||
{ path: "/api", type: "REST-API" },
|
||||
{ path: "/rest", type: "REST-API" },
|
||||
{ path: "/rpc", type: "JSON-RPC" },
|
||||
{ path: "/jsonrpc", type: "JSON-RPC" },
|
||||
// Konkrete, in Homelabs verbreitete Software - jeweils ein Endpunkt IHRER
|
||||
// eigenen echten API (nicht nur ein Status-Ping), deshalb behalten.
|
||||
{ path: "/api/config/core", type: "Home Assistant API" }, // erfordert Auth, meldet sich aber als API
|
||||
{ path: "/api2/json/version", type: "Proxmox API" },
|
||||
{ path: "/api/status", type: "Portainer API" },
|
||||
{ path: "/api/v3/system/status", type: "Sonarr/Radarr/Prowlarr API" },
|
||||
{ path: "/admin/api.php", type: "Pi-hole API" },
|
||||
{ path: "/System/Info/Public", type: "Jellyfin API" },
|
||||
{ path: "/identity", type: "Plex API" },
|
||||
{ path: "/api/self", type: "Unifi-Controller API" },
|
||||
{ path: "/api/v2/server/about", type: "Nextcloud API" },
|
||||
{ path: "/ocs/v1.php/cloud/capabilities", type: "Nextcloud API" },
|
||||
];
|
||||
|
||||
function looksLikeJson(body: string): boolean {
|
||||
const trimmed = body.trim();
|
||||
return trimmed.startsWith("{") || trimmed.startsWith("[");
|
||||
}
|
||||
|
||||
function isApiLikeResponse(result: RawProbeResult): boolean {
|
||||
const contentTypeIsApi =
|
||||
!!result.contentType &&
|
||||
(result.contentType.includes("json") ||
|
||||
result.contentType.includes("graphql") ||
|
||||
result.contentType.includes("xml"));
|
||||
const bodyIsJson = looksLikeJson(result.bodySnippet);
|
||||
const hasAuthChallenge = !!result.wwwAuthenticate;
|
||||
return contentTypeIsApi || bodyIsJson || hasAuthChallenge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die kuratierten Kandidaten-Pfade unter einer Basis-URL parallel und
|
||||
* liefert alle, die auf eine tatsächlich vorhandene API hindeuten:
|
||||
* - eine JSON-Antwort (egal ob 200 oder z. B. 401 "unauthorized" - eine
|
||||
* JSON-Fehlermeldung zeigt trotzdem "hier läuft eine API"),
|
||||
* - ein Content-Type, der explizit auf JSON/GraphQL/XML-API hindeutet,
|
||||
* - oder ein "WWW-Authenticate"-Header (401 mit diesem Header ist normalerweise
|
||||
* ein starkes Signal für eine authentifizierungspflichtige API).
|
||||
* Reine HTML-Seiten (z. B. eine 404-Fehlerseite des Frontends oder eine
|
||||
* Login-Weiterleitung ohne API-Signal) zählen nicht.
|
||||
*
|
||||
* KEIN pauschaler Kontroll-Check gegen einen zufälligen Pfad mehr (frühere
|
||||
* Version): viele Selfhosted-Programme (Sonarr, Portainer, Home Assistant, …)
|
||||
* fordern für JEDE unauthentifizierte Anfrage einheitlich eine Anmeldung -
|
||||
* das sah wie ein Gerät aus, "dem man nicht trauen kann", war aber normales,
|
||||
* erwartbares Verhalten und hat massenhaft echte Treffer unterdrückt.
|
||||
* Stattdessen wird JEDER einzelne Treffer für sich geprüft (siehe unten).
|
||||
*/
|
||||
export async function detectApis(baseUrl: string): Promise<DetectedApi[]> {
|
||||
const checks = await Promise.all(
|
||||
CANDIDATE_PATHS.map(async ({ path, type }) => {
|
||||
const result = await fetchRaw(`${baseUrl}${path}`);
|
||||
if (!result || result.status === 0 || result.status === 404) return null;
|
||||
if (!isApiLikeResponse(result)) return null;
|
||||
|
||||
const detected: DetectedApi = { path, type, status: result.status };
|
||||
return detected;
|
||||
})
|
||||
);
|
||||
|
||||
let matches = checks.filter((c): c is DetectedApi => c !== null);
|
||||
|
||||
// Gegen Catch-all-Verhalten (z. B. FritzBox/Hue Bridge: 404 für "echte"
|
||||
// Fremdpfade, aber jede Anfrage UNTER einem bekannten API-Pfad wird gleich
|
||||
// generisch beantwortet - ganz unabhängig vom genauen Unterpfad): für
|
||||
// jeden Treffer werden ZWEI erfundene Varianten geprüft - (a) derselbe
|
||||
// Pfad mit verändertem letzten Segment, (b) ein komplett zufälliger Pfad
|
||||
// im selben Verzeichnis. Antwortet EINE der beiden genauso "positiv", war
|
||||
// der ursprüngliche Treffer nur ein genereller Handler, kein echter,
|
||||
// spezifischer Endpunkt.
|
||||
const verified = await Promise.all(
|
||||
matches.map(async (m) => {
|
||||
const mutatedLastSegment = m.path.replace(/([^/]+)$/, `$1-x${Date.now().toString(36)}`);
|
||||
const directoryPrefix = m.path.slice(0, m.path.lastIndexOf("/") + 1);
|
||||
const randomSibling = `${directoryPrefix}__lp_probe_${Date.now().toString(36)}__`;
|
||||
|
||||
const [siblingA, siblingB] = await Promise.all([
|
||||
fetchRaw(`${baseUrl}${mutatedLastSegment}`),
|
||||
directoryPrefix.length > 1 ? fetchRaw(`${baseUrl}${randomSibling}`) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const isCatchAll = [siblingA, siblingB].some(
|
||||
(s) => !!s && s.status !== 404 && s.status !== 0 && isApiLikeResponse(s)
|
||||
);
|
||||
return isCatchAll ? null : m;
|
||||
})
|
||||
);
|
||||
matches = verified.filter((m): m is DetectedApi => m !== null);
|
||||
|
||||
// Mehrere Kandidaten-Einträge können denselben Pfad haben (z. B. das
|
||||
// generische "/api/status" -> "REST-API" UND das Portainer-spezifische
|
||||
// "/api/status" -> "Portainer API") - nach Pfad deduplizieren, dabei die
|
||||
// spezifischere (nicht-generische) Beschriftung bevorzugen.
|
||||
const GENERIC_TYPES = new Set(["REST-API", "JSON-RPC", "Health-Endpunkt"]);
|
||||
const byPath = new Map<string, DetectedApi>();
|
||||
for (const match of matches) {
|
||||
const existing = byPath.get(match.path);
|
||||
if (!existing || (GENERIC_TYPES.has(existing.type) && !GENERIC_TYPES.has(match.type))) {
|
||||
byPath.set(match.path, match);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byPath.values());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
280
apps/backend/src/scanner/httpx.ts
Normal file
280
apps/backend/src/scanner/httpx.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export interface HttpxResult {
|
||||
port: number;
|
||||
scheme: "http" | "https";
|
||||
statusCode?: number;
|
||||
title?: string;
|
||||
webserver?: string;
|
||||
}
|
||||
|
||||
let httpxConfirmedUnavailable = false;
|
||||
|
||||
// Klartext-Grund des letzten Fehlschlags (stderr/Fehlermeldung) - wird im
|
||||
// Scan-Log angezeigt, damit sichtbar ist WARUM httpx nicht greift, statt nur
|
||||
// "nicht genutzt/verfügbar" ohne jede Erklärung.
|
||||
let httpxLastError: string | null = null;
|
||||
|
||||
export function isHttpxConfirmedUnavailable(): boolean {
|
||||
return httpxConfirmedUnavailable;
|
||||
}
|
||||
|
||||
export function getHttpxLastError(): string | null {
|
||||
return httpxLastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Einfacher Zähl-Semaphore (siehe auch masscan.ts), Kapazität 2: Seit
|
||||
* round50 läuft pro Scan-JOB nur noch EIN httpx-Prozess für ALLE Geräte
|
||||
* gleichzeitig (siehe probeManyWithHttpx unten), nicht mehr einer PRO Gerät
|
||||
* wie bis round49. Der Semaphore schützt nur noch den seltenen Fall, dass
|
||||
* zwei unabhängige Scan-Jobs (Sammel-Scan + gleichzeitig ein manueller
|
||||
* Einzelgeräte-Scan) überlappen.
|
||||
*/
|
||||
class Semaphore {
|
||||
private available: number;
|
||||
private readonly queue: (() => void)[] = [];
|
||||
|
||||
constructor(count: number) {
|
||||
this.available = count;
|
||||
}
|
||||
|
||||
acquire(): Promise<void> {
|
||||
if (this.available > 0) {
|
||||
this.available--;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => this.queue.push(resolve));
|
||||
}
|
||||
|
||||
release(): void {
|
||||
const next = this.queue.shift();
|
||||
if (next) next();
|
||||
else this.available++;
|
||||
}
|
||||
}
|
||||
|
||||
const httpxSemaphore = new Semaphore(2);
|
||||
|
||||
/**
|
||||
* Threads für den httpx-Aufruf: skaliert leicht mit der Zielanzahl (mehr
|
||||
* Ziele -> mehr Parallelität sinnvoll), aber mit Ober-/Untergrenze. Seit nur
|
||||
* noch EIN httpx-Prozess pro Scan-Job läuft (statt bis zu 10 gleichzeitig),
|
||||
* ist ein höherer Wert als die bisherigen festen 50 unproblematisch - dieser
|
||||
* eine Prozess hat die volle Bandbreite für sich.
|
||||
*/
|
||||
function computeThreads(targetCount: number): number {
|
||||
return Math.min(200, Math.max(50, targetCount));
|
||||
}
|
||||
|
||||
/**
|
||||
* Schätzt einen angemessenen Gesamt-Timeout für einen httpx-Lauf über
|
||||
* mehrere Ziele: (Zielanzahl / Threads) * Zeit pro Versuch (Timeout *
|
||||
* (1 + Retries)), mit Sicherheitsfaktor und Grenzen (30s..10min).
|
||||
*/
|
||||
function computeTimeoutMs(targetCount: number, threads: number, perProbeTimeoutSec: number, retries: number): number {
|
||||
const perProbeSeconds = perProbeTimeoutSec * (1 + retries);
|
||||
const estimatedSeconds = (targetCount / threads) * perProbeSeconds;
|
||||
const withMargin = estimatedSeconds * 1.5 + 10;
|
||||
// Math.round() analog zu masscan.ts: auch wenn Node's setTimeout() Floats
|
||||
// technisch toleriert, bleibt der Wert so konsistent nachvollziehbar
|
||||
// (ganze Sekunden in den Log-/Fehlermeldungen weiter oben).
|
||||
return Math.round(Math.min(Math.max(withMargin * 1000, 30_000), 10 * 60_000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Nutzt httpx (ProjectDiscovery, falls im Container installiert - siehe
|
||||
* Dockerfile), um eine LISTE von "ip:port"-Zielen ÜBER MEHRERE GERÄTE HINWEG
|
||||
* in einem einzigen Prozesslauf zu prüfen, statt pro Gerät einen eigenen
|
||||
* httpx-Prozess zu starten (das führte bei vielen parallel gescannten
|
||||
* Geräten - siehe routes/scan.ts CONCURRENT_DEVICES - zu bis zu 500
|
||||
* gleichzeitigen HTTP-Anfragen allein durch httpx und damit zu Timeouts
|
||||
* unter Last, siehe Bugreport).
|
||||
*
|
||||
* Ergebnis ist eine Map, deren Schlüssel "ip:port" ist (z. B.
|
||||
* "192.168.1.50:8080") - zusammengesetzt aus httpx' eigenem "url"-Feld
|
||||
* (IMMER Teil der -json-Ausgabe, im Gegensatz zu ggf. je nach Version
|
||||
* abweichenden Feldern wie "input"), damit die Zuordnung robust gegenüber
|
||||
* kleineren httpx-Versionsunterschieden ist.
|
||||
*
|
||||
* Liefert null (nicht eine leere Map!), wenn httpx fehlt oder komplett
|
||||
* fehlschlägt - der Aufrufer erkennt daran "kein httpx verfügbar" und fällt
|
||||
* für ALLE betroffenen Ports auf die eingebaute Methode zurück. Ein Ziel,
|
||||
* das httpx innerhalb des Timeouts nicht erreicht, taucht in der Map
|
||||
* schlicht nicht auf - von hier aus nicht unterscheidbar von "spricht kein
|
||||
* HTTP". Die Aufrufer (networkScanner.ts) prüfen daher jeden offenen Port,
|
||||
* der NICHT in der Map auftaucht, sicherheitshalber trotzdem noch einmal
|
||||
* selbst nach, statt ihn zu verwerfen.
|
||||
*/
|
||||
export async function probeManyWithHttpx(
|
||||
targets: Array<{ ip: string; port: number }>,
|
||||
onProgress?: (confirmedSoFar: number) => void
|
||||
): Promise<Map<string, HttpxResult> | null> {
|
||||
if (httpxConfirmedUnavailable || targets.length === 0) {
|
||||
return targets.length === 0 ? new Map() : null;
|
||||
}
|
||||
|
||||
await httpxSemaphore.acquire();
|
||||
try {
|
||||
const threads = computeThreads(targets.length);
|
||||
const perProbeTimeoutSec = 8;
|
||||
const retries = 1;
|
||||
const timeoutMs = computeTimeoutMs(targets.length, threads, perProbeTimeoutSec, retries);
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const child = spawn(
|
||||
"httpx",
|
||||
[
|
||||
"-silent",
|
||||
"-json",
|
||||
"-timeout",
|
||||
String(perProbeTimeoutSec),
|
||||
"-retries",
|
||||
String(retries),
|
||||
"-no-color",
|
||||
"-threads",
|
||||
String(threads),
|
||||
],
|
||||
{ stdio: ["pipe", "pipe", "pipe"] }
|
||||
);
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
// Zählt Zeilen live mit, während sie eintreffen (httpx schreibt jedes
|
||||
// Ergebnis sofort nach erfolgreicher Prüfung, nicht erst am Ende) -
|
||||
// für eine Live-Anzeige "X bereits gefunden" im Sammel-Scan-Job.
|
||||
// Erfasst nur ALS HTTP/HTTPS BESTÄTIGTE Ziele (jede Zeile ist ein
|
||||
// Treffer), nicht die Gesamtzahl geprüfter Ziele - httpx meldet
|
||||
// erfolglose Versuche gar nicht erst (siehe Kommentar an der Funktion
|
||||
// oben).
|
||||
let confirmedCount = 0;
|
||||
let progressParseBuffer = "";
|
||||
|
||||
const finish = (result: Map<string, HttpxResult> | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
child.on("error", (err) => {
|
||||
httpxLastError = err.message.slice(0, 400);
|
||||
// ENOENT (Programm fehlt) - künftig direkt auf den Fallback gehen,
|
||||
// statt bei jedem Scan erneut (dann garantiert erfolglos) zu versuchen.
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
httpxConfirmedUnavailable = true;
|
||||
}
|
||||
finish(null);
|
||||
});
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
const text = chunk.toString("utf-8");
|
||||
stdout += text;
|
||||
|
||||
if (!onProgress) return;
|
||||
progressParseBuffer += text;
|
||||
const lines = progressParseBuffer.split("\n");
|
||||
progressParseBuffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
confirmedCount += 1;
|
||||
onProgress(confirmedCount);
|
||||
}
|
||||
}
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf-8");
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
httpxLastError = `Zeitüberschreitung (${Math.round(timeoutMs / 1000)}s) - httpx wurde beendet, bevor alle ${targets.length} Ziel(e) geprüft waren`;
|
||||
child.kill();
|
||||
finish(null);
|
||||
}, timeoutMs);
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
// War der Lauf schon durch den Timeout oben als fehlgeschlagen
|
||||
// markiert (settled=true), NICHT mehr überschreiben - sonst geht die
|
||||
// konkrete "Zeitüberschreitung"-Meldung unter der generischen
|
||||
// Exit-Code-Meldung unter, die "close" hier sonst noch setzen würde
|
||||
// (der Kill selbst löst "close" erst danach aus).
|
||||
if (settled) return;
|
||||
|
||||
if (code !== 0 && stdout.trim().length === 0) {
|
||||
httpxLastError = (stderr.trim() || `httpx beendet mit Exit-Code ${code}`).slice(0, 400);
|
||||
if (/not found|no such file/i.test(stderr)) httpxConfirmedUnavailable = true;
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
|
||||
httpxLastError = null; // erfolgreicher Lauf - alten Fehler nicht mehr anzeigen
|
||||
|
||||
const results = new Map<string, HttpxResult>();
|
||||
for (const line of stdout.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const obj = JSON.parse(trimmed) as {
|
||||
url?: string;
|
||||
port?: string;
|
||||
scheme?: string;
|
||||
status_code?: number;
|
||||
title?: string;
|
||||
webserver?: string;
|
||||
};
|
||||
const port = Number(obj.port);
|
||||
if (!Number.isFinite(port) || !obj.url) continue;
|
||||
|
||||
// IP aus dem "url"-Feld ziehen (immer vorhanden, z. B.
|
||||
// "https://192.168.1.50:8080") statt aus ggf. je nach
|
||||
// httpx-Version abweichenden Zusatzfeldern - siehe Kommentar
|
||||
// oben an der Funktion.
|
||||
let ip: string;
|
||||
try {
|
||||
ip = new URL(obj.url).hostname;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
results.set(`${ip}:${port}`, {
|
||||
port,
|
||||
scheme: obj.scheme === "https" ? "https" : "http",
|
||||
statusCode: obj.status_code,
|
||||
title: obj.title,
|
||||
webserver: obj.webserver,
|
||||
});
|
||||
} catch {
|
||||
// einzelne kaputte Zeile ignorieren, Rest trotzdem verwerten
|
||||
}
|
||||
}
|
||||
finish(results);
|
||||
});
|
||||
|
||||
// Eingabe: "ip:port" pro Zeile, httpx erkennt Schema (http/https) je
|
||||
// Eintrag selbst (Standardverhalten: erst https versuchen, sonst http).
|
||||
for (const t of targets) {
|
||||
child.stdin.write(`${t.ip}:${t.port}\n`);
|
||||
}
|
||||
child.stdin.end();
|
||||
});
|
||||
} finally {
|
||||
httpxSemaphore.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelgerät-Variante von probeManyWithHttpx() - für den "Jetzt scannen"-
|
||||
* Button auf der Geräteseite (ein Gerät, kein Sammel-Scan).
|
||||
*/
|
||||
export async function probeWithHttpx(ip: string, ports: number[]): Promise<Map<number, HttpxResult> | null> {
|
||||
const result = await probeManyWithHttpx(ports.map((port) => ({ ip, port })));
|
||||
if (result === null) return null;
|
||||
|
||||
const map = new Map<number, HttpxResult>();
|
||||
for (const port of ports) {
|
||||
const hit = result.get(`${ip}:${port}`);
|
||||
if (hit) map.set(port, hit);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
266
apps/backend/src/scanner/iconDb.ts
Normal file
266
apps/backend/src/scanner/iconDb.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { request } from "node:https";
|
||||
|
||||
const TREE_URL = "https://raw.githubusercontent.com/homarr-labs/dashboard-icons/main/tree.json";
|
||||
const CDN_BASE = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png";
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 Tag
|
||||
|
||||
let cachedNames: string[] | null = null;
|
||||
let cachedAt = 0;
|
||||
let inFlight: Promise<string[]> | null = null;
|
||||
|
||||
function fetchJson(url: string): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = request(url, { timeout: 8000 }, (res) => {
|
||||
if (res.statusCode && res.statusCode >= 400) {
|
||||
reject(new Error(`HTTP ${res.statusCode}`));
|
||||
res.resume();
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on("timeout", () => req.destroy(new Error("Timeout")));
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt (und cacht 24h im Speicher) die Liste aller Icon-Namen aus dem
|
||||
* homarr-labs/dashboard-icons-Projekt (freie Icon-Sammlung speziell für
|
||||
* Homelab-/Selfhosted-Dashboards, via jsDelivr-CDN ausgeliefert).
|
||||
*
|
||||
* Bewusst als einzige Stelle im Projekt, die tatsächlich einen externen
|
||||
* Dienst kontaktiert (abgesehen vom eigentlichen Aufruf der Dienste selbst)
|
||||
* - das Icon-Datenbank-Feature wurde ausdrücklich so gewünscht. Schlägt der
|
||||
* Abruf fehl (kein Internetzugang, Dienst nicht erreichbar), liefert die
|
||||
* Suche einfach keine Treffer statt einen Fehler zu werfen.
|
||||
*/
|
||||
async function getIconNames(): Promise<string[]> {
|
||||
if (cachedNames && Date.now() - cachedAt < CACHE_TTL_MS) return cachedNames;
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const tree = (await fetchJson(TREE_URL)) as { png?: string[] };
|
||||
const names = (tree.png ?? []).map((f) => f.replace(/\.png$/, ""));
|
||||
cachedNames = names;
|
||||
cachedAt = Date.now();
|
||||
return names;
|
||||
} catch {
|
||||
// Kein Absturz, nur keine Treffer - die restliche Favicon-Auswahl
|
||||
// (bereits im System verwendete Favicons) funktioniert unabhängig davon weiter.
|
||||
return cachedNames ?? [];
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
export interface IconSearchResult {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export async function searchIconDb(query: string, limit = 24): Promise<IconSearchResult[]> {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (q.length < 2) return [];
|
||||
|
||||
const names = await getIconNames();
|
||||
const matches = names.filter((n) => n.includes(q)).slice(0, limit);
|
||||
return matches.map((name) => ({ name, url: `${CDN_BASE}/${name}.png` }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Begriffe, die beim Zerlegen eines Dienstnamens in Wörter komplett
|
||||
* ignoriert werden - reine Infrastruktur-/Container-Präfixe, nie ein
|
||||
* sinnvoller Icon-Treffer ("LXC Adguard" soll nach "Adguard" suchen, nicht
|
||||
* nach "LXC").
|
||||
*/
|
||||
const STOPWORDS = new Set([
|
||||
"lxc", "vm", "docker", "container", "srv", "ct", "instance",
|
||||
"the", "der", "die", "das",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Generische Kategorie-Wörter - beschreiben WAS etwas ist, nicht WELCHES
|
||||
* Produkt es ist ("Cam Reolink Keller" soll primär nach "Reolink" suchen,
|
||||
* nicht nach "Cam"). Werden nicht komplett verworfen (manchmal ist die
|
||||
* Kategorie das einzige brauchbare Wort), aber erst als letzter Ausweg
|
||||
* versucht, nachdem alle spezifischeren Wörter erfolglos blieben.
|
||||
*/
|
||||
const GENERIC_WORDS = new Set([
|
||||
"cam", "camera", "media", "print", "printer", "bridge", "hub", "sync",
|
||||
"box", "sensor", "plug", "light", "dock", "home", "server", "service",
|
||||
"app", "web", "tv", "cast", "stream", "monitor", "gateway", "node",
|
||||
"backup", "storage", "network", "device", "system",
|
||||
]);
|
||||
|
||||
function normalize(s: string): string {
|
||||
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/** Wörter in ursprünglicher Reihenfolge (für Bigramme wie "philips-hue" wichtig). */
|
||||
function significantWords(name: string): string[] {
|
||||
return name
|
||||
.split(/[\s_-]+/)
|
||||
.map((w) => normalize(w))
|
||||
.filter((w) => w.length >= 3 && !STOPWORDS.has(w));
|
||||
}
|
||||
|
||||
/** Aufeinanderfolgende Wortpaare in Original-Reihenfolge, z. B. "philips hue bridge" -> ["philips-hue", "hue-bridge"]. */
|
||||
function bigrams(words: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < words.length - 1; i++) out.push(`${words[i]}-${words[i + 1]}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Spezifische Wörter zuerst (längste zuerst - meist Markennamen), generische zuletzt. */
|
||||
function byPriority(words: string[]): string[] {
|
||||
const specific = words.filter((w) => !GENERIC_WORDS.has(w)).sort((a, b) => b.length - a.length);
|
||||
const generic = words.filter((w) => GENERIC_WORDS.has(w)).sort((a, b) => b.length - a.length);
|
||||
return [...specific, ...generic];
|
||||
}
|
||||
|
||||
function exactMatch(candidate: string, names: string[]): string | null {
|
||||
if (names.includes(candidate)) return candidate;
|
||||
const collapsed = candidate.replace(/-/g, "");
|
||||
return names.find((n) => n.replace(/-/g, "") === collapsed) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht den besten Treffer in der Icon-Datenbank für einen Dienst-/
|
||||
* Software-Namen ("Plex", "LXC Adguard", "Philips Hue Bridge") - für den
|
||||
* Fall, dass der Scanner selbst kein Favicon gefunden hat (siehe
|
||||
* networkScanner.ts). Läuft unbeaufsichtigt während eines Scans, liefert
|
||||
* deshalb nur EINEN Treffer und ausschließlich über exakte (bzw. nach
|
||||
* Entfernen von Bindestrichen exakte) Übereinstimmungen - keine
|
||||
* Teilstring-Suche, die wäre für eine automatische, unbeaufsichtigte
|
||||
* Zuordnung zu unzuverlässig.
|
||||
*
|
||||
* Reihenfolge: ganzer Name -> Wortpaare in Originalreihenfolge (erkennt
|
||||
* "Philips Hue" als Einheit) -> einzelne Wörter, spezifische (längere,
|
||||
* nicht-generische) zuerst.
|
||||
*/
|
||||
export async function findBestIconMatch(name: string): Promise<string | null> {
|
||||
const names = await getIconNames();
|
||||
|
||||
const wholeName = normalize(name);
|
||||
if (wholeName.length >= 2) {
|
||||
const match = exactMatch(wholeName, names);
|
||||
if (match) return `${CDN_BASE}/${match}.png`;
|
||||
}
|
||||
|
||||
const words = significantWords(name);
|
||||
|
||||
for (const pair of bigrams(words)) {
|
||||
const match = exactMatch(pair, names);
|
||||
if (match) return `${CDN_BASE}/${match}.png`;
|
||||
}
|
||||
|
||||
for (const word of byPriority(words)) {
|
||||
const match = exactMatch(word, names);
|
||||
if (match) return `${CDN_BASE}/${match}.png`;
|
||||
}
|
||||
|
||||
// Letzter, bewusst noch immer präziser Versuch: ein Name wie "adguard"
|
||||
// trifft "adguard-home" nicht exakt (auch nicht nach Bindestrich-Vergleich),
|
||||
// ist aber ein eindeutiger Wortanfang-Treffer, wenn KEIN anderer Icon-Name
|
||||
// mit "<wort>-" beginnt. Nur dann automatisch übernehmen - bei mehreren
|
||||
// möglichen Treffern bleibt es bei "kein Favicon" (dafür gibt es die
|
||||
// interaktive Auswahl mit mehreren Kandidaten).
|
||||
for (const candidate of [wholeName, ...byPriority(words)]) {
|
||||
if (candidate.length < 3) continue;
|
||||
const prefixMatches = names.filter((n) => n === candidate || n.startsWith(`${candidate}-`));
|
||||
if (prefixMatches.length === 1) return `${CDN_BASE}/${prefixMatches[0]}.png`;
|
||||
if (prefixMatches.length > 1) {
|
||||
// Mehrere Treffer (z. B. "adguard-home" UND "adguard-home-sync" für
|
||||
// "adguard") - den kürzesten (meist der "Haupt"-Name der Software)
|
||||
// automatisch nehmen, aber NUR wenn er eindeutig der kürzeste ist,
|
||||
// sonst bleibt es zweideutig (dafür gibt es die interaktive Auswahl).
|
||||
const sorted = [...prefixMatches].sort((a, b) => a.length - b.length);
|
||||
if (sorted[0].length < sorted[1].length) return `${CDN_BASE}/${sorted[0]}.png`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie findBestIconMatch, liefert aber ALLE plausiblen Kandidaten statt nur
|
||||
* den ersten - für die interaktive "Fehlende Favicons ergänzen"-Übersicht,
|
||||
* wo der Mensch selbst auswählt, welcher Treffer stimmt.
|
||||
*
|
||||
* Reihenfolge: erst alle exakten Treffer (ganzer Name, Wortpaare, einzelne
|
||||
* Wörter nach Priorität), DANACH erst - falls noch Platz bis zum Limit ist -
|
||||
* Teilstring-Treffer, wieder erst über spezifische Wörter. Generische
|
||||
* Kategorie-Wörter (siehe GENERIC_WORDS) werden für die Teilstring-Suche nur
|
||||
* herangezogen, wenn die spezifischen Wörter GAR NICHTS geliefert haben -
|
||||
* genau das war das gemeldete Problem ("Cam Blink Sync" fand nur
|
||||
* Kamera-Zubehör über "Cam" statt "Blink").
|
||||
*/
|
||||
export async function suggestIconsForServiceName(name: string, limit = 8): Promise<IconSearchResult[]> {
|
||||
const names = await getIconNames();
|
||||
const seen = new Set<string>();
|
||||
const results: IconSearchResult[] = [];
|
||||
|
||||
function addIfNew(iconName: string) {
|
||||
if (seen.has(iconName) || results.length >= limit) return;
|
||||
seen.add(iconName);
|
||||
results.push({ name: iconName, url: `${CDN_BASE}/${iconName}.png` });
|
||||
}
|
||||
|
||||
const words = significantWords(name);
|
||||
const specificWords = byPriority(words).filter((w) => !GENERIC_WORDS.has(w));
|
||||
const genericWords = byPriority(words).filter((w) => GENERIC_WORDS.has(w));
|
||||
|
||||
const wholeName = normalize(name);
|
||||
if (wholeName.length >= 2) {
|
||||
const exact = exactMatch(wholeName, names);
|
||||
if (exact) addIfNew(exact);
|
||||
}
|
||||
for (const pair of bigrams(words)) {
|
||||
const exact = exactMatch(pair, names);
|
||||
if (exact) addIfNew(exact);
|
||||
}
|
||||
for (const word of specificWords) {
|
||||
const exact = exactMatch(word, names);
|
||||
if (exact) addIfNew(exact);
|
||||
}
|
||||
|
||||
// Teilstring-Suche nur über spezifische Wörter, solange noch Platz ist.
|
||||
for (const word of specificWords) {
|
||||
if (results.length >= limit) break;
|
||||
for (const n of names) {
|
||||
if (results.length >= limit) break;
|
||||
if (n.includes(word)) addIfNew(n);
|
||||
}
|
||||
}
|
||||
|
||||
// Generische Wörter (z. B. "cam", "media") nur als allerletzter Ausweg,
|
||||
// wenn spezifische Wörter überhaupt nichts geliefert haben.
|
||||
if (results.length === 0) {
|
||||
for (const word of genericWords) {
|
||||
const exact = exactMatch(word, names);
|
||||
if (exact) addIfNew(exact);
|
||||
}
|
||||
for (const word of genericWords) {
|
||||
if (results.length >= limit) break;
|
||||
for (const n of names) {
|
||||
if (results.length >= limit) break;
|
||||
if (n.includes(word)) addIfNew(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
61
apps/backend/src/scanner/macVendors.ts
Normal file
61
apps/backend/src/scanner/macVendors.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Ordnet die ersten drei Oktette einer MAC-Adresse (OUI-Präfix) einem
|
||||
* Hersteller zu. Bewusst eine KLEINE, aber jeweils einzeln online
|
||||
* verifizierte Auswahl (nicht aus dem Gedächtnis geraten) - NICHT die
|
||||
* vollständige IEEE-OUI-Registrierung (die hat >50.000 Einträge). Komplett
|
||||
* offline zur Laufzeit, kein externer Dienst.
|
||||
*
|
||||
* Deckt bewusst die im Homelab-Kontext häufigsten "Kern"-Geräte ab: Single-
|
||||
* Board-Computer, NAS-Systeme und Virtualisierung. Router/Switches/IoT
|
||||
* wurden nicht aufgenommen, weil sich die exakten Präfixe nicht ohne
|
||||
* weitere ausführliche Recherche verlässlich verifizieren ließen - lieber
|
||||
* gar keinen Treffer als einen falschen.
|
||||
*
|
||||
* Unbekannte Präfixe liefern null, das Feld bleibt dann leer und frei
|
||||
* editierbar. Bereits gesetzte Werte werden nie überschrieben (siehe
|
||||
* Aufrufer in devices.ts).
|
||||
*/
|
||||
const OUI_VENDORS: Record<string, string> = {
|
||||
// Raspberry Pi Foundation / Raspberry Pi Trading Ltd
|
||||
// Quelle: forums.raspberrypi.com/viewtopic.php?t=347926, maclookup.app/macaddress/b827eb
|
||||
"28CDC1": "Raspberry Pi",
|
||||
B827EB: "Raspberry Pi",
|
||||
D83ADD: "Raspberry Pi",
|
||||
DCA632: "Raspberry Pi",
|
||||
E45F01: "Raspberry Pi",
|
||||
|
||||
// VMware (virtuelle Netzwerkkarten)
|
||||
// Quelle: maclookup.app/macaddress/005056, maclookup.app/macaddress/000c29
|
||||
"005056": "VMware (VM)",
|
||||
"000C29": "VMware (VM)",
|
||||
|
||||
// Synology
|
||||
// Quelle: maclookup.app/vendors/synology-incorporated
|
||||
"001132": "Synology",
|
||||
"9009D0": "Synology",
|
||||
|
||||
// QNAP
|
||||
// Quelle: maclookup.app/vendors/qnap-systems-inc
|
||||
"245EBE": "QNAP",
|
||||
E843B6: "QNAP",
|
||||
|
||||
// QEMU/KVM (Standard-Präfix für automatisch generierte VM-MACs,
|
||||
// u. a. von Proxmox VE verwendet) - allgemein bekannte Konvention.
|
||||
"525400": "QEMU/Proxmox (VM)",
|
||||
|
||||
// Docker (docker0-Bridge-Netzwerk) - Software-Konvention, kein
|
||||
// IEEE-registriertes OUI, aber in der Praxis sehr zuverlässig.
|
||||
"0242AC": "Docker (virtuell)",
|
||||
};
|
||||
|
||||
function normalizeOui(mac: string): string | null {
|
||||
const cleaned = mac.replace(/[^0-9A-Fa-f]/g, "").toUpperCase();
|
||||
if (cleaned.length < 6) return null;
|
||||
return cleaned.slice(0, 6);
|
||||
}
|
||||
|
||||
export function lookupVendorFromMac(mac: string): string | null {
|
||||
const oui = normalizeOui(mac);
|
||||
if (!oui) return null;
|
||||
return OUI_VENDORS[oui] ?? null;
|
||||
}
|
||||
274
apps/backend/src/scanner/masscan.ts
Normal file
274
apps/backend/src/scanner/masscan.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
let masscanConfirmedUnavailable = false;
|
||||
|
||||
// Klartext-Grund des letzten Fehlschlags - wird im Scan-Log angezeigt, damit
|
||||
// man sieht WARUM masscan nicht greift, statt nur "nicht genutzt/verfügbar"
|
||||
// ohne jede Erklärung.
|
||||
let masscanLastError: string | null = null;
|
||||
|
||||
export function getMasscanLastError(): string | null {
|
||||
return masscanLastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Einfacher Zähl-Semaphore mit Kapazität 1. Seit round49 lief HIERÜBER noch
|
||||
* pro GERÄT ein eigener masscan-Prozess - bei bis zu 10 parallel gescannten
|
||||
* Geräten (siehe routes/scan.ts CONCURRENT_DEVICES) wurden die vollständigen
|
||||
* Portscans dadurch faktisch serialisiert und der Gesamtscan massiv
|
||||
* verlangsamt (siehe Bugreport). Seit round50 gibt es dafür
|
||||
* scanHostsWithMasscan() weiter unten: EIN masscan-Prozess für ALLE Geräte
|
||||
* eines Scan-Laufs gleichzeitig (masscan kann das nativ, siehe
|
||||
* Mehrfach-Ziele als Argumente). Der Semaphore hier schützt nur noch den
|
||||
* seltenen Fall, dass zwei UNABHÄNGIGE Scan-Jobs (z. B. Sammel-Scan +
|
||||
* gleichzeitig ein manueller Einzelgeräte-Scan per Button) zufällig
|
||||
* überlappen.
|
||||
*/
|
||||
class Semaphore {
|
||||
private available: number;
|
||||
private readonly queue: (() => void)[] = [];
|
||||
|
||||
constructor(count: number) {
|
||||
this.available = count;
|
||||
}
|
||||
|
||||
acquire(): Promise<void> {
|
||||
if (this.available > 0) {
|
||||
this.available--;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => this.queue.push(resolve));
|
||||
}
|
||||
|
||||
release(): void {
|
||||
const next = this.queue.shift();
|
||||
if (next) next();
|
||||
else this.available++;
|
||||
}
|
||||
}
|
||||
|
||||
const masscanSemaphore = new Semaphore(1);
|
||||
|
||||
// Rate war bisher 2000 Pakete/Sekunde - ein sehr konservativer, für
|
||||
// Internet-weite Scans gedachter Wert. Auf einem lokalen Heimnetz verkraftet
|
||||
// masscan problemlos deutlich mehr, gerade jetzt wo nur noch EIN Prozess
|
||||
// gleichzeitig läuft (siehe Semaphore oben). 20.000 pps ist für
|
||||
// masscan-Verhältnisse immer noch sehr moderat.
|
||||
const DEFAULT_RATE = 20_000;
|
||||
|
||||
/**
|
||||
* Zählt die Ports in einem masscan-Portbereich ("1-65535", "80,443,8000-8100"
|
||||
* usw.) - für die Timeout-Schätzung unten. Fällt auf 65535 zurück, falls das
|
||||
* Format nicht erkannt wird (sichere Obergrenze, lieber zu großzügig als zu
|
||||
* knapp beim Timeout).
|
||||
*/
|
||||
function countPortsInRange(portRange: string): number {
|
||||
let count = 0;
|
||||
for (const part of portRange.split(",")) {
|
||||
const [a, b] = part.split("-").map(Number);
|
||||
if (Number.isFinite(a) && Number.isFinite(b)) count += Math.max(0, b - a + 1);
|
||||
else if (Number.isFinite(a)) count += 1;
|
||||
}
|
||||
return count > 0 ? count : 65535;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schätzt einen angemessenen Timeout für einen masscan-Lauf über mehrere
|
||||
* Hosts: Sendezeit (Gesamtzahl Probes / Rate) plus großzügiger
|
||||
* Sicherheitsfaktor (x2) plus masscans eigene Nachlaufzeit für spät
|
||||
* eintreffende Antworten (~15s), gedeckelt auf sinnvolle Grenzen (60s..15min).
|
||||
*/
|
||||
function computeTimeoutMs(hostCount: number, portRange: string, rate: number): number {
|
||||
const totalProbes = hostCount * countPortsInRange(portRange);
|
||||
const estimatedSeconds = totalProbes / rate;
|
||||
const withMargin = estimatedSeconds * 2 + 15;
|
||||
// Math.round() ist Pflicht: eine Kommazahl hier führte zuvor zu "The value
|
||||
// of "timeout" is out of range" (siehe Bugreport round53).
|
||||
return Math.round(Math.min(Math.max(withMargin * 1000, 60_000), 15 * 60_000));
|
||||
}
|
||||
|
||||
export interface MasscanProgress {
|
||||
/** Prozent (0-100) laut masscans eigener Fortschrittsanzeige. */
|
||||
percent: number;
|
||||
/** Bisher gefundene offene Ports, geräteübergreifend. */
|
||||
foundSoFar: number;
|
||||
/** Von masscan geschätzte verbleibende Sekunden, falls bekannt. */
|
||||
etaSeconds: number | null;
|
||||
}
|
||||
|
||||
// masscan gibt während des Laufs LAUFEND eine Statuszeile auf stderr aus
|
||||
// (standardmäßig per \r überschrieben, für ein Terminal gedacht - beim
|
||||
// Auslesen per Kindprozess kommen die \r trotzdem an), z. B.:
|
||||
// "rate: 1234.56-kpps, 45.23% done, 0:00:12 remaining, found=7"
|
||||
// oder kurz vor Schluss (fertig gesendet, wartet noch auf späte Antworten):
|
||||
// "rate: 0.00-kpps, 100.00% done, waiting 5-secs, found=7"
|
||||
// Diese Zeile wird unten live geparst, um echten Fortschritt (Prozent,
|
||||
// geschätzte Restzeit, bisherige Treffer) nach außen zu geben - vorher
|
||||
// wusste man während eines laufenden Sammel-Scans nur "masscan läuft…" ohne
|
||||
// jede weitere Information (siehe Bugreport).
|
||||
const MASSCAN_PROGRESS_RE =
|
||||
/rate:\s*([\d.]+)-kpps,\s*([\d.]+)%\s*done,\s*(?:(\d+):(\d+):(\d+)\s*remaining|waiting\s*(-?\d+)-secs),\s*found=(\d+)/;
|
||||
|
||||
/**
|
||||
* Nutzt masscan (falls im Container installiert, siehe Dockerfile) für den
|
||||
* vollständigen Portscan (1-65535) EINER GANZEN GRUPPE von Hosts in einem
|
||||
* einzigen Prozesslauf - masscan unterstützt mehrere Ziel-IPs nativ als
|
||||
* Argumente und verschickt SYN-Pakete asynchron per Rohsocket, ohne auf jede
|
||||
* einzelne TCP-Verbindung zu warten.
|
||||
*
|
||||
* Fällt automatisch auf null zurück, wenn masscan fehlt, keine Rechte hat
|
||||
* (braucht CAP_NET_RAW, siehe docker-compose.yml) oder aus anderem Grund
|
||||
* fehlschlägt - der Aufrufer nutzt dann stattdessen scanPortsInBatches PRO
|
||||
* GERÄT. Der genaue Grund eines Fehlschlags steht danach in
|
||||
* getMasscanLastError().
|
||||
*
|
||||
* @param onProgress Optionaler Callback, der bei JEDER von masscan
|
||||
* ausgegebenen Statuszeile mit dem aktuellen Fortschritt aufgerufen wird
|
||||
* (siehe MasscanProgress) - für eine Live-Anzeige im Sammel-Scan-Job.
|
||||
*/
|
||||
export async function scanHostsWithMasscan(
|
||||
hosts: string[],
|
||||
portRange: string,
|
||||
rate: number = DEFAULT_RATE,
|
||||
onProgress?: (progress: MasscanProgress) => void
|
||||
): Promise<Map<string, number[]> | null> {
|
||||
if (masscanConfirmedUnavailable || hosts.length === 0) {
|
||||
return hosts.length === 0 ? new Map() : null;
|
||||
}
|
||||
|
||||
await masscanSemaphore.acquire();
|
||||
try {
|
||||
const timeoutMs = computeTimeoutMs(hosts.length, portRange, rate);
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const child = spawn("masscan", [...hosts, "-p", portRange, "--rate", String(rate), "-oL", "-"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
// Nur die letzten ~2000 Zeichen von stderr vorhalten (für die
|
||||
// Fehlermeldung bei einem Abbruch) - masscan schreibt bei langen
|
||||
// Läufen laufend Statuszeilen, eine unbegrenzte Sammlung würde bei
|
||||
// vielen Minuten Laufzeit unnötig Speicher aufbauen.
|
||||
let stderrTail = "";
|
||||
let stderrParseBuffer = "";
|
||||
let settled = false;
|
||||
|
||||
const finish = (result: Map<string, number[]> | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
child.on("error", (err) => {
|
||||
masscanLastError = err.message.slice(0, 400);
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
masscanConfirmedUnavailable = true;
|
||||
}
|
||||
finish(null);
|
||||
});
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
// Defensive Obergrenze - siehe frühere maxBuffer-Absicherung bei
|
||||
// execFile, hier von Hand nachgebildet, da spawn() das nicht kennt.
|
||||
if (stdout.length < 64 * 1024 * 1024) {
|
||||
stdout += chunk.toString("utf-8");
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
const text = chunk.toString("utf-8");
|
||||
stderrTail = (stderrTail + text).slice(-2000);
|
||||
|
||||
if (!onProgress) return;
|
||||
// masscan trennt Status-Updates per \r (Terminal-Konvention) statt
|
||||
// \n - beide als Trenner behandeln. Der letzte, noch unvollständige
|
||||
// Rest bleibt im Puffer bis zum nächsten Chunk.
|
||||
stderrParseBuffer += text;
|
||||
const parts = stderrParseBuffer.split(/[\r\n]+/);
|
||||
stderrParseBuffer = parts.pop() ?? "";
|
||||
for (const line of parts) {
|
||||
const match = line.match(MASSCAN_PROGRESS_RE);
|
||||
if (!match) continue;
|
||||
const percent = Number(match[2]);
|
||||
const foundSoFar = Number(match[7]);
|
||||
let etaSeconds: number | null = null;
|
||||
if (match[3] !== undefined) {
|
||||
etaSeconds = Number(match[3]) * 3600 + Number(match[4]) * 60 + Number(match[5]);
|
||||
} else if (match[6] !== undefined) {
|
||||
etaSeconds = Math.max(0, Number(match[6]));
|
||||
}
|
||||
if (Number.isFinite(percent)) onProgress({ percent, foundSoFar, etaSeconds });
|
||||
}
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
masscanLastError = `Zeitüberschreitung nach ${Math.round(timeoutMs / 1000)}s (${hosts.length} Host(s), Portbereich ${portRange}) - masscan wurde beendet, bevor der Scan fertig war. Letzter Stand vor Abbruch: "${stderrTail.trim() || "(keine Ausgabe)"}"`;
|
||||
child.kill();
|
||||
finish(null);
|
||||
}, timeoutMs);
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (settled) return; // schon per Timeout oben abgeschlossen
|
||||
|
||||
if (code !== 0) {
|
||||
const exitCode = code ?? "unbekannt";
|
||||
masscanLastError = `Exit-Code ${exitCode}: ${stderrTail.trim() || "(keine Fehlerausgabe)"}`.slice(0, 400);
|
||||
|
||||
const msg = stderrTail.toLowerCase();
|
||||
if (
|
||||
msg.includes("permission") ||
|
||||
msg.includes("operation not permitted") ||
|
||||
msg.includes("pcap")
|
||||
) {
|
||||
masscanConfirmedUnavailable = true;
|
||||
}
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// "-oL -" (List-Format) gibt pro offenem Port eine Zeile aus:
|
||||
// "open tcp 80 192.168.1.50 1700000000" - die IP-Spalte (Index 3)
|
||||
// ist bei mehreren Ziel-Hosts der Schlüssel, über den die Treffer
|
||||
// den einzelnen Geräten zugeordnet werden.
|
||||
const portsByHost = new Map<string, number[]>();
|
||||
for (const line of stdout.split("\n")) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts[0] === "open" && parts[1] === "tcp") {
|
||||
const port = Number(parts[2]);
|
||||
const ip = parts[3];
|
||||
if (Number.isFinite(port) && ip) {
|
||||
const list = portsByHost.get(ip);
|
||||
if (list) list.push(port);
|
||||
else portsByHost.set(ip, [port]);
|
||||
}
|
||||
}
|
||||
}
|
||||
masscanLastError = null; // erfolgreicher Lauf - alten Fehler nicht mehr anzeigen
|
||||
finish(portsByHost);
|
||||
});
|
||||
|
||||
});
|
||||
} finally {
|
||||
masscanSemaphore.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelgerät-Variante von scanHostsWithMasscan() - für den "Jetzt scannen"-
|
||||
* Button auf der Geräteseite (ein Gerät, kein Sammel-Scan).
|
||||
*/
|
||||
export async function scanWithMasscan(
|
||||
host: string,
|
||||
portRange: string,
|
||||
rate: number = DEFAULT_RATE
|
||||
): Promise<number[] | null> {
|
||||
const result = await scanHostsWithMasscan([host], portRange, rate);
|
||||
if (result === null) return null;
|
||||
return result.get(host) ?? [];
|
||||
}
|
||||
|
||||
export function isMasscanConfirmedUnavailable(): boolean {
|
||||
return masscanConfirmedUnavailable;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
45
apps/backend/src/scanner/ping.ts
Normal file
45
apps/backend/src/scanner/ping.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { platform } from "node:os";
|
||||
|
||||
let pingBinaryConfirmedMissing = false;
|
||||
|
||||
/**
|
||||
* Prüft per System-Ping (ICMP), ob eine IP erreichbar ist. Bewusst NICHT über
|
||||
* einen Port-Connect (wie isPortOpen in ports.ts) - ein Gerät kann online
|
||||
* sein, ohne einen der bekannten Ports offen zu haben (z. B. ein Drucker
|
||||
* ohne Web-UI), ein echter Ping ist hier das treffendere Signal.
|
||||
*
|
||||
* Nutzt den System-ping-Befehl statt eines rohen ICMP-Sockets, da Node dafür
|
||||
* root-Rechte bräuchte - für einen einzelnen Zwischencheck reicht das völlig.
|
||||
* Timeout: 1 Sekunde, ein einzelnes Paket.
|
||||
*/
|
||||
export function pingHost(ip: string): Promise<boolean> {
|
||||
const isWindows = platform() === "win32";
|
||||
const command = isWindows ? `ping -n 1 -w 1000 ${ip}` : `ping -c 1 -W 1 ${ip}`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
exec(command, { timeout: 2000 }, (error, _stdout, stderr) => {
|
||||
// Fehlt der ping-Befehl im Container, meldet die Shell das über exec
|
||||
// NICHT als Node-ENOENT, sondern als regulären Fehlschlag mit
|
||||
// Exit-Code 127 und "not found" in stderr (z. B. "/bin/sh: 1: ping:
|
||||
// not found") - das wird hier separat erkannt (siehe
|
||||
// isPingBinaryConfirmedMissing), damit ein fehlendes ping-Programm
|
||||
// nicht stillschweigend JEDEN Scan leerlaufen lässt.
|
||||
if (error && (error.code === 127 || /not found/i.test(stderr))) {
|
||||
pingBinaryConfirmedMissing = true;
|
||||
}
|
||||
resolve(!error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* true, wenn ein vorheriger pingHost()-Aufruf festgestellt hat, dass der
|
||||
* ping-Befehl im Container gar nicht existiert (z. B. iputils-ping fehlt im
|
||||
* Image). Wird von scanDeviceServices genutzt, um den Ping-Vorab-Check in
|
||||
* dem Fall zu überspringen, statt fälschlich jedes Gerät als nicht
|
||||
* erreichbar zu melden.
|
||||
*/
|
||||
export function isPingBinaryConfirmedMissing(): boolean {
|
||||
return pingBinaryConfirmedMissing;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;"]
|
||||
|
||||
82
apps/frontend/docker-entrypoint.sh
Normal file
82
apps/frontend/docker-entrypoint.sh
Normal file
@@ -0,0 +1,82 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
CERT_DIR="/etc/nginx/certs"
|
||||
CA_KEY="$CERT_DIR/ca-key.pem"
|
||||
CA_CERT="$CERT_DIR/ca-cert.pem"
|
||||
CERT_FILE="$CERT_DIR/fullchain.pem"
|
||||
KEY_FILE="$CERT_DIR/privkey.pem"
|
||||
CERT_HOST_FILE="$CERT_DIR/.cert-host"
|
||||
HOST="${LAUNCHPAD_HOST:-localhost}"
|
||||
EXTRA_HOST="${LAUNCHPAD_EXTRA_HOST:-}"
|
||||
|
||||
mkdir -p "$CERT_DIR"
|
||||
|
||||
# Baut einen SAN-Eintrag (IP:... oder DNS:...) für einen einzelnen Hostwert.
|
||||
san_entry_for() {
|
||||
case "$1" in
|
||||
*[0-9]*.*[0-9]*.*[0-9]*.*[0-9]*)
|
||||
echo "IP:$1"
|
||||
;;
|
||||
*)
|
||||
echo "DNS:$1"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ] && [ ! -f "$CA_CERT" ]; then
|
||||
# fullchain.pem/privkey.pem wurden vom Nutzer eingebunden (z. B. eigenes
|
||||
# Zertifikat) und es gibt keine von uns erzeugte CA dazu -> nichts anfassen.
|
||||
echo "[entrypoint] Eingebundenes Zertifikat gefunden (kein CA-Download unter /ca.crt verfügbar)."
|
||||
else
|
||||
# Eigene lokale Root-CA erzeugen, falls noch keine existiert. Liegt im
|
||||
# Volume "launchpad-certs" und bleibt über Neustarts/Rebuilds stabil -
|
||||
# einmal pro Gerät importiert, reicht dauerhaft.
|
||||
if [ ! -f "$CA_KEY" ] || [ ! -f "$CA_CERT" ]; then
|
||||
echo "[entrypoint] Erzeuge neue lokale Root-CA für LaunchPad..."
|
||||
openssl genrsa -out "$CA_KEY" 2048 2>/dev/null
|
||||
openssl req -x509 -new -nodes -key "$CA_KEY" -sha256 -days 3650 \
|
||||
-subj "/CN=LaunchPad Local CA" \
|
||||
-out "$CA_CERT" 2>/dev/null
|
||||
fi
|
||||
|
||||
# Kombinierte Kennung aus HOST + EXTRA_HOST, um zu erkennen ob sich einer
|
||||
# von beiden geändert hat -> dann Server-Zertifikat neu ausstellen. Die CA
|
||||
# selbst bleibt davon unberührt (bereits erteiltes Gerätevertrauen gilt weiter).
|
||||
HOST_SIGNATURE="$HOST|$EXTRA_HOST"
|
||||
NEED_NEW_LEAF=false
|
||||
if [ ! -f "$CERT_FILE" ] || [ ! -f "$KEY_FILE" ]; then
|
||||
NEED_NEW_LEAF=true
|
||||
elif [ "$(cat "$CERT_HOST_FILE" 2>/dev/null)" != "$HOST_SIGNATURE" ]; then
|
||||
NEED_NEW_LEAF=true
|
||||
fi
|
||||
|
||||
if [ "$NEED_NEW_LEAF" = "true" ]; then
|
||||
SAN="$(san_entry_for "$HOST"),DNS:localhost,IP:127.0.0.1"
|
||||
if [ -n "$EXTRA_HOST" ] && [ "$EXTRA_HOST" != "$HOST" ]; then
|
||||
SAN="$SAN,$(san_entry_for "$EXTRA_HOST")"
|
||||
fi
|
||||
|
||||
EXTFILE=$(mktemp)
|
||||
printf "subjectAltName=%s\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n" "$SAN" > "$EXTFILE"
|
||||
|
||||
openssl genrsa -out "$KEY_FILE" 2048 2>/dev/null
|
||||
openssl req -new -key "$KEY_FILE" -subj "/CN=$HOST" -out "$CERT_DIR/server.csr" 2>/dev/null
|
||||
openssl x509 -req -in "$CERT_DIR/server.csr" -CA "$CA_CERT" -CAkey "$CA_KEY" \
|
||||
-CAcreateserial -days 825 -sha256 -extfile "$EXTFILE" \
|
||||
-out "$CERT_FILE" 2>/dev/null
|
||||
|
||||
rm -f "$EXTFILE" "$CERT_DIR/server.csr"
|
||||
echo "$HOST_SIGNATURE" > "$CERT_HOST_FILE"
|
||||
echo "[entrypoint] Server-Zertifikat erzeugt (SAN: $SAN), mit lokaler CA signiert."
|
||||
fi
|
||||
fi
|
||||
|
||||
# CA-Zertifikat zum Download bereitstellen (Android/iOS/Windows bieten beim
|
||||
# Öffnen einer .crt-Datei im Browser automatisch "Zertifikat installieren" an).
|
||||
if [ -f "$CA_CERT" ]; then
|
||||
mkdir -p /usr/share/nginx/html
|
||||
cp "$CA_CERT" /usr/share/nginx/html/ca.crt
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -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>
|
||||
|
||||
40
apps/frontend/nginx-locations.conf
Normal file
40
apps/frontend/nginx-locations.conf
Normal file
@@ -0,0 +1,40 @@
|
||||
# PWA / SPA: alle unbekannten Routen auf index.html zurückführen
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API-Anfragen an den Backend-Container weiterleiten
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3001/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Service Worker und Manifest dürfen nicht langfristig gecacht werden,
|
||||
# sonst bekommen installierte PWA-Nutzer nie ein Update mit.
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /manifest.webmanifest {
|
||||
add_header Cache-Control "no-cache";
|
||||
default_type application/manifest+json;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Root-CA zum Download – Android/iOS erkennen diesen MIME-Type und bieten
|
||||
# beim Öffnen im Browser direkt "Zertifikat installieren" an.
|
||||
location = /ca.crt {
|
||||
add_header Cache-Control "no-cache";
|
||||
default_type application/x-x509-ca-cert;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~* \.(?:css|js|svg|png|jpg|jpeg|gif|ico|woff2?)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, max-age=604800, immutable";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
8
apps/frontend/public/opensearch.xml
Normal file
8
apps/frontend/public/opensearch.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
||||
<ShortName>LaunchPad</ShortName>
|
||||
<Description>Dienste und Lesezeichen in LaunchPad suchen und direkt öffnen</Description>
|
||||
<InputEncoding>UTF-8</InputEncoding>
|
||||
<Image height="16" width="16" type="image/svg+xml">/favicon.svg</Image>
|
||||
<Url type="text/html" method="get" template="/search?q={searchTerms}"/>
|
||||
</OpenSearchDescription>
|
||||
866
apps/frontend/src/components/MobileAndroidHome.tsx
Normal file
866
apps/frontend/src/components/MobileAndroidHome.tsx
Normal file
@@ -0,0 +1,866 @@
|
||||
import { useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faGear,
|
||||
faMagnifyingGlass,
|
||||
faXmark,
|
||||
faClockRotateLeft,
|
||||
faBookmark,
|
||||
faPen,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import type { Category, Service, Bookmark, SearchResult } from "@launchpad/shared";
|
||||
import { rankSearchResults } from "@launchpad/shared";
|
||||
import { ResultsList, Button } from "@launchpad/ui";
|
||||
import { useHomeLayout, type HomeSlot, type ItemRef } from "../hooks/useHomeLayout.js";
|
||||
|
||||
/**
|
||||
* Android-Startbildschirm-artiges alternatives Design für die mobile Ansicht
|
||||
* (siehe Einstellungen -> "Design"). Zeigt AUSSCHLIESSLICH als Favorit
|
||||
* markierte Dienste/Lesezeichen (siehe Bugreport round65: vorher wurden auch
|
||||
* alle Kategorien automatisch als Ordner gezeigt - das war zu viel/nicht
|
||||
* kuratiert genug). Die Anordnung (Reihenfolge, eigene Ordner, Aufteilung
|
||||
* Hauptbereich/Dock) legt der Nutzer selbst per Ziehen fest, genau wie auf
|
||||
* einem echten Android-Handy - siehe useHomeLayout.ts für die
|
||||
* Datenstruktur/Persistierung und die Drag-Logik weiter unten in dieser Datei.
|
||||
*
|
||||
* WICHTIG zur Interaktion: klassisches HTML5-Drag&Drop (draggable=true,
|
||||
* onDragStart/onDrop, siehe FavoritesBar.tsx an anderer Stelle im Projekt)
|
||||
* feuert auf Touchscreens NICHT zuverlässig - für eine mobile Oberfläche
|
||||
* daher bewusst mit der Pointer-Events-API nachgebaut (vereinheitlicht
|
||||
* Maus/Touch/Stift), inklusive Lang-Drücken zum Start (350ms), damit
|
||||
* normales Scrollen nicht versehentlich als Ziehen interpretiert wird.
|
||||
*
|
||||
* Zusätzlich: mobile Browser fangen ein Lang-Drücken auf einem <img> (bzw.
|
||||
* dem umgebenden Element) standardmäßig SELBST ab und zeigen ihr eigenes
|
||||
* Kontextmenü ("Bild speichern"/"Bild kopieren" usw.), noch bevor unser
|
||||
* eigener Drag-Timer greift. Dagegen: draggable={false} + pointer-events-none
|
||||
* auf den <img>-Tags (Touch-Events landen dadurch immer auf dem umgebenden
|
||||
* <button>), -webkit-touch-callout:none (unterdrückt das iOS-Kontextmenü),
|
||||
* onContextMenu-preventDefault (unterdrückt das Android-Pendant).
|
||||
*
|
||||
* Außerdem (Korrektur nach erstem Anlauf): die Kacheln brauchen
|
||||
* touch-action:"none" von Anfang an, NICHT "manipulation" - mit
|
||||
* "manipulation" beansprucht der Browser eine Zieh-nach-unten-Geste bereits
|
||||
* für natives Scrollen, bevor unser preventDefault() in handlePointerMove
|
||||
* überhaupt zum Zug kommt (führte dazu, dass beim Ziehen das
|
||||
* Handy-Benachrichtigungsmenü von oben aufging statt das Icon zu bewegen).
|
||||
* Mit touch-action:"none" überlässt der Browser Touch-Gesten auf den
|
||||
* Kacheln von vornherein komplett uns. Nebeneffekt: ein Scroll-Versuch, der
|
||||
* exakt auf einem Icon beginnt, scrollt nicht mehr nativ - das ist
|
||||
* hinnehmbar (Wischen zwischen den Icons/auf dem Hintergrund scrollt
|
||||
* weiterhin normal) und entspricht dem Verhalten eines echten
|
||||
* Android-Homescreens, wo ein Icon selbst auch nicht als Scroll-Fläche dient.
|
||||
*
|
||||
* Signatur-Element: der Wallpaper-Hintergrund besteht aus den tatsächlichen,
|
||||
* in den eigenen Kategorien hinterlegten Farben (Admin -> Kategorien).
|
||||
*/
|
||||
|
||||
const FALLBACK_PALETTE = ["#6366f1", "#a855f7", "#06b6d4", "#f97316"];
|
||||
const BLOB_POSITIONS = [
|
||||
{ top: "-10%", left: "-15%" },
|
||||
{ top: "55%", left: "60%" },
|
||||
{ top: "70%", left: "-20%" },
|
||||
{ top: "-15%", left: "55%" },
|
||||
];
|
||||
const LONG_PRESS_MS = 350;
|
||||
const MOVE_CANCEL_PX = 10;
|
||||
|
||||
function resolveIconSrc(src: string): string {
|
||||
if (src.startsWith("data:")) return src;
|
||||
return `/api/favicon-proxy?url=${encodeURIComponent(src)}`;
|
||||
}
|
||||
|
||||
interface ResolvedItem {
|
||||
id: string;
|
||||
type: "service" | "bookmark";
|
||||
displayName: string;
|
||||
favicon: string | null;
|
||||
url: string;
|
||||
category: string | null;
|
||||
}
|
||||
|
||||
function slotId(): string {
|
||||
return `slot-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
function formatSavedAt(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const datePart = date.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "2-digit" });
|
||||
const timePart = date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
return `${datePart}, ${timePart}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reine Funktion, die aus dem Ist-Zustand (Layout) + der abgeschlossenen
|
||||
* Ziehgeste das NEUE Layout berechnet - bewusst ohne React-State-Zugriff,
|
||||
* damit sie unabhängig testbar/nachvollziehbar bleibt.
|
||||
*
|
||||
* Regeln (angelehnt an Android):
|
||||
* - Item auf Item gezogen -> neuer Ordner mit beiden.
|
||||
* - Item auf Ordner gezogen -> tritt dem Ordner bei.
|
||||
* - Ordner auf irgendwas gezogen (oder Item auf leeren Bereich) -> reine
|
||||
* Neusortierung, kein Verschmelzen (keine verschachtelten Ordner).
|
||||
* - Dock hat eine feste Obergrenze (siehe maxDockSlots) - ein Ablegen dort,
|
||||
* das die Grenze sprengen würde, wird verworfen (Layout bleibt
|
||||
* unverändert, wie ein Android-Dock, das "voll" ist).
|
||||
*/
|
||||
function applyDragResult(
|
||||
layout: { grid: HomeSlot[]; dock: HomeSlot[] },
|
||||
dragged: HomeSlot,
|
||||
hover: { area: "grid" | "dock"; slotId: string } | null,
|
||||
maxDockSlots: number
|
||||
): { grid: HomeSlot[]; dock: HomeSlot[] } | null {
|
||||
if (!hover) return null;
|
||||
|
||||
const grid = layout.grid.filter((s) => s.id !== dragged.id);
|
||||
const dock = layout.dock.filter((s) => s.id !== dragged.id);
|
||||
const targetArr = hover.area === "grid" ? grid : dock;
|
||||
|
||||
if (hover.slotId === "__end__") {
|
||||
if (hover.area === "dock" && dock.length >= maxDockSlots) return null;
|
||||
targetArr.push(dragged);
|
||||
return { grid, dock };
|
||||
}
|
||||
|
||||
const targetIndex = targetArr.findIndex((s) => s.id === hover.slotId);
|
||||
if (targetIndex === -1) return null;
|
||||
const targetSlot = targetArr[targetIndex];
|
||||
|
||||
if (dragged.kind === "item" && targetSlot.kind === "item") {
|
||||
targetArr.splice(targetIndex, 1, {
|
||||
id: slotId(),
|
||||
kind: "folder",
|
||||
name: "Neuer Ordner",
|
||||
items: [targetSlot.ref, dragged.ref],
|
||||
});
|
||||
} else if (dragged.kind === "item" && targetSlot.kind === "folder") {
|
||||
targetArr.splice(targetIndex, 1, { ...targetSlot, items: [...targetSlot.items, dragged.ref] });
|
||||
} else {
|
||||
if (hover.area === "dock" && dock.length >= maxDockSlots) return null;
|
||||
targetArr.splice(targetIndex, 0, dragged);
|
||||
}
|
||||
|
||||
return { grid, dock };
|
||||
}
|
||||
|
||||
interface TileVisualProps {
|
||||
favicon?: string | null;
|
||||
label: string;
|
||||
size?: "normal" | "small";
|
||||
dimmed?: boolean;
|
||||
}
|
||||
|
||||
function TileIcon({ favicon, label, size = "normal", dimmed }: TileVisualProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const dimension = size === "small" ? "h-9 w-9" : "h-14 w-14";
|
||||
const rounding = size === "small" ? "rounded-xl" : "rounded-[20px]";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`flex ${dimension} ${rounding} shrink-0 items-center justify-center bg-white/75
|
||||
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm transition-opacity
|
||||
dark:bg-white/10 dark:ring-white/10 ${dimmed ? "opacity-30" : ""}`}
|
||||
>
|
||||
{favicon && !failed ? (
|
||||
<img
|
||||
src={resolveIconSrc(favicon)}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className={`pointer-events-none select-none [-webkit-touch-callout:none] ${
|
||||
size === "small" ? "h-5 w-5" : "h-8 w-8"
|
||||
} object-contain`}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className={`font-semibold text-black/70 dark:text-white/70 ${size === "small" ? "text-xs" : "text-lg"}`}>
|
||||
{label.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderTile({ icons, dimmed }: { icons: Array<{ favicon: string | null; label: string }>; dimmed?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`grid h-14 w-14 shrink-0 grid-cols-2 grid-rows-2 gap-1 rounded-[20px] bg-white/60 p-1.5
|
||||
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm transition-opacity
|
||||
dark:bg-white/10 dark:ring-white/10 ${dimmed ? "opacity-30" : ""}`}
|
||||
>
|
||||
{Array.from({ length: 4 }).map((_, i) => {
|
||||
const item = icons[i];
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="flex items-center justify-center overflow-hidden rounded-md bg-white/70 dark:bg-white/10"
|
||||
>
|
||||
{item?.favicon ? (
|
||||
<img
|
||||
src={resolveIconSrc(item.favicon)}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="pointer-events-none h-full w-full select-none object-contain p-0.5 [-webkit-touch-callout:none]"
|
||||
/>
|
||||
) : item ? (
|
||||
<span className="text-[8px] font-semibold text-black/50">{item.label.charAt(0).toUpperCase()}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type OpenTarget = { url: string; id: string; kind: "service" | "bookmark" };
|
||||
|
||||
interface MinimalVisitItem {
|
||||
id: string;
|
||||
url: string;
|
||||
displayName: string;
|
||||
favicon: string | null;
|
||||
kind?: "service" | "bookmark";
|
||||
}
|
||||
|
||||
export interface MobileAndroidHomeProps {
|
||||
services: Service[];
|
||||
bookmarks: Bookmark[];
|
||||
categories: Category[];
|
||||
categoryColors: Record<string, string>;
|
||||
allSearchItems: SearchResult[];
|
||||
recentVisits: MinimalVisitItem[];
|
||||
readLaterItems: Array<{ id: string; url: string; displayName: string; favicon: string | null; savedAt: string }>;
|
||||
onOpen: (item: OpenTarget) => void;
|
||||
onToggleFavorite: (item: SearchResult) => void;
|
||||
onAddReadLater: (url: string) => void;
|
||||
isOnline: boolean;
|
||||
/**
|
||||
* Wird erst true, wenn ALLE für das Raster nötigen Daten (Dienste,
|
||||
* Lesezeichen, Kategorien, Zuletzt-besucht, Einstellungen/Layout)
|
||||
* mindestens einmal geladen sind - siehe Kommentar in HomePage.tsx. Bis
|
||||
* dahin wird ein einfacher Platzhalter statt des Rasters gezeigt, damit
|
||||
* nicht einzelne Kacheln nacheinander "nachrücken".
|
||||
*/
|
||||
isReady: boolean;
|
||||
}
|
||||
|
||||
export function MobileAndroidHome({
|
||||
services,
|
||||
bookmarks,
|
||||
categories,
|
||||
categoryColors,
|
||||
allSearchItems,
|
||||
recentVisits,
|
||||
readLaterItems,
|
||||
onOpen,
|
||||
onToggleFavorite,
|
||||
onAddReadLater,
|
||||
isOnline,
|
||||
isReady,
|
||||
}: MobileAndroidHomeProps) {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [openFolderId, setOpenFolderId] = useState<string | null>(null);
|
||||
const [readLaterUrl, setReadLaterUrl] = useState("");
|
||||
const [renamingFolder, setRenamingFolder] = useState(false);
|
||||
const [folderNameDraft, setFolderNameDraft] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const palette = useMemo(() => {
|
||||
const fromCategories = (categories ?? []).map((c) => c.color).filter((c): c is string => Boolean(c));
|
||||
const unique = Array.from(new Set(fromCategories));
|
||||
return unique.length > 0 ? unique.slice(0, 4) : FALLBACK_PALETTE;
|
||||
}, [categories]);
|
||||
|
||||
const serviceById = useMemo(() => new Map(services.map((s) => [s.id, s])), [services]);
|
||||
const bookmarkById = useMemo(() => new Map(bookmarks.map((b) => [b.id, b])), [bookmarks]);
|
||||
|
||||
const resolveRef = (ref: ItemRef): ResolvedItem | null => {
|
||||
if (ref.type === "service") {
|
||||
const s = serviceById.get(ref.id);
|
||||
if (!s || !s.visible) return null;
|
||||
return { id: s.id, type: "service", displayName: s.displayName, favicon: s.favicon, url: s.url, category: s.category };
|
||||
}
|
||||
const b = bookmarkById.get(ref.id);
|
||||
if (!b) return null;
|
||||
return { id: b.id, type: "bookmark", displayName: b.displayName, favicon: b.favicon, url: b.url, category: b.category };
|
||||
};
|
||||
|
||||
const favoriteRefs: ItemRef[] = useMemo(
|
||||
() => [
|
||||
...services.filter((s) => s.visible && s.favorite).map((s) => ({ id: s.id, type: "service" as const })),
|
||||
...bookmarks.filter((b) => b.favorite).map((b) => ({ id: b.id, type: "bookmark" as const })),
|
||||
],
|
||||
[services, bookmarks]
|
||||
);
|
||||
|
||||
const { layout, saveLayout, maxDockSlots } = useHomeLayout(favoriteRefs);
|
||||
|
||||
// --- Ziehen (Pointer Events, siehe Kommentar oben an der Datei) ---
|
||||
const [drag, setDrag] = useState<{
|
||||
slot: HomeSlot;
|
||||
pointerId: number;
|
||||
x: number;
|
||||
y: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
} | null>(null);
|
||||
const [hover, setHover] = useState<{ area: "grid" | "dock"; slotId: string } | null>(null);
|
||||
const longPressTimer = useRef<number | null>(null);
|
||||
const pressStart = useRef<{ x: number; y: number } | null>(null);
|
||||
const suppressNextClick = useRef(false);
|
||||
|
||||
function cancelPendingPress() {
|
||||
if (longPressTimer.current !== null) {
|
||||
window.clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
}
|
||||
pressStart.current = null;
|
||||
}
|
||||
|
||||
function handlePointerDown(e: ReactPointerEvent<HTMLButtonElement>, slot: HomeSlot) {
|
||||
if (e.pointerType === "mouse" && e.button !== 0) return;
|
||||
pressStart.current = { x: e.clientX, y: e.clientY };
|
||||
const target = e.currentTarget;
|
||||
const pointerId = e.pointerId;
|
||||
const clientX = e.clientX;
|
||||
const clientY = e.clientY;
|
||||
longPressTimer.current = window.setTimeout(() => {
|
||||
target.setPointerCapture(pointerId);
|
||||
const rect = target.getBoundingClientRect();
|
||||
setDrag({
|
||||
slot,
|
||||
pointerId,
|
||||
x: clientX,
|
||||
y: clientY,
|
||||
offsetX: clientX - rect.left,
|
||||
offsetY: clientY - rect.top,
|
||||
});
|
||||
}, LONG_PRESS_MS);
|
||||
}
|
||||
|
||||
function handlePointerMove(e: ReactPointerEvent<HTMLButtonElement>) {
|
||||
if (!drag) {
|
||||
if (pressStart.current && longPressTimer.current !== null) {
|
||||
const dx = e.clientX - pressStart.current.x;
|
||||
const dy = e.clientY - pressStart.current.y;
|
||||
if (Math.hypot(dx, dy) > MOVE_CANCEL_PX) cancelPendingPress();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.pointerId !== drag.pointerId) return;
|
||||
// Solange aktiv gezogen wird, das native Scrollen unterdrücken.
|
||||
// WICHTIG (Korrektur): touch-action:"manipulation" reicht dafür NICHT -
|
||||
// das erlaubt dem Browser bereits VOR diesem preventDefault(), die Geste
|
||||
// für natives Scrollen zu beanspruchen (führte dazu, dass beim Ziehen
|
||||
// nach unten das Handy-Benachrichtigungsmenü aufging, siehe Bugreport).
|
||||
// Die Kacheln nutzen daher touch-action:"none" von Anfang an - der
|
||||
// Browser überlässt Touch-Gesten auf ihnen komplett uns/JavaScript.
|
||||
e.preventDefault();
|
||||
setDrag((d) => (d ? { ...d, x: e.clientX, y: e.clientY } : d));
|
||||
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||
const slotEl = el?.closest("[data-slot-id]") as HTMLElement | null;
|
||||
const areaEl = el?.closest("[data-drop-area]") as HTMLElement | null;
|
||||
if (slotEl && slotEl.dataset.slotId && slotEl.dataset.slotId !== drag.slot.id) {
|
||||
setHover({ area: (slotEl.dataset.area as "grid" | "dock") ?? "grid", slotId: slotEl.dataset.slotId });
|
||||
} else if (areaEl?.dataset.dropArea) {
|
||||
setHover({ area: areaEl.dataset.dropArea as "grid" | "dock", slotId: "__end__" });
|
||||
} else {
|
||||
setHover(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerUp(e: ReactPointerEvent<HTMLButtonElement>) {
|
||||
cancelPendingPress();
|
||||
if (!drag || e.pointerId !== drag.pointerId) return;
|
||||
const next = applyDragResult(layout, drag.slot, hover, maxDockSlots);
|
||||
if (next) {
|
||||
saveLayout(next);
|
||||
suppressNextClick.current = true;
|
||||
}
|
||||
setDrag(null);
|
||||
setHover(null);
|
||||
}
|
||||
|
||||
function handlePointerCancel() {
|
||||
cancelPendingPress();
|
||||
setDrag(null);
|
||||
setHover(null);
|
||||
}
|
||||
|
||||
function handleTileClick(action: () => void) {
|
||||
if (suppressNextClick.current) {
|
||||
suppressNextClick.current = false;
|
||||
return;
|
||||
}
|
||||
if (drag) return;
|
||||
action();
|
||||
}
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
// WICHTIG (Bugfix): vorher stand hier ein eigener, simpler
|
||||
// .includes()-Filter, der zusätzlich Geräte komplett ausgeschlossen hat
|
||||
// - dadurch fand die Suche im Android-Design weniger als im normalen
|
||||
// Design (siehe Bugreport). Jetzt exakt dieselbe Rangier-Funktion wie im
|
||||
// klassischen Design (siehe HomePage.tsx), inklusive Geräten - ein Klick
|
||||
// auf ein Gerät bleibt trotzdem ohne Wirkung (kein eigenes Ziel, siehe
|
||||
// onOpen/openItem in HomePage.tsx), aber es taucht wie gewohnt als
|
||||
// Treffer auf.
|
||||
return rankSearchResults(allSearchItems, query);
|
||||
}, [query, allSearchItems]);
|
||||
|
||||
const closeSearch = () => {
|
||||
setSearchOpen(false);
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
const openFolder =
|
||||
openFolderId && openFolderId !== "__recent__" && openFolderId !== "__readlater__"
|
||||
? ([...layout.grid, ...layout.dock].find((s) => s.id === openFolderId && s.kind === "folder") as
|
||||
| Extract<HomeSlot, { kind: "folder" }>
|
||||
| undefined)
|
||||
: null;
|
||||
|
||||
function removeFromFolder(folder: Extract<HomeSlot, { kind: "folder" }>, ref: ItemRef) {
|
||||
const items = folder.items.filter((i) => !(i.id === ref.id && i.type === ref.type));
|
||||
const patch = (slots: HomeSlot[]) =>
|
||||
slots
|
||||
.map((s) => (s.id === folder.id ? (items.length > 0 ? { ...s, items } : null) : s))
|
||||
.filter((s): s is HomeSlot => s !== null);
|
||||
saveLayout({ grid: patch(layout.grid), dock: patch(layout.dock) });
|
||||
if (items.length === 0) setOpenFolderId(null);
|
||||
}
|
||||
|
||||
function renameFolder(folder: Extract<HomeSlot, { kind: "folder" }>, name: string) {
|
||||
const patch = (slots: HomeSlot[]) => slots.map((s) => (s.id === folder.id ? { ...s, name } : s));
|
||||
saveLayout({ grid: patch(layout.grid), dock: patch(layout.dock) });
|
||||
}
|
||||
|
||||
function renderTile(slot: HomeSlot, area: "grid" | "dock", size: "normal" | "small" = "normal") {
|
||||
const isDragging = drag?.slot.id === slot.id;
|
||||
const isHoverTarget = hover?.slotId === slot.id;
|
||||
|
||||
if (slot.kind === "folder") {
|
||||
const icons = slot.items
|
||||
.map((ref) => resolveRef(ref))
|
||||
.filter((i): i is ResolvedItem => i !== null)
|
||||
.slice(0, 4)
|
||||
.map((i) => ({ favicon: i.favicon, label: i.displayName }));
|
||||
return (
|
||||
<button
|
||||
key={slot.id}
|
||||
type="button"
|
||||
data-slot-id={slot.id}
|
||||
data-area={area}
|
||||
onPointerDown={(e) => handlePointerDown(e, slot)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onClick={() => handleTileClick(() => setOpenFolderId(slot.id))}
|
||||
style={{ touchAction: "none", WebkitTouchCallout: "none", WebkitUserSelect: "none", userSelect: "none" }}
|
||||
className={`flex flex-col items-center gap-1.5 ${isHoverTarget ? "scale-110" : ""} transition-transform`}
|
||||
>
|
||||
<FolderTile icons={icons} dimmed={isDragging} />
|
||||
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
|
||||
{slot.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const item = resolveRef(slot.ref);
|
||||
if (!item) return null;
|
||||
return (
|
||||
<button
|
||||
key={slot.id}
|
||||
type="button"
|
||||
data-slot-id={slot.id}
|
||||
data-area={area}
|
||||
onPointerDown={(e) => handlePointerDown(e, slot)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onClick={() => handleTileClick(() => onOpen({ url: item.url, id: item.id, kind: item.type }))}
|
||||
style={{ touchAction: "none", WebkitTouchCallout: "none", WebkitUserSelect: "none", userSelect: "none" }}
|
||||
className={`flex flex-col items-center gap-1.5 ${isHoverTarget ? "scale-110" : ""} transition-transform`}
|
||||
>
|
||||
<TileIcon favicon={item.favicon} label={item.displayName} dimmed={isDragging} />
|
||||
{size === "normal" ? (
|
||||
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
|
||||
{item.displayName}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-dvh overflow-hidden bg-neutral-100 dark:bg-neutral-950">
|
||||
{/* Wallpaper */}
|
||||
<div className="pointer-events-none fixed inset-0 overflow-hidden">
|
||||
{palette.map((color, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute h-[60vmax] w-[60vmax] rounded-full opacity-25 motion-safe:animate-drift dark:opacity-30"
|
||||
style={{
|
||||
background: color,
|
||||
filter: "blur(60px)",
|
||||
top: BLOB_POSITIONS[i % BLOB_POSITIONS.length].top,
|
||||
left: BLOB_POSITIONS[i % BLOB_POSITIONS.length].left,
|
||||
animationDelay: `${i * 4}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className="absolute inset-0 bg-white/50 dark:bg-black/55" />
|
||||
</div>
|
||||
|
||||
{/* Suchleiste: fest oben */}
|
||||
<div className="sticky top-0 z-20 flex items-center gap-2 px-4 pb-3 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchOpen(true);
|
||||
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||
}}
|
||||
className="flex flex-1 items-center gap-3 rounded-full border border-white/40 bg-white/70
|
||||
px-4 py-3 text-left shadow-lg backdrop-blur-xl dark:border-white/10 dark:bg-neutral-900/70"
|
||||
>
|
||||
<FontAwesomeIcon icon={faMagnifyingGlass} className="text-black/40 dark:text-white/40" />
|
||||
<span className="text-sm text-black/40 dark:text-white/40">Dienst oder Lesezeichen suchen …</span>
|
||||
</button>
|
||||
<Link
|
||||
to="/admin"
|
||||
aria-label="Adminbereich öffnen"
|
||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border
|
||||
border-white/40 bg-white/70 text-black/60 shadow-lg backdrop-blur-xl
|
||||
dark:border-white/10 dark:bg-neutral-900/70 dark:text-white/60"
|
||||
>
|
||||
<FontAwesomeIcon icon={faGear} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Haupt-Raster */}
|
||||
<div data-drop-area="grid" className="min-h-[40vh] px-4 pb-32 pt-2">
|
||||
{isReady ? (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-x-3 gap-y-5">
|
||||
{recentVisits.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenFolderId("__recent__")}
|
||||
className="flex flex-col items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
className="flex h-14 w-14 items-center justify-center rounded-[20px] bg-white/60 text-black/50
|
||||
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm
|
||||
dark:bg-white/10 dark:text-white/50 dark:ring-white/10"
|
||||
>
|
||||
<FontAwesomeIcon icon={faClockRotateLeft} />
|
||||
</span>
|
||||
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
|
||||
Zuletzt besucht
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={() => setOpenFolderId("__readlater__")} className="flex flex-col items-center gap-1.5">
|
||||
<span
|
||||
className="flex h-14 w-14 items-center justify-center rounded-[20px] bg-white/60 text-black/50
|
||||
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm
|
||||
dark:bg-white/10 dark:text-white/50 dark:ring-white/10"
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
</span>
|
||||
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
|
||||
Später lesen
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{layout.grid.map((slot) => renderTile(slot, "grid"))}
|
||||
</div>
|
||||
|
||||
{layout.grid.length === 0 ? (
|
||||
<p className="mt-8 text-center text-sm text-black/40 dark:text-white/40">
|
||||
Markiere Dienste/Lesezeichen im Adminbereich als Favorit – sie erscheinen dann hier.
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
// Platzhalter statt des Rasters, solange noch nicht ALLE nötigen
|
||||
// Daten da sind (siehe isReady-Kommentar an der Prop weiter oben) -
|
||||
// verhindert, dass z. B. "Zuletzt besucht" erst nachträglich
|
||||
// auftaucht und alle Icons danach verschiebt (siehe Bugreport).
|
||||
<div className="grid grid-cols-4 gap-x-3 gap-y-5" aria-hidden="true">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col items-center gap-1.5">
|
||||
<div className="h-14 w-14 animate-pulse rounded-[20px] bg-black/10 dark:bg-white/10" />
|
||||
<div className="h-2.5 w-10 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dock */}
|
||||
<div
|
||||
data-drop-area="dock"
|
||||
className="fixed inset-x-0 bottom-0 z-20 flex justify-center px-4 pb-[calc(env(safe-area-inset-bottom)+0.75rem)]"
|
||||
>
|
||||
<div
|
||||
className="flex min-h-[4.5rem] min-w-[5rem] items-center gap-3 rounded-[28px] border border-white/40
|
||||
bg-white/70 px-4 py-3 shadow-xl backdrop-blur-xl dark:border-white/10 dark:bg-neutral-900/70"
|
||||
>
|
||||
{!isReady ? (
|
||||
<div className="h-9 w-9 animate-pulse rounded-xl bg-black/10 dark:bg-white/10" aria-hidden="true" />
|
||||
) : layout.dock.length === 0 ? (
|
||||
<span className="px-2 text-xs text-black/30 dark:text-white/30">Icon hierher ziehen zum Anheften</span>
|
||||
) : (
|
||||
layout.dock.map((slot) => renderTile(slot, "dock", "small"))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verbindungsstatus */}
|
||||
<div
|
||||
title={isOnline ? "Backend verbunden" : "Backend nicht erreichbar"}
|
||||
className={`fixed left-4 top-[calc(env(safe-area-inset-top)+0.1rem)] z-10 h-2 w-2 rounded-full ${
|
||||
isOnline ? "bg-emerald-500" : "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Schwebendes Icon während des Ziehens */}
|
||||
{drag ? (
|
||||
<div
|
||||
className="pointer-events-none fixed z-50 opacity-90"
|
||||
style={{ left: drag.x - drag.offsetX, top: drag.y - drag.offsetY }}
|
||||
>
|
||||
{drag.slot.kind === "folder" ? (
|
||||
<FolderTile
|
||||
icons={drag.slot.items
|
||||
.map((r) => resolveRef(r))
|
||||
.filter((i): i is ResolvedItem => i !== null)
|
||||
.slice(0, 4)
|
||||
.map((i) => ({ favicon: i.favicon, label: i.displayName }))}
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
const item = resolveRef(drag.slot.ref);
|
||||
return item ? <TileIcon favicon={item.favicon} label={item.displayName} /> : null;
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Ordner-Sheet: Zuletzt besucht / Später lesen / eigener Ordner */}
|
||||
{openFolderId ? (
|
||||
<div
|
||||
className="fixed inset-0 z-30 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center"
|
||||
onClick={() => {
|
||||
setOpenFolderId(null);
|
||||
setRenamingFolder(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="max-h-[75vh] w-full max-w-md overflow-y-auto rounded-t-3xl border border-white/40
|
||||
bg-white/90 p-5 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-neutral-900/90
|
||||
sm:rounded-3xl"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
{openFolder && renamingFolder ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={folderNameDraft}
|
||||
onChange={(e) => setFolderNameDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
setRenamingFolder(false);
|
||||
if (folderNameDraft.trim()) renameFolder(openFolder, folderNameDraft.trim());
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
}}
|
||||
className="flex-1 rounded-lg border border-black/10 bg-white px-2 py-1 text-base font-semibold
|
||||
text-black outline-none dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||||
/>
|
||||
) : (
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-black dark:text-white">
|
||||
{openFolderId === "__recent__" ? "Zuletzt besucht" : openFolderId === "__readlater__" ? "Später lesen" : openFolder?.name}
|
||||
{openFolder ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFolderNameDraft(openFolder.name);
|
||||
setRenamingFolder(true);
|
||||
}}
|
||||
aria-label="Ordner umbenennen"
|
||||
className="text-black/30 hover:text-black/60 dark:text-white/30 dark:hover:text-white/60"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPen} className="text-xs" />
|
||||
</button>
|
||||
) : null}
|
||||
</h2>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpenFolderId(null);
|
||||
setRenamingFolder(false);
|
||||
}}
|
||||
aria-label="Schließen"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-black/50
|
||||
hover:bg-black/5 dark:text-white/50 dark:hover:bg-white/10"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{openFolderId === "__readlater__" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!readLaterUrl.trim()) return;
|
||||
onAddReadLater(readLaterUrl.trim());
|
||||
setReadLaterUrl("");
|
||||
}}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<input
|
||||
value={readLaterUrl}
|
||||
onChange={(e) => setReadLaterUrl(e.target.value)}
|
||||
placeholder="Link einfügen …"
|
||||
className="flex-1 rounded-xl border border-black/10 bg-white/70 px-3 py-2 text-sm text-black
|
||||
outline-none placeholder:text-black/30 focus:border-indigo-400/60 dark:border-white/10
|
||||
dark:bg-white/5 dark:text-white dark:placeholder:text-white/30"
|
||||
/>
|
||||
<Button type="submit" variant="secondary" size="sm">
|
||||
Merken
|
||||
</Button>
|
||||
</form>
|
||||
{readLaterItems.length > 0 ? (
|
||||
<div className="flex flex-col divide-y divide-black/5 dark:divide-white/10">
|
||||
{[...readLaterItems]
|
||||
.sort((a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime())
|
||||
.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onOpen({ url: item.url, id: item.id, kind: "bookmark" })}
|
||||
className="flex items-center gap-3 py-2.5 text-left"
|
||||
>
|
||||
<TileIcon favicon={item.favicon} label={item.displayName} size="small" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm text-black dark:text-white">{item.displayName}</span>
|
||||
<span className="block text-[11px] text-black/40 dark:text-white/40">
|
||||
{formatSavedAt(item.savedAt)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-black/40 dark:text-white/40">Noch nichts gemerkt.</p>
|
||||
)}
|
||||
</div>
|
||||
) : openFolderId === "__recent__" ? (
|
||||
<div className="grid grid-cols-4 gap-x-3 gap-y-5">
|
||||
{recentVisits.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onOpen({ url: item.url, id: item.id, kind: item.kind ?? "service" })}
|
||||
className="flex flex-col items-center gap-1.5"
|
||||
>
|
||||
<TileIcon favicon={item.favicon} label={item.displayName} />
|
||||
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
|
||||
{item.displayName}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : openFolder ? (
|
||||
<div className="grid grid-cols-4 gap-x-3 gap-y-5">
|
||||
{openFolder.items.map((ref) => {
|
||||
const item = resolveRef(ref);
|
||||
if (!item) return null;
|
||||
return (
|
||||
<div key={`${ref.type}:${ref.id}`} className="relative flex flex-col items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFromFolder(openFolder, ref)}
|
||||
aria-label={`${item.displayName} aus Ordner entfernen`}
|
||||
className="absolute -right-1 -top-1 z-10 flex h-5 w-5 items-center justify-center
|
||||
rounded-full bg-black/60 text-[10px] text-white shadow hover:bg-black/80"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen({ url: item.url, id: item.id, kind: item.type })}
|
||||
className="flex flex-col items-center gap-1.5"
|
||||
>
|
||||
<TileIcon favicon={item.favicon} label={item.displayName} />
|
||||
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
|
||||
{item.displayName}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Such-Overlay */}
|
||||
{searchOpen ? (
|
||||
<div className="fixed inset-0 z-40 flex flex-col bg-white/95 backdrop-blur-xl dark:bg-neutral-950/95">
|
||||
<div className="flex items-center gap-2 px-4 pb-3 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
|
||||
<div className="flex flex-1 items-center gap-3 rounded-full border border-black/10 bg-white px-4 py-3 shadow-sm dark:border-white/10 dark:bg-neutral-900">
|
||||
<FontAwesomeIcon icon={faMagnifyingGlass} className="text-black/40 dark:text-white/40" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
placeholder="Dienst oder Lesezeichen suchen …"
|
||||
className="flex-1 bg-transparent text-sm text-black outline-none placeholder:text-black/30
|
||||
dark:text-white dark:placeholder:text-white/30"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeSearch}
|
||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-black/60
|
||||
hover:bg-black/5 dark:text-white/60 dark:hover:bg-white/10"
|
||||
aria-label="Suche schließen"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6">
|
||||
<ResultsList
|
||||
results={searchResults}
|
||||
selectedIndex={selectedIndex}
|
||||
categoryColors={categoryColors}
|
||||
onHover={setSelectedIndex}
|
||||
onOpen={(item) => {
|
||||
onOpen(item as OpenTarget);
|
||||
closeSearch();
|
||||
}}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
emptyLabel={query.trim().length === 0 ? "Tippe, um zu suchen." : "Keine Treffer."}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
apps/frontend/src/components/ScrollToTopButton.tsx
Normal file
37
apps/frontend/src/components/ScrollToTopButton.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faArrowUp } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
/**
|
||||
* Schwebender Button unten rechts, der zum Seitenanfang zurückscrollt.
|
||||
* Erscheint erst, sobald tatsächlich runtergescrollt wurde (kein Sinn, ihn
|
||||
* direkt am Anfang der Seite schon anzuzeigen).
|
||||
*/
|
||||
export function ScrollToTopButton() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll() {
|
||||
setVisible(window.scrollY > 300);
|
||||
}
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||
title="Nach oben scrollen"
|
||||
aria-label="Nach oben scrollen"
|
||||
className="fixed bottom-20 right-5 z-30 flex h-11 w-11 items-center justify-center rounded-full
|
||||
border border-black/10 bg-white/90 text-lg text-black/70 shadow-lg backdrop-blur-md
|
||||
transition-opacity hover:bg-white dark:border-white/10 dark:bg-neutral-900/90
|
||||
dark:text-white/70 dark:hover:bg-neutral-900"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowUp} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
17
apps/frontend/src/hooks/useBookmarks.ts
Normal file
17
apps/frontend/src/hooks/useBookmarks.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Bookmark } from "@launchpad/shared";
|
||||
|
||||
async function fetchBookmarks(): Promise<Bookmark[]> {
|
||||
const res = await fetch("/api/bookmarks");
|
||||
if (!res.ok) {
|
||||
throw new Error(`Lesezeichen konnten nicht geladen werden (HTTP ${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useBookmarks() {
|
||||
return useQuery({
|
||||
queryKey: ["bookmarks"],
|
||||
queryFn: fetchBookmarks,
|
||||
});
|
||||
}
|
||||
25
apps/frontend/src/hooks/useDetectedApis.ts
Normal file
25
apps/frontend/src/hooks/useDetectedApis.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export interface DetectedApiEntry {
|
||||
id: string;
|
||||
serviceId: string;
|
||||
path: string;
|
||||
type: string;
|
||||
status: number;
|
||||
detectedAt: string;
|
||||
}
|
||||
|
||||
async function fetchDetectedApis(): Promise<DetectedApiEntry[]> {
|
||||
const res = await fetch("/api/detected-apis");
|
||||
if (!res.ok) {
|
||||
throw new Error(`APIs konnten nicht geladen werden (HTTP ${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useDetectedApis() {
|
||||
return useQuery({
|
||||
queryKey: ["detected-apis"],
|
||||
queryFn: fetchDetectedApis,
|
||||
});
|
||||
}
|
||||
110
apps/frontend/src/hooks/useHomeLayout.ts
Normal file
110
apps/frontend/src/hooks/useHomeLayout.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSettings } from "./useSettings.js";
|
||||
|
||||
export interface ItemRef {
|
||||
id: string;
|
||||
type: "service" | "bookmark";
|
||||
}
|
||||
|
||||
export type HomeSlot =
|
||||
| { id: string; kind: "item"; ref: ItemRef }
|
||||
| { id: string; kind: "folder"; name: string; items: ItemRef[] };
|
||||
|
||||
export interface HomeLayout {
|
||||
grid: HomeSlot[];
|
||||
dock: HomeSlot[];
|
||||
}
|
||||
|
||||
const MAX_DOCK_SLOTS = 5;
|
||||
|
||||
function refKey(ref: ItemRef): string {
|
||||
return `${ref.type}:${ref.id}`;
|
||||
}
|
||||
|
||||
function slotId(): string {
|
||||
return `slot-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gleicht ein gespeichertes Layout mit der AKTUELLEN Favoriten-Liste ab:
|
||||
* - Favoriten, die noch in keinem Slot vorkommen (neu als Favorit markiert,
|
||||
* oder allererster Aufruf ohne gespeichertes Layout), werden hinten an den
|
||||
* Hauptbereich angehängt.
|
||||
* - Einträge, die nicht mehr in der Favoriten-Liste sind (Favorit entfernt
|
||||
* oder Dienst/Lesezeichen gelöscht), werden aus Slots UND aus Ordnern
|
||||
* entfernt. Dadurch leer gewordene Ordner werden mit entfernt.
|
||||
* Rein bereinigend, verändert nie die vom Nutzer gewählte Reihenfolge
|
||||
* bestehender Einträge.
|
||||
*/
|
||||
function reconcile(layout: HomeLayout, favorites: ItemRef[]): HomeLayout {
|
||||
const favoriteKeys = new Set(favorites.map(refKey));
|
||||
const placedKeys = new Set<string>();
|
||||
|
||||
const cleanSlots = (slots: HomeSlot[]): HomeSlot[] =>
|
||||
slots
|
||||
.map((slot): HomeSlot | null => {
|
||||
if (slot.kind === "item") {
|
||||
if (!favoriteKeys.has(refKey(slot.ref))) return null;
|
||||
placedKeys.add(refKey(slot.ref));
|
||||
return slot;
|
||||
}
|
||||
const items = slot.items.filter((ref) => {
|
||||
if (!favoriteKeys.has(refKey(ref))) return false;
|
||||
placedKeys.add(refKey(ref));
|
||||
return true;
|
||||
});
|
||||
if (items.length === 0) return null;
|
||||
return { ...slot, items };
|
||||
})
|
||||
.filter((s): s is HomeSlot => s !== null);
|
||||
|
||||
const grid = cleanSlots(layout.grid);
|
||||
const dock = cleanSlots(layout.dock);
|
||||
|
||||
const missing = favorites.filter((ref) => !placedKeys.has(refKey(ref)));
|
||||
const newSlots: HomeSlot[] = missing.map((ref) => ({ id: slotId(), kind: "item", ref }));
|
||||
|
||||
return { grid: [...grid, ...newSlots], dock };
|
||||
}
|
||||
|
||||
export function useHomeLayout(favorites: ItemRef[]) {
|
||||
const { data: settings } = useSettings();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const layout = useMemo<HomeLayout>(() => {
|
||||
let parsed: HomeLayout = { grid: [], dock: [] };
|
||||
if (settings?.mobileHomeLayout) {
|
||||
try {
|
||||
const raw = JSON.parse(settings.mobileHomeLayout) as Partial<HomeLayout>;
|
||||
parsed = { grid: raw.grid ?? [], dock: raw.dock ?? [] };
|
||||
} catch {
|
||||
parsed = { grid: [], dock: [] };
|
||||
}
|
||||
}
|
||||
return reconcile(parsed, favorites);
|
||||
// favorites als JSON-String in der dep-Liste, damit sich das Memo nur bei
|
||||
// TATSÄCHLICH geänderten Favoriten neu berechnet, nicht bei jedem Render
|
||||
// (favorites wird von außen als neues Array-Objekt reincegeben).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings?.mobileHomeLayout, JSON.stringify(favorites)]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (next: HomeLayout) => {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mobileHomeLayout: next }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Layout konnte nicht gespeichert werden (HTTP ${res.status})`);
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
|
||||
});
|
||||
|
||||
return {
|
||||
layout,
|
||||
saveLayout: (next: HomeLayout) => saveMutation.mutate(next),
|
||||
maxDockSlots: MAX_DOCK_SLOTS,
|
||||
};
|
||||
}
|
||||
28
apps/frontend/src/hooks/useIsMobile.ts
Normal file
28
apps/frontend/src/hooks/useIsMobile.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT_QUERY = "(max-width: 640px)";
|
||||
|
||||
/**
|
||||
* Erkennt eine mobile Bildschirmbreite (Tailwinds "sm"-Breakpoint, 640px) -
|
||||
* genutzt, um das neue Android-Startbildschirm-Design (siehe
|
||||
* MobileAndroidHome.tsx) NUR auf schmalen Bildschirmen zu zeigen, auch wenn
|
||||
* es in den Einstellungen aktiviert ist. Auf Desktop-Breite bleibt immer das
|
||||
* bisherige Design, unabhängig von der Einstellung - das neue Design ist
|
||||
* ausdrücklich als mobile Alternative gedacht, nicht als Ersatz für die
|
||||
* Desktop-Ansicht.
|
||||
*/
|
||||
export function useIsMobile(): boolean {
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => typeof window !== "undefined" && window.matchMedia(MOBILE_BREAKPOINT_QUERY).matches
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(MOBILE_BREAKPOINT_QUERY);
|
||||
const onChange = () => setIsMobile(mql.matches);
|
||||
onChange();
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
40
apps/frontend/src/hooks/usePersistedState.ts
Normal file
40
apps/frontend/src/hooks/usePersistedState.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
/**
|
||||
* Wie useState, aber der Wert überlebt zusätzlich das Verlassen und erneute
|
||||
* Betreten einer Seite (z. B. Scanner -> Geräte -> zurück zu Scanner), ohne
|
||||
* dass ein laufender Scan erneut ausgeführt werden muss. Nutzt sessionStorage
|
||||
* (pro Browser-Tab, weg nach Schließen des Tabs - bewusst kein dauerhafter
|
||||
* Speicher, da es sich um vorübergehende "wartet auf Bestätigung"-Zustände
|
||||
* handelt, keine echten Nutzdaten).
|
||||
*/
|
||||
export function usePersistedState<T>(key: string, initial: T): [T, (value: T | ((prev: T) => T)) => void] {
|
||||
const storageKey = `launchpad:${key}`;
|
||||
|
||||
const [state, setState] = useState<T>(() => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey);
|
||||
return raw !== null ? (JSON.parse(raw) as T) : initial;
|
||||
} catch {
|
||||
return initial;
|
||||
}
|
||||
});
|
||||
|
||||
const setPersisted = useCallback(
|
||||
(value: T | ((prev: T) => T)) => {
|
||||
setState((prev) => {
|
||||
const next = typeof value === "function" ? (value as (p: T) => T)(prev) : value;
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(next));
|
||||
} catch {
|
||||
// z. B. Speicher voll oder Privatmodus - dann bleibt der Zustand
|
||||
// wenigstens für den Rest dieser Sitzung im Memory erhalten.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[storageKey]
|
||||
);
|
||||
|
||||
return [state, setPersisted];
|
||||
}
|
||||
17
apps/frontend/src/hooks/usePlugins.ts
Normal file
17
apps/frontend/src/hooks/usePlugins.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { PluginInfo } from "@launchpad/shared";
|
||||
|
||||
async function fetchPlugins(): Promise<PluginInfo[]> {
|
||||
const res = await fetch("/api/plugins");
|
||||
if (!res.ok) {
|
||||
throw new Error(`Plugins konnten nicht geladen werden (HTTP ${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function usePlugins() {
|
||||
return useQuery({
|
||||
queryKey: ["plugins"],
|
||||
queryFn: fetchPlugins,
|
||||
});
|
||||
}
|
||||
24
apps/frontend/src/hooks/useReadLater.ts
Normal file
24
apps/frontend/src/hooks/useReadLater.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export interface ReadLaterItem {
|
||||
id: string;
|
||||
url: string;
|
||||
displayName: string;
|
||||
favicon: string | null;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
async function fetchReadLater(): Promise<ReadLaterItem[]> {
|
||||
const res = await fetch("/api/read-later");
|
||||
if (!res.ok) {
|
||||
throw new Error(`Später-lesen-Liste konnte nicht geladen werden (HTTP ${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useReadLater() {
|
||||
return useQuery({
|
||||
queryKey: ["read-later"],
|
||||
queryFn: fetchReadLater,
|
||||
});
|
||||
}
|
||||
29
apps/frontend/src/hooks/useRecentVisits.ts
Normal file
29
apps/frontend/src/hooks/useRecentVisits.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Bookmark, Service } from "@launchpad/shared";
|
||||
|
||||
export type RecentVisitItem =
|
||||
| (Service & { kind: "service" })
|
||||
| (Bookmark & { kind: "bookmark" });
|
||||
|
||||
async function fetchRecentVisits(): Promise<RecentVisitItem[]> {
|
||||
const res = await fetch("/api/recent-visits");
|
||||
if (!res.ok) {
|
||||
throw new Error(`Zuletzt besucht konnte nicht geladen werden (HTTP ${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useRecentVisits() {
|
||||
return useQuery({
|
||||
queryKey: ["recent-visits"],
|
||||
queryFn: fetchRecentVisits,
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordVisit(itemType: "service" | "bookmark", itemId: string): Promise<void> {
|
||||
await fetch("/api/recent-visits", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ itemType, itemId }),
|
||||
});
|
||||
}
|
||||
69
apps/frontend/src/hooks/useScanActivity.ts
Normal file
69
apps/frontend/src/hooks/useScanActivity.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface ScanActivity {
|
||||
running: boolean;
|
||||
pendingCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geräteübergreifender Hinweis, ob gerade ein Scan läuft oder es
|
||||
* unbestätigte Scan-Ergebnisse gibt - für ein Abzeichen am
|
||||
* "Scanner"-Navigationspunkt, sichtbar von jeder Admin-Seite UND jedem
|
||||
* Gerät/Browser aus, da der Zustand jetzt vom BACKEND abgefragt wird
|
||||
* (siehe apps/backend/src/scanJobs.ts), nicht mehr aus dem lokalen
|
||||
* sessionStorage des jeweiligen Browsers gelesen wird - Ersteres kann
|
||||
* naturgemäß nicht geräteübergreifend sein.
|
||||
*/
|
||||
export function useScanActivity(): ScanActivity {
|
||||
const [activity, setActivity] = useState<ScanActivity>({ running: false, pendingCount: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const [devicesRes, apisRes, fritzboxRes, activeDevicesRes] = await Promise.all([
|
||||
fetch("/api/scan/devices/all/status"),
|
||||
fetch("/api/scan/apis/status"),
|
||||
fetch("/api/scan/fritzbox/status"),
|
||||
fetch("/api/scan/devices/active"),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
const devicesStatus = devicesRes.ok ? await devicesRes.json() : null;
|
||||
const apisStatus = apisRes.ok ? await apisRes.json() : null;
|
||||
const fritzboxStatus = fritzboxRes.ok ? await fritzboxRes.json() : null;
|
||||
// Manuelle Einzelgeräte-Scans (Button auf der Geräteseite) laufen
|
||||
// NICHT über den Sammel-Scan-Job, sondern über eine einfache,
|
||||
// blockierende Anfrage - ohne diesen zusätzlichen Abruf wäre so ein
|
||||
// Scan für jeden anderen Browser/jedes andere Gerät unsichtbar
|
||||
// gewesen (siehe Bugreport).
|
||||
const activeDevices = activeDevicesRes.ok ? await activeDevicesRes.json() : null;
|
||||
|
||||
const running =
|
||||
!!devicesStatus?.running ||
|
||||
!!apisStatus?.running ||
|
||||
!!fritzboxStatus?.running ||
|
||||
(activeDevices?.deviceIds?.length ?? 0) > 0;
|
||||
|
||||
const lastResult = devicesStatus?.lastResult;
|
||||
const pendingCount = lastResult
|
||||
? (lastResult.nameChanges?.length ?? 0) + (lastResult.staleServices?.length ?? 0)
|
||||
: 0;
|
||||
|
||||
setActivity({ running, pendingCount });
|
||||
} catch {
|
||||
// Netzwerk-Hänger beim Poll ignorieren, nächster Versuch folgt.
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
const interval = setInterval(poll, 2000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return activity;
|
||||
}
|
||||
27
apps/frontend/src/hooks/useSettings.ts
Normal file
27
apps/frontend/src/hooks/useSettings.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export interface AppSettings {
|
||||
recentVisitsLimit: number;
|
||||
readLaterLimit: number;
|
||||
staleDeviceThresholdDays: number;
|
||||
liveStatusEnabled: boolean;
|
||||
liveStatusIntervalMinutes: number;
|
||||
liveStatusLastRunAt: string | null;
|
||||
mobileHomeStyle: "classic" | "android";
|
||||
mobileHomeLayout: string | null;
|
||||
}
|
||||
|
||||
async function fetchSettings(): Promise<AppSettings> {
|
||||
const res = await fetch("/api/settings");
|
||||
if (!res.ok) {
|
||||
throw new Error(`Einstellungen konnten nicht geladen werden (HTTP ${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useSettings() {
|
||||
return useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: fetchSettings,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
54
apps/frontend/src/routes/SearchRedirectPage.tsx
Normal file
54
apps/frontend/src/routes/SearchRedirectPage.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { rankSearchResults, type SearchResult } from "@launchpad/shared";
|
||||
import { useServices } from "../hooks/useServices.js";
|
||||
import { useBookmarks } from "../hooks/useBookmarks.js";
|
||||
|
||||
/**
|
||||
* Ziel des OpenSearch-Eintrags (siehe /opensearch.xml): Browser mit
|
||||
* zugewiesenem Kürzel (z. B. "lp") rufen "/search?q=<Suchbegriff>" auf.
|
||||
* Gibt es einen eindeutigen Treffer, wird SOFORT dorthin weitergeleitet
|
||||
* (window.location.replace, kein Eintrag in der Chronik). Gibt es keinen
|
||||
* eindeutigen Treffer, landet man stattdessen auf der Startseite mit dem
|
||||
* Suchbegriff bereits eingetragen - dort lässt sich die volle Trefferliste
|
||||
* durchsehen.
|
||||
*
|
||||
* Bewusst rein client-seitig (kein eigener Backend-Redirect-Endpunkt nötig):
|
||||
* nutzt exakt dieselben Daten/Rangfolge wie die normale Suche auf der
|
||||
* Startseite (siehe rankSearchResults in @launchpad/shared).
|
||||
*/
|
||||
export function SearchRedirectPage() {
|
||||
const { data: services, isLoading: servicesLoading } = useServices();
|
||||
const { data: bookmarks, isLoading: bookmarksLoading } = useBookmarks();
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const query = new URLSearchParams(window.location.search).get("q") ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (servicesLoading || bookmarksLoading) return;
|
||||
if (!query.trim()) {
|
||||
window.location.replace("/");
|
||||
return;
|
||||
}
|
||||
|
||||
const items: SearchResult[] = [
|
||||
...(services ?? []).filter((s) => s.visible).map((s) => ({ ...s, kind: "service" as const })),
|
||||
...(bookmarks ?? []).map((b) => ({ ...b, kind: "bookmark" as const })),
|
||||
];
|
||||
const results = rankSearchResults(items, query);
|
||||
|
||||
if (results.length > 0 && results[0].kind !== "device") {
|
||||
window.location.replace(results[0].url);
|
||||
} else {
|
||||
setFailed(true);
|
||||
window.location.replace(`/?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
}, [servicesLoading, bookmarksLoading, services, bookmarks, query]);
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh flex-col items-center justify-center gap-2 bg-white text-black dark:bg-black dark:text-white">
|
||||
<p className="text-sm text-black/50 dark:text-white/50">
|
||||
{failed ? "Kein eindeutiger Treffer, leite zur Suche weiter …" : `Suche „${query}“ …`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
78
apps/frontend/src/routes/admin/ApisPage.tsx
Normal file
78
apps/frontend/src/routes/admin/ApisPage.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useServices } from "../../hooks/useServices.js";
|
||||
import { useDetectedApis, type DetectedApiEntry } from "../../hooks/useDetectedApis.js";
|
||||
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export function ApisPage() {
|
||||
const { data: services } = useServices();
|
||||
const { data: apis, isLoading, isError } = useDetectedApis();
|
||||
|
||||
const serviceById = new Map((services ?? []).map((s) => [s.id, s]));
|
||||
const groups = new Map<string, DetectedApiEntry[]>();
|
||||
for (const entry of apis ?? []) {
|
||||
const list = groups.get(entry.serviceId) ?? [];
|
||||
list.push(entry);
|
||||
groups.set(entry.serviceId, list);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader
|
||||
title="APIs"
|
||||
description='Von einem eigenen, separaten Scan gefundene API-Endpunkte bekannter Dienste (siehe Admin -> Scanner -> "APIs jetzt scannen"). Läuft nie automatisch mit.'
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-black/40 dark:text-white/40">Lade …</p>
|
||||
) : isError ? (
|
||||
<p className="text-sm text-red-500">Konnte nicht geladen werden.</p>
|
||||
) : groups.size === 0 ? (
|
||||
<p className="text-sm text-black/40 dark:text-white/40">
|
||||
Noch keine APIs gefunden. Auf der Scanner-Seite einmal „APIs jetzt scannen" ausführen.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Array.from(groups.entries()).map(([serviceId, entries]) => {
|
||||
const service = serviceById.get(serviceId);
|
||||
return (
|
||||
<div key={serviceId} className="rounded-2xl border border-black/10 p-4 dark:border-white/10">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="font-medium text-black dark:text-white">
|
||||
{service?.displayName ?? "Unbekannter Dienst"}
|
||||
</h3>
|
||||
<span className="text-xs text-black/30 dark:text-white/30">
|
||||
zuletzt geprüft: {formatDate(entries[0].detectedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{entries.map((e) => (
|
||||
<li key={e.id}>
|
||||
<a
|
||||
href={`${service?.url ?? ""}${e.path}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`HTTP ${e.status}`}
|
||||
className="flex items-center gap-1.5 rounded-full bg-black/5 px-2.5 py-1 text-xs
|
||||
text-black/70 hover:bg-black/10 dark:bg-white/10 dark:text-white/70
|
||||
dark:hover:bg-white/20"
|
||||
>
|
||||
{e.type} · {e.path}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
615
apps/frontend/src/routes/admin/BookmarksPage.tsx
Normal file
615
apps/frontend/src/routes/admin/BookmarksPage.tsx
Normal file
@@ -0,0 +1,615 @@
|
||||
import { useMemo, useState, type DragEvent, type FormEvent } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faFloppyDisk, faPen, faTrash, faXmark, faSort, faSortUp, faSortDown, faStar as faStarSolid, faEye, faEyeSlash, faArrowLeft } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faStar } from "@fortawesome/free-regular-svg-icons";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button, Favicon, FaviconPicker } from "@launchpad/ui";
|
||||
import type { Bookmark } from "@launchpad/shared";
|
||||
import { suggestBookmarkCategory } from "@launchpad/shared";
|
||||
import { useBookmarks } from "../../hooks/useBookmarks.js";
|
||||
import { useServices } from "../../hooks/useServices.js";
|
||||
import { useCategories } from "../../hooks/useCategories.js";
|
||||
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||
|
||||
interface BookmarkPatch {
|
||||
url?: string;
|
||||
displayName?: string;
|
||||
description?: string | null;
|
||||
category?: string | null;
|
||||
favorite?: boolean;
|
||||
alias?: string[];
|
||||
order?: number;
|
||||
favicon?: string | null;
|
||||
}
|
||||
|
||||
async function createBookmarkRequest(input: {
|
||||
url: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
}): Promise<Bookmark> {
|
||||
const res = await fetch("/api/bookmarks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? `Lesezeichen konnte nicht angelegt werden (HTTP ${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function patchBookmark(id: string, patch: BookmarkPatch): Promise<Bookmark> {
|
||||
const res = await fetch(`/api/bookmarks/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? `Lesezeichen konnte nicht aktualisiert werden (HTTP ${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function deleteBookmarkRequest(id: string) {
|
||||
const res = await fetch(`/api/bookmarks/${id}`, { method: "DELETE" });
|
||||
if (!res.ok && res.status !== 404) {
|
||||
throw new Error(`Lesezeichen konnte nicht gelöscht werden (HTTP ${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function reorderBookmarksRequest(entries: { id: string; order: number }[]) {
|
||||
const res = await fetch("/api/bookmarks/reorder", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(entries),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Reihenfolge konnte nicht gespeichert werden (HTTP ${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const NEW_CATEGORY_VALUE = "__new__";
|
||||
|
||||
function CategorySelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const { data: categories } = useCategories();
|
||||
const isKnown = !value || categories?.some((c) => c.name === value);
|
||||
const [isNew, setIsNew] = useState(!isKnown);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<select
|
||||
value={isNew ? NEW_CATEGORY_VALUE : value}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === NEW_CATEGORY_VALUE) {
|
||||
setIsNew(true);
|
||||
onChange("");
|
||||
} else {
|
||||
setIsNew(false);
|
||||
onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||||
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||||
>
|
||||
<option value="">– Keine –</option>
|
||||
{categories?.map((c) => (
|
||||
<option key={c.id} value={c.name}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
<option value={NEW_CATEGORY_VALUE}>+ Neue Kategorie …</option>
|
||||
</select>
|
||||
{isNew ? (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Name der neuen Kategorie"
|
||||
className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||||
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddBookmarkForm() {
|
||||
const queryClient = useQueryClient();
|
||||
const [url, setUrl] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createBookmarkRequest({
|
||||
url: url.trim(),
|
||||
category: category.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setUrl("");
|
||||
setCategory("");
|
||||
setDescription("");
|
||||
queryClient.invalidateQueries({ queryKey: ["bookmarks"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!url.trim()) return;
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
// Kategorie-Vorschlag anhand der eingegebenen URL (rein heuristisch anhand
|
||||
// des Hostnamens, siehe suggestBookmarkCategory in @launchpad/shared) - nur
|
||||
// solange der Nutzer noch keine eigene Kategorie gewählt hat.
|
||||
const suggestedCategory = category.trim() === "" ? suggestBookmarkCategory(url.trim()) : null;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mb-6 flex flex-wrap items-end gap-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">URL</label>
|
||||
<input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
className="w-64 rounded-lg border border-black/10 bg-white px-3 py-1.5 text-sm
|
||||
text-black outline-none focus:border-black/30 dark:border-white/10
|
||||
dark:bg-white/5 dark:text-white dark:focus:border-white/30"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
|
||||
Kategorie
|
||||
</label>
|
||||
<CategorySelect value={category} onChange={setCategory} />
|
||||
{suggestedCategory ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCategory(suggestedCategory)}
|
||||
className="mt-1 block text-xs text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
Vorschlag: {suggestedCategory} übernehmen?
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
|
||||
Beschreibung
|
||||
</label>
|
||||
<input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="optional"
|
||||
className="w-48 rounded-lg border border-black/10 bg-white px-3 py-1.5 text-sm
|
||||
text-black outline-none focus:border-black/30 dark:border-white/10
|
||||
dark:bg-white/5 dark:text-white dark:focus:border-white/30"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="primary" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Lade Titel/Favicon …" : "Anlegen"}
|
||||
</Button>
|
||||
{mutation.isError ? (
|
||||
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
|
||||
) : null}
|
||||
<span className="w-full text-xs text-black/40 dark:text-white/40">
|
||||
Titel und Favicon werden automatisch von der Seite geladen, falls verfügbar.
|
||||
</span>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const EDIT_FORM_COLSPAN = 7;
|
||||
|
||||
function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: allBookmarks } = useBookmarks();
|
||||
const { data: allServices } = useServices();
|
||||
const [displayName, setDisplayName] = useState(bookmark.displayName);
|
||||
const [url, setUrl] = useState(bookmark.url);
|
||||
const [category, setCategory] = useState(bookmark.category ?? "");
|
||||
const [description, setDescription] = useState(bookmark.description ?? "");
|
||||
const [alias, setAlias] = useState(bookmark.alias.join(", "));
|
||||
|
||||
const [favicon, setFavicon] = useState<string | null | undefined>(undefined);
|
||||
const [faviconError, setFaviconError] = useState<string | null>(null);
|
||||
const FAVICON_MAX_BYTES = 300 * 1024;
|
||||
|
||||
const existingFavicons = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const b of allBookmarks ?? []) {
|
||||
if (b.favicon && b.id !== bookmark.id && !seen.has(b.favicon)) seen.set(b.favicon, b.displayName);
|
||||
}
|
||||
for (const s of allServices ?? []) {
|
||||
if (s.favicon && !seen.has(s.favicon)) seen.set(s.favicon, s.displayName);
|
||||
}
|
||||
return Array.from(seen.entries());
|
||||
}, [allBookmarks, allServices, bookmark.id]);
|
||||
|
||||
function handleFaviconFile(file: File | undefined) {
|
||||
setFaviconError(null);
|
||||
if (!file) return;
|
||||
if (file.size > FAVICON_MAX_BYTES) {
|
||||
setFaviconError("Datei zu groß (max. 300 KB) – bitte ein kleineres Bild wählen.");
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setFavicon(reader.result as string);
|
||||
reader.onerror = () => setFaviconError("Datei konnte nicht gelesen werden.");
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
const faviconPreview = favicon === undefined ? bookmark.favicon : favicon;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
patchBookmark(bookmark.id, {
|
||||
displayName: displayName.trim(),
|
||||
url: url.trim(),
|
||||
category: category.trim() || null,
|
||||
description: description.trim() || null,
|
||||
alias: alias
|
||||
.split(",")
|
||||
.map((a) => a.trim())
|
||||
.filter(Boolean),
|
||||
...(favicon !== undefined ? { favicon } : {}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["bookmarks"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||
onDone();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<tr className="border-b border-black/5 bg-black/[0.02] last:border-0 dark:border-white/5 dark:bg-white/5">
|
||||
<td colSpan={EDIT_FORM_COLSPAN} className="px-4 py-3">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Favicon</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Favicon src={faviconPreview} fallbackLetter={displayName} size="sm" />
|
||||
<label className="cursor-pointer rounded-lg border border-black/10 px-2 py-1 text-xs
|
||||
text-black/70 hover:bg-black/5 dark:border-white/10 dark:text-white/70 dark:hover:bg-white/10">
|
||||
Hochladen
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleFaviconFile(e.target.files?.[0])}
|
||||
/>
|
||||
</label>
|
||||
<FaviconPicker existingFavicons={existingFavicons} onSelect={(url) => setFavicon(url)} />
|
||||
{faviconPreview ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFavicon(null)}
|
||||
className="text-xs text-black/40 underline dark:text-white/40"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{faviconError ? <p className="mt-1 text-xs text-red-500">{faviconError}</p> : null}
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label>
|
||||
<input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||||
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">URL</label>
|
||||
<input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="w-56 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||||
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Kategorie</label>
|
||||
<CategorySelect value={category} onChange={setCategory} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">
|
||||
Beschreibung
|
||||
</label>
|
||||
<input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-48 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||||
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">
|
||||
Alias (kommagetrennt)
|
||||
</label>
|
||||
<input
|
||||
value={alias}
|
||||
onChange={(e) => setAlias(e.target.value)}
|
||||
className="w-40 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
|
||||
dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern"><FontAwesomeIcon icon={faFloppyDisk} /></Button>
|
||||
<Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen"><FontAwesomeIcon icon={faXmark} /></Button>
|
||||
{mutation.isError ? (
|
||||
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function BookmarkRow({
|
||||
bookmark,
|
||||
draggable,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
isDragging,
|
||||
}: {
|
||||
bookmark: Bookmark;
|
||||
draggable: boolean;
|
||||
onDragStart: (e: DragEvent<HTMLTableRowElement>) => void;
|
||||
onDragOver: (e: DragEvent<HTMLTableRowElement>) => void;
|
||||
onDrop: (e: DragEvent<HTMLTableRowElement>) => void;
|
||||
isDragging: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const favoriteMutation = useMutation({
|
||||
mutationFn: () => patchBookmark(bookmark.id, { favorite: !bookmark.favorite }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["bookmarks"] }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteBookmarkRequest(bookmark.id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["bookmarks"] }),
|
||||
});
|
||||
|
||||
if (editing) {
|
||||
return <EditForm bookmark={bookmark} onDone={() => setEditing(false)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
draggable={draggable}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
className={`border-b border-black/5 last:border-0 dark:border-white/5 ${isDragging ? "opacity-40" : ""}`}
|
||||
>
|
||||
<td className="px-2 py-3 text-center">
|
||||
<span
|
||||
className={`select-none ${draggable ? "cursor-grab text-black/30 dark:text-white/30" : "text-black/10 dark:text-white/10"}`}
|
||||
aria-hidden
|
||||
>
|
||||
⠿⠿
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-3">
|
||||
<button
|
||||
onClick={() => favoriteMutation.mutate()}
|
||||
aria-label={bookmark.favorite ? "Favorit entfernen" : "Als Favorit markieren"}
|
||||
className={`text-lg ${bookmark.favorite ? "text-amber-500" : "text-black/15 hover:text-amber-400 dark:text-white/15"}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={bookmark.favorite ? faStarSolid : faStar} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Favicon src={bookmark.favicon} fallbackLetter={bookmark.displayName} size="sm" />
|
||||
<div>
|
||||
<div className="font-medium text-black dark:text-white">{bookmark.displayName}</div>
|
||||
<div className="text-xs text-black/40 dark:text-white/40">{bookmark.hostname}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-black/60 dark:text-white/60">{bookmark.category ?? "–"}</td>
|
||||
<td className="px-4 py-3 text-black/60 dark:text-white/60">
|
||||
{bookmark.description ?? "–"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<a
|
||||
href={bookmark.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-black/60 underline decoration-black/20 hover:text-black dark:text-white/60 dark:decoration-white/20 dark:hover:text-white"
|
||||
>
|
||||
öffnen
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"><FontAwesomeIcon icon={faPen} /></Button>
|
||||
<Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
type SortColumn = "displayName" | "hostname" | "category" | null;
|
||||
|
||||
function SortableHeader({
|
||||
label,
|
||||
column,
|
||||
activeColumn,
|
||||
direction,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
column: SortColumn;
|
||||
activeColumn: SortColumn;
|
||||
direction: "asc" | "desc";
|
||||
onClick: (column: SortColumn) => void;
|
||||
}) {
|
||||
const active = activeColumn === column;
|
||||
return (
|
||||
<th className="px-4 py-2 font-medium">
|
||||
<button
|
||||
onClick={() => onClick(column)}
|
||||
className={`flex items-center gap-1 hover:text-black dark:hover:text-white ${
|
||||
active ? "text-black dark:text-white" : ""
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
<span className="text-[10px]"><FontAwesomeIcon icon={active ? (direction === "asc" ? faSortUp : faSortDown) : faSort} /></span>
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookmarksPage() {
|
||||
const { data: bookmarks, isLoading, isError } = useBookmarks();
|
||||
const queryClient = useQueryClient();
|
||||
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||
const [localOrder, setLocalOrder] = useState<Bookmark[] | null>(null);
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>(null);
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: reorderBookmarksRequest,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["bookmarks"] });
|
||||
setLocalOrder(null);
|
||||
},
|
||||
onError: () => setLocalOrder(null),
|
||||
});
|
||||
|
||||
const baseList = localOrder ?? bookmarks ?? [];
|
||||
|
||||
const list = useMemo(() => {
|
||||
if (!sortColumn) return baseList;
|
||||
const sorted = [...baseList].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (sortColumn) {
|
||||
case "displayName":
|
||||
cmp = a.displayName.localeCompare(b.displayName);
|
||||
break;
|
||||
case "hostname":
|
||||
cmp = a.hostname.localeCompare(b.hostname);
|
||||
break;
|
||||
case "category":
|
||||
cmp = (a.category ?? "").localeCompare(b.category ?? "");
|
||||
break;
|
||||
}
|
||||
return sortDirection === "asc" ? cmp : -cmp;
|
||||
});
|
||||
return sorted;
|
||||
}, [baseList, sortColumn, sortDirection]);
|
||||
|
||||
function handleHeaderClick(column: SortColumn) {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
}
|
||||
|
||||
const dragEnabled = sortColumn === null;
|
||||
|
||||
function handleDragStart(id: string) {
|
||||
return (_e: DragEvent<HTMLTableRowElement>) => setDraggedId(id);
|
||||
}
|
||||
|
||||
function handleDragOver(targetId: string) {
|
||||
return (e: DragEvent<HTMLTableRowElement>) => {
|
||||
e.preventDefault();
|
||||
if (!dragEnabled || !draggedId || draggedId === targetId) return;
|
||||
const current = localOrder ?? bookmarks ?? [];
|
||||
const fromIndex = current.findIndex((b) => b.id === draggedId);
|
||||
const toIndex = current.findIndex((b) => b.id === targetId);
|
||||
if (fromIndex === -1 || toIndex === -1) return;
|
||||
const next = [...current];
|
||||
const [moved] = next.splice(fromIndex, 1);
|
||||
next.splice(toIndex, 0, moved);
|
||||
setLocalOrder(next);
|
||||
};
|
||||
}
|
||||
|
||||
function handleDrop() {
|
||||
return (e: DragEvent<HTMLTableRowElement>) => {
|
||||
e.preventDefault();
|
||||
if (!dragEnabled) return;
|
||||
setDraggedId(null);
|
||||
const current = localOrder ?? bookmarks ?? [];
|
||||
reorderMutation.mutate(current.map((b, index) => ({ id: b.id, order: index })));
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader
|
||||
title="Lesezeichen"
|
||||
description="Eigenständig von Diensten – erscheinen zusammen mit ihnen in der Suche, aber als eigene Favoriten-Gruppe auf der Startseite. Spaltenköpfe anklickbar zum Sortieren; Drag & Drop (⠿⠿) nur in der Standard-Reihenfolge."
|
||||
/>
|
||||
|
||||
<AddBookmarkForm />
|
||||
|
||||
{sortColumn ? (
|
||||
<div className="mb-3">
|
||||
<Button size="sm" variant="ghost" onClick={() => setSortColumn(null)}>
|
||||
<FontAwesomeIcon icon={faArrowLeft} /> Zur manuellen Reihenfolge (Drag & Drop) zurück
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-black/40 dark:text-white/40">Lade Lesezeichen …</p>
|
||||
) : isError ? (
|
||||
<p className="text-sm text-red-500">Lesezeichen konnten nicht geladen werden.</p>
|
||||
) : list.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[720px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-black/10 bg-black/[0.02] text-left text-xs
|
||||
uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
|
||||
<th className="px-2 py-2" />
|
||||
<th className="px-2 py-2" />
|
||||
<SortableHeader label="Lesezeichen" column="displayName" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||||
<SortableHeader label="Kategorie" column="category" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||||
<th className="px-4 py-2 font-medium">Beschreibung</th>
|
||||
<SortableHeader label="URL" column="hostname" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||||
<th className="px-4 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((bookmark) => (
|
||||
<BookmarkRow
|
||||
key={bookmark.id}
|
||||
bookmark={bookmark}
|
||||
draggable={dragEnabled}
|
||||
isDragging={draggedId === bookmark.id}
|
||||
onDragStart={handleDragStart(bookmark.id)}
|
||||
onDragOver={handleDragOver(bookmark.id)}
|
||||
onDrop={handleDrop()}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-black/40 dark:text-white/40">
|
||||
Noch keine Lesezeichen angelegt. Füge oben eine URL hinzu.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
208
apps/frontend/src/routes/admin/ReadLaterPage.tsx
Normal file
208
apps/frontend/src/routes/admin/ReadLaterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 <suchbegriff>" 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>
|
||||
);
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
249
docs/ROADMAP.md
249
docs/ROADMAP.md
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
69
packages/ui/src/Favicon.tsx
Normal file
69
packages/ui/src/Favicon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
272
packages/ui/src/FaviconPicker.tsx
Normal file
272
packages/ui/src/FaviconPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
packages/ui/src/FavoritesBar.tsx
Normal file
120
packages/ui/src/FavoritesBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
132
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user