From 430b79763c0a99122ab746cacbc40e446543fd01 Mon Sep 17 00:00:00 2001 From: Dicken Date: Fri, 24 Jul 2026 09:24:57 +0200 Subject: [PATCH] round27: Ping-Regression rueckgaengig gemacht (Echo/Samsung-Geraete ignorieren ICMP), Aktualisiert-Text entfernt, Suchmaschinen-Hinweis+README, API-Scanner stark erweitert mit eigenem Reiter --- README.md | 69 ++++++++++++++ apps/backend/src/db/repositories/apis.ts | 21 ++++- apps/backend/src/routes/apis.ts | 13 ++- apps/backend/src/scanner/apiDetector.ts | 93 ++++++++++++++++--- apps/backend/src/scanner/networkScanner.ts | 13 --- apps/frontend/src/router.tsx | 8 ++ .../frontend/src/routes/admin/AdminLayout.tsx | 2 + apps/frontend/src/routes/admin/ApisPage.tsx | 78 ++++++++++++++++ .../frontend/src/routes/admin/ScannerPage.tsx | 87 ++++++++--------- .../src/routes/admin/SettingsPage.tsx | 54 +++++++++++ 10 files changed, 358 insertions(+), 80 deletions(-) create mode 100644 apps/frontend/src/routes/admin/ApisPage.tsx diff --git a/README.md b/README.md index d1eb47e..fc9cfc2 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,75 @@ POST /api/plugins/:name/import löst importDevices() eines Plugins aus /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://:8443/search?q=%s` + +**Firefox:** +1. Einmal `https://:8443/` öffnen (Firefox erkennt den + hinterlegten OpenSearch-Eintrag automatisch) +2. Einstellungen → „Suche" → bei „LaunchPad" ein Schlagwort (z. B. `lp`) + eintragen + +Danach: `lp ` in die Adresszeile, Enter – landet bei eindeutigem +Treffer direkt beim Dienst, sonst auf der LaunchPad-Startseite mit bereits +eingetragenem Suchbegriff. + +## 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 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 diff --git a/apps/backend/src/db/repositories/apis.ts b/apps/backend/src/db/repositories/apis.ts index d92916c..8539967 100644 --- a/apps/backend/src/db/repositories/apis.ts +++ b/apps/backend/src/db/repositories/apis.ts @@ -27,15 +27,29 @@ 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 }[] -): DetectedApiEntry[] { +): { 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(); @@ -52,7 +66,10 @@ export function replaceApisForService( db.insert(detectedApis).values(rows).run(); } - return rows.map(mapRow); + 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 { diff --git a/apps/backend/src/routes/apis.ts b/apps/backend/src/routes/apis.ts index 337955a..caf29d9 100644 --- a/apps/backend/src/routes/apis.ts +++ b/apps/backend/src/routes/apis.ts @@ -19,13 +19,17 @@ export async function apiRoutes(app: FastifyInstance): Promise { const services = serviceRepo.listServices(); let servicesWithApi = 0; let totalFound = 0; + const newFindings: { serviceId: string; serviceName: string; apis: apiRepo.DetectedApiEntry[] }[] = []; for (const service of services) { const found = await detectApis(service.url); if (found.length > 0) { - apiRepo.replaceApisForService(service.id, found); + 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); } @@ -38,7 +42,7 @@ export async function apiRoutes(app: FastifyInstance): Promise { message: `API-Scan: ${services.length} Dienst(e) geprüft, bei ${servicesWithApi} Dienst(en) ${totalFound} API-Endpunkt(e) gefunden.`, }); - return { checked: services.length, servicesWithApi, totalFound }; + return { checked: services.length, servicesWithApi, totalFound, newFindings }; }); app.post("/api/scan/apis/:serviceId", async (request, reply) => { @@ -49,9 +53,10 @@ export async function apiRoutes(app: FastifyInstance): Promise { } const found = await detectApis(service.url); - const saved = found.length > 0 ? apiRepo.replaceApisForService(service.id, found) : []; + 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 }; + return { serviceId, apis: saved, added }; }); } diff --git a/apps/backend/src/scanner/apiDetector.ts b/apps/backend/src/scanner/apiDetector.ts index 7068c22..d1dee9e 100644 --- a/apps/backend/src/scanner/apiDetector.ts +++ b/apps/backend/src/scanner/apiDetector.ts @@ -10,6 +10,7 @@ export interface DetectedApi { interface RawProbeResult { status: number; contentType: string | null; + wwwAuthenticate: string | null; bodySnippet: string; } @@ -32,7 +33,13 @@ function fetchRaw(url: string, timeoutMs = 2500): Promise }); res.on("end", () => { const contentType = res.headers["content-type"] ?? null; - resolve({ status: res.statusCode ?? 0, contentType, bodySnippet: body }); + 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)); } @@ -47,20 +54,58 @@ function fetchRaw(url: string, timeoutMs = 2500): Promise /** * Wohlbekannte Pfade, unter denen selbstgehostete Software üblicherweise - * ihre API bzw. deren Dokumentation/Schema anbietet. Bewusst eine - * kuratierte, kurze Liste statt eines vollständigen Wortlisten-Bruteforce - - * das hier ist ein Hinweis-Scanner, kein Sicherheits-/Pentesting-Werkzeug. + * 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 { 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: "/docs", type: "API-Dokumentation" }, { 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: "/.well-known/openapi.json", type: "OpenAPI" }, + { path: "/rest", type: "REST-API" }, + { path: "/rpc", type: "JSON-RPC" }, + { path: "/jsonrpc", type: "JSON-RPC" }, + { path: "/api/status", type: "REST-API" }, + { path: "/api/system", type: "REST-API" }, + { path: "/api/info", type: "REST-API" }, + { path: "/api/version", type: "REST-API" }, + { path: "/api/config", type: "REST-API" }, + { path: "/actuator", type: "Spring-Boot-Actuator" }, + { path: "/actuator/health", type: "Spring-Boot-Actuator" }, + { path: "/metrics", type: "Metriken (Prometheus-Format)" }, + { path: "/healthz", type: "Health-Endpunkt" }, + { path: "/health", type: "Health-Endpunkt" }, + // Konkrete, in Homelabs verbreitete Software + { 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/health", type: "Grafana 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 { @@ -70,11 +115,15 @@ function looksLikeJson(body: string): boolean { /** * 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"), oder ein - * Content-Type, der explizit auf JSON/GraphQL hindeutet. Reine HTML-Seiten - * (z. B. eine 404-Fehlerseite des Frontends) zählen nicht. + * 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 ein sehr + * starkes Signal für eine authentifizierungspflichtige API, selbst wenn + * der Body selbst nur eine schlichte Textmeldung ist). + * Reine HTML-Seiten (z. B. eine 404-Fehlerseite des Frontends oder eine + * Login-Weiterleitung ohne API-Signal) zählen nicht. */ export async function detectApis(baseUrl: string): Promise { const checks = await Promise.all( @@ -83,15 +132,33 @@ export async function detectApis(baseUrl: string): Promise { if (!result || result.status === 0 || result.status === 404) return null; const contentTypeIsApi = - result.contentType?.includes("json") || result.contentType?.includes("graphql"); + result.contentType?.includes("json") || + result.contentType?.includes("graphql") || + result.contentType?.includes("xml"); const bodyIsJson = looksLikeJson(result.bodySnippet); + const hasAuthChallenge = !!result.wwwAuthenticate; - if (!contentTypeIsApi && !bodyIsJson) return null; + if (!contentTypeIsApi && !bodyIsJson && !hasAuthChallenge) return null; const detected: DetectedApi = { path, type, status: result.status }; return detected; }) ); - return checks.filter((c): c is DetectedApi => c !== null); + const matches = checks.filter((c): c is DetectedApi => c !== 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(); + 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()); } diff --git a/apps/backend/src/scanner/networkScanner.ts b/apps/backend/src/scanner/networkScanner.ts index 7a9533d..767ce1e 100644 --- a/apps/backend/src/scanner/networkScanner.ts +++ b/apps/backend/src/scanner/networkScanner.ts @@ -3,7 +3,6 @@ import { isPortOpen, TYPICAL_PORTS } from "./ports.js"; import { probeHttp } from "./http.js"; import { detectSoftware } from "./softwareDetection.js"; import { findBestIconMatch } from "./iconDb.js"; -import { pingHost, isPingBinaryConfirmedMissing } from "./ping.js"; export interface ScanTarget { hostname: string; @@ -91,18 +90,6 @@ export async function scanDeviceServices( // gemeldeten Namen ein. const suggestedHostname = dnsResult ? null : await reverseLookup(device.ip); - // Vor dem eigentlichen Portscan erst ein einzelner Ping (ICMP) - ist das - // Gerät gar nicht erreichbar (aus, im Standby, vom Netz getrennt), spart - // das den kompletten Portscan (auch parallel noch ~800ms) UND alle - // nachfolgenden DNS/HTTP-Versuche. Nur wenn KEIN Ping ankommt wird - // übersprungen - manche Geräte blocken ICMP, antworten aber auf TCP, dafür - // bleibt genau deswegen bewusst KEIN weiterer früher Abbruch bestehen, - // sondern nur dieser eine zusätzliche, sehr schnelle Vorab-Check. - const reachable = await pingHost(device.ip); - if (!reachable && !isPingBinaryConfirmedMissing()) { - return { services: [], suggestedHostname }; - } - const candidatePorts = Array.from(new Set([80, 443, ...extraPorts])); // Die offenen Ports werden PARALLEL geprüft, nicht nacheinander - bei diff --git a/apps/frontend/src/router.tsx b/apps/frontend/src/router.tsx index 3a18a86..6a7c510 100644 --- a/apps/frontend/src/router.tsx +++ b/apps/frontend/src/router.tsx @@ -8,6 +8,7 @@ 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"; @@ -80,6 +81,12 @@ const adminScannerRoute = createRoute({ component: ScannerPage, }); +const adminApisRoute = createRoute({ + getParentRoute: () => adminRoute, + path: "/apis", + component: ApisPage, +}); + const adminReadLaterRoute = createRoute({ getParentRoute: () => adminRoute, path: "/read-later", @@ -115,6 +122,7 @@ const routeTree = rootRoute.addChildren([ adminBookmarksRoute, adminCategoriesRoute, adminScannerRoute, + adminApisRoute, adminReadLaterRoute, adminPluginsRoute, adminSettingsRoute, diff --git a/apps/frontend/src/routes/admin/AdminLayout.tsx b/apps/frontend/src/routes/admin/AdminLayout.tsx index d6b0138..f4f495c 100644 --- a/apps/frontend/src/routes/admin/AdminLayout.tsx +++ b/apps/frontend/src/routes/admin/AdminLayout.tsx @@ -12,6 +12,7 @@ import { faBookmark, faThumbtack, faMagnifyingGlass, + faPlug, faTag, faPuzzlePiece, faGear, @@ -29,6 +30,7 @@ const NAV_ITEMS: { to: string; label: string; icon: IconDefinition }[] = [ { 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 }, diff --git a/apps/frontend/src/routes/admin/ApisPage.tsx b/apps/frontend/src/routes/admin/ApisPage.tsx new file mode 100644 index 0000000..3696e74 --- /dev/null +++ b/apps/frontend/src/routes/admin/ApisPage.tsx @@ -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(); + for (const entry of apis ?? []) { + const list = groups.get(entry.serviceId) ?? []; + list.push(entry); + groups.set(entry.serviceId, list); + } + + return ( +
+ + + {isLoading ? ( +

Lade …

+ ) : isError ? ( +

Konnte nicht geladen werden.

+ ) : groups.size === 0 ? ( +

+ Noch keine APIs gefunden. Auf der Scanner-Seite einmal „APIs jetzt scannen" ausführen. +

+ ) : ( +
+ {Array.from(groups.entries()).map(([serviceId, entries]) => { + const service = serviceById.get(serviceId); + return ( +
+
+

+ {service?.displayName ?? "Unbekannter Dienst"} +

+ + zuletzt geprüft: {formatDate(entries[0].detectedAt)} + +
+ +
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/frontend/src/routes/admin/ScannerPage.tsx b/apps/frontend/src/routes/admin/ScannerPage.tsx index d1ee601..29fdfc5 100644 --- a/apps/frontend/src/routes/admin/ScannerPage.tsx +++ b/apps/frontend/src/routes/admin/ScannerPage.tsx @@ -1,11 +1,11 @@ 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 { useServices } from "../../hooks/useServices.js"; -import { useDetectedApis, type DetectedApiEntry } from "../../hooks/useDetectedApis.js"; +import type { DetectedApiEntry } from "../../hooks/useDetectedApis.js"; import { usePersistedState } from "../../hooks/usePersistedState.js"; import { AdminPageHeader } from "./AdminPageHeader.js"; @@ -329,40 +329,46 @@ function StaleDevicesReview({ function ApiScannerCard() { const queryClient = useQueryClient(); - const { data: services } = useServices(); - const { data: detectedApis } = useDetectedApis(); const [status, setStatus] = usePersistedState("scanner:apiStatus", null); + const [lastNewFindings, setLastNewFindings] = usePersistedState< + { serviceId: string; serviceName: string; apis: DetectedApiEntry[] }[] + >("scanner:apiNewFindings", []); const scanMutation = useMutation({ mutationFn: async () => { const res = await fetch("/api/scan/apis", { method: "POST" }); if (!res.ok) throw new Error(`Fehlgeschlagen (HTTP ${res.status})`); - return res.json() as Promise<{ checked: number; servicesWithApi: number; totalFound: number }>; + return res.json() as Promise<{ + checked: number; + servicesWithApi: number; + totalFound: number; + newFindings: { serviceId: string; serviceName: string; apis: DetectedApiEntry[] }[]; + }>; }, onSuccess: (result) => { + const newCount = result.newFindings.reduce((sum, f) => sum + f.apis.length, 0); setStatus( - `Fertig: ${result.checked} Dienst(e) geprüft, bei ${result.servicesWithApi} Dienst(en) ${result.totalFound} API-Endpunkt(e) gefunden.` + `Fertig: ${result.checked} Dienst(e) geprüft, ${result.totalFound} API-Endpunkt(e) insgesamt bei ${result.servicesWithApi} Dienst(en)` + + (newCount > 0 ? `, davon ${newCount} neu/geändert seit dem letzten Scan.` : ", keine Änderungen seit dem letzten Scan.") ); + setLastNewFindings(result.newFindings); queryClient.invalidateQueries({ queryKey: ["detected-apis"] }); }, onError: (err: Error) => setStatus(err.message), }); - const serviceById = new Map((services ?? []).map((s) => [s.id, s])); - const groups = new Map(); - for (const entry of detectedApis ?? []) { - const list = groups.get(entry.serviceId) ?? []; - list.push(entry); - groups.set(entry.serviceId, list); - } - return (

API-Scanner

Eigener, separater Scan: prüft alle bekannten Dienste auf gängige API-Pfade - (OpenAPI/Swagger, GraphQL, REST) und listet die Funde auf. Läuft nur auf Knopfdruck, - nie zusammen mit dem normalen Geräte-Scan. + (OpenAPI/Swagger, GraphQL, REST und viele konkrete Selfhosted-Programme) und listet + neue/geänderte Funde hier auf. Läuft nur auf Knopfdruck, nie zusammen mit dem normalen + Geräte-Scan. Die vollständige, dauerhafte Liste steht immer unter{" "} + + Admin → APIs + + .

); } @@ -483,7 +474,7 @@ async function scanAllDevices() { allStale.push(...result.staleServices); allNameChanges.push(...result.nameChanges); allNew.push(...result.newServices); - setBulkStatus(`Scanne ${device.hostname} … (${created} neu, ${updated} aktualisiert bisher)`); + setBulkStatus(`Scanne ${device.hostname} … (${created} neue(r) Dienst(e) bisher)`); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") break; // einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen diff --git a/apps/frontend/src/routes/admin/SettingsPage.tsx b/apps/frontend/src/routes/admin/SettingsPage.tsx index 6ad67d4..191704c 100644 --- a/apps/frontend/src/routes/admin/SettingsPage.tsx +++ b/apps/frontend/src/routes/admin/SettingsPage.tsx @@ -328,6 +328,58 @@ function DangerZone() { ); } +function SearchEngineHint() { + const [open, setOpen] = useState(false); + const origin = typeof window !== "undefined" ? window.location.origin : ""; + + return ( +
+ + {open ? ( +
+
+

Chrome / Edge

+
    +
  1. Einstellungen → „Suchmaschine" → „Suchmaschinen verwalten"
  2. +
  3. „Website-Suchmaschine hinzufügen"
  4. +
  5. + Name: LaunchPad, Kürzel:{" "} + lp, URL:{" "} + {origin}/search?q=%s +
  6. +
+
+
+

Firefox

+
    +
  1. Diese Seite einmal besucht (fertig, Firefox hat den Eintrag bereits erkannt)
  2. +
  3. Einstellungen → „Suche" → bei „LaunchPad" ein Schlagwort (z. B. „lp") eintragen
  4. +
+
+

+ Danach: „lp <suchbegriff>" in die Adresszeile – bei eindeutigem Treffer geht's direkt + zum Dienst, sonst zur LaunchPad-Suche mit vorbefülltem Begriff. +

+
+ ) : null} +
+ ); +} + export function SettingsPage() { const { health, error } = useBackendHealth(); const [theme, toggleTheme] = useTheme(); @@ -338,6 +390,8 @@ export function SettingsPage() {
+ +

Darstellung