generated from Dicken/dickendock
round26: Ping vor Portscan, Liste neuer Dienste, Suchmaschinen-Integration (OpenSearch), API-Scanner
This commit is contained in:
@@ -119,6 +119,15 @@ export function ensureSchema(): void {
|
||||
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
|
||||
);
|
||||
`);
|
||||
|
||||
// Leichte Migration für Datenbanken, die vor Einführung von "visible"/
|
||||
|
||||
60
apps/backend/src/db/repositories/apis.ts
Normal file
60
apps/backend/src/db/repositories/apis.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function replaceApisForService(
|
||||
serviceId: string,
|
||||
found: { path: string; type: string; status: number }[]
|
||||
): DetectedApiEntry[] {
|
||||
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();
|
||||
}
|
||||
|
||||
return rows.map(mapRow);
|
||||
}
|
||||
|
||||
export function deleteApisForService(serviceId: string): void {
|
||||
db.delete(detectedApis).where(eq(detectedApis.serviceId, serviceId)).run();
|
||||
}
|
||||
@@ -153,3 +153,20 @@ export const readLater = sqliteTable("read_later", {
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ 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";
|
||||
@@ -78,6 +79,7 @@ async function main() {
|
||||
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" };
|
||||
|
||||
57
apps/backend/src/routes/apis.ts
Normal file
57
apps/backend/src/routes/apis.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function apiRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/detected-apis", async () => {
|
||||
return apiRepo.listDetectedApis();
|
||||
});
|
||||
|
||||
app.post("/api/scan/apis", async () => {
|
||||
const services = serviceRepo.listServices();
|
||||
let servicesWithApi = 0;
|
||||
let totalFound = 0;
|
||||
|
||||
for (const service of services) {
|
||||
const found = await detectApis(service.url);
|
||||
if (found.length > 0) {
|
||||
apiRepo.replaceApisForService(service.id, found);
|
||||
servicesWithApi++;
|
||||
totalFound += found.length;
|
||||
} 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.`,
|
||||
});
|
||||
|
||||
return { checked: services.length, servicesWithApi, totalFound };
|
||||
});
|
||||
|
||||
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 = found.length > 0 ? apiRepo.replaceApisForService(service.id, found) : [];
|
||||
if (found.length === 0) apiRepo.deleteApisForService(service.id);
|
||||
|
||||
return { serviceId, apis: saved };
|
||||
});
|
||||
}
|
||||
@@ -147,6 +147,7 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||
created,
|
||||
updated,
|
||||
services: results.map((r) => r.service),
|
||||
newServices: results.filter((r) => r.created).map((r) => r.service),
|
||||
staleServices,
|
||||
nameChanges,
|
||||
deviceNameSuggestion,
|
||||
|
||||
97
apps/backend/src/scanner/apiDetector.ts
Normal file
97
apps/backend/src/scanner/apiDetector.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
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;
|
||||
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;
|
||||
resolve({ status: res.statusCode ?? 0, contentType, 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. Bewusst eine
|
||||
* kuratierte, kurze Liste statt eines vollständigen Wortlisten-Bruteforce -
|
||||
* das hier ist ein Hinweis-Scanner, kein Sicherheits-/Pentesting-Werkzeug.
|
||||
*/
|
||||
const CANDIDATE_PATHS: { path: string; type: string }[] = [
|
||||
{ path: "/openapi.json", type: "OpenAPI" },
|
||||
{ path: "/swagger.json", type: "OpenAPI (Swagger)" },
|
||||
{ path: "/api-docs", type: "OpenAPI (Swagger)" },
|
||||
{ path: "/swagger/index.html", type: "Swagger-UI" },
|
||||
{ path: "/docs", type: "API-Dokumentation" },
|
||||
{ path: "/graphql", type: "GraphQL" },
|
||||
{ path: "/api/v1", type: "REST-API" },
|
||||
{ path: "/api", type: "REST-API" },
|
||||
{ path: "/.well-known/openapi.json", type: "OpenAPI" },
|
||||
];
|
||||
|
||||
function looksLikeJson(body: string): boolean {
|
||||
const trimmed = body.trim();
|
||||
return trimmed.startsWith("{") || trimmed.startsWith("[");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
|
||||
const contentTypeIsApi =
|
||||
result.contentType?.includes("json") || result.contentType?.includes("graphql");
|
||||
const bodyIsJson = looksLikeJson(result.bodySnippet);
|
||||
|
||||
if (!contentTypeIsApi && !bodyIsJson) return null;
|
||||
|
||||
const detected: DetectedApi = { path, type, status: result.status };
|
||||
return detected;
|
||||
})
|
||||
);
|
||||
|
||||
return checks.filter((c): c is DetectedApi => c !== null);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -90,6 +91,18 @@ 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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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
|
||||
@@ -16,8 +18,28 @@ export function pingHost(ip: string): Promise<boolean> {
|
||||
const command = isWindows ? `ping -n 1 -w 1000 ${ip}` : `ping -c 1 -W 1 ${ip}`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
exec(command, { timeout: 2000 }, (error) => {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
<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
|
||||
|
||||
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>
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -22,6 +23,12 @@ const indexRoute = createRoute({
|
||||
component: HomePage,
|
||||
});
|
||||
|
||||
const searchRedirectRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/search",
|
||||
component: SearchRedirectPage,
|
||||
});
|
||||
|
||||
const adminRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/admin",
|
||||
@@ -99,6 +106,7 @@ const adminLogsRoute = createRoute({
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
searchRedirectRoute,
|
||||
adminRoute.addChildren([
|
||||
adminIndexRoute,
|
||||
adminDashboardRoute,
|
||||
|
||||
@@ -115,9 +115,13 @@ function ReadLaterBox() {
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [query, setQuery] = useState(
|
||||
() => new URLSearchParams(window.location.search).get("q") ?? ""
|
||||
);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [resultsVisible, setResultsVisible] = useState(false);
|
||||
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();
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { faBan, faCheck, faTrash, faXmark } from "@fortawesome/free-solid-svg-ic
|
||||
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 { usePersistedState } from "../../hooks/usePersistedState.js";
|
||||
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||
|
||||
@@ -90,6 +92,7 @@ async function scanDeviceById(id: string, signal?: AbortSignal) {
|
||||
created: number;
|
||||
updated: number;
|
||||
staleServices: Service[];
|
||||
newServices: Service[];
|
||||
nameChanges: ScanNameChange[];
|
||||
deviceNameSuggestion: string | null;
|
||||
};
|
||||
@@ -166,6 +169,25 @@ function NameChangesReview({
|
||||
);
|
||||
}
|
||||
|
||||
function NewServicesList({ newServices }: { newServices: Service[] }) {
|
||||
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">
|
||||
<p className="mb-2 font-medium text-emerald-700 dark:text-emerald-400">
|
||||
{newServices.length} neue(r) Dienst(e) gefunden:
|
||||
</p>
|
||||
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
||||
{newServices.map((s) => (
|
||||
<li key={s.id} className="text-black/70 dark:text-white/70">
|
||||
{s.displayName} ({s.hostname}:{s.port})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StaleServicesReview({
|
||||
staleServices,
|
||||
onResolve,
|
||||
@@ -305,6 +327,93 @@ function StaleDevicesReview({
|
||||
);
|
||||
}
|
||||
|
||||
function ApiScannerCard() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: services } = useServices();
|
||||
const { data: detectedApis } = useDetectedApis();
|
||||
const [status, setStatus] = usePersistedState<string | null>("scanner:apiStatus", null);
|
||||
|
||||
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 }>;
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
setStatus(
|
||||
`Fertig: ${result.checked} Dienst(e) geprüft, bei ${result.servicesWithApi} Dienst(en) ${result.totalFound} API-Endpunkt(e) gefunden.`
|
||||
);
|
||||
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<string, DetectedApiEntry[]>();
|
||||
for (const entry of detectedApis ?? []) {
|
||||
const list = groups.get(entry.serviceId) ?? [];
|
||||
list.push(entry);
|
||||
groups.set(entry.serviceId, list);
|
||||
}
|
||||
|
||||
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 listet die Funde auf. Läuft nur auf Knopfdruck,
|
||||
nie zusammen mit dem normalen Geräte-Scan.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-4"
|
||||
onClick={() => {
|
||||
setStatus(null);
|
||||
scanMutation.mutate();
|
||||
}}
|
||||
disabled={scanMutation.isPending}
|
||||
>
|
||||
{scanMutation.isPending ? "Scanne …" : "APIs jetzt scannen"}
|
||||
</Button>
|
||||
{status ? <p className="mt-2 text-sm text-black/50 dark:text-white/50">{status}</p> : null}
|
||||
|
||||
{groups.size > 0 ? (
|
||||
<ul className="mt-4 max-h-72 space-y-2 overflow-y-auto text-sm">
|
||||
{Array.from(groups.entries()).map(([serviceId, entries]) => {
|
||||
const service = serviceById.get(serviceId);
|
||||
return (
|
||||
<li key={serviceId} className="rounded-lg border border-black/5 p-2 dark:border-white/5">
|
||||
<div className="font-medium text-black dark:text-white">
|
||||
{service?.displayName ?? "Unbekannter Dienst"}
|
||||
</div>
|
||||
<ul className="mt-1 flex flex-wrap gap-2">
|
||||
{entries.map((e) => (
|
||||
<li key={e.id}>
|
||||
<a
|
||||
href={`${service?.url ?? ""}${e.path}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-full bg-black/5 px-2 py-0.5 text-xs text-black/60
|
||||
hover:bg-black/10 dark:bg-white/10 dark:text-white/60 dark:hover:bg-white/20"
|
||||
>
|
||||
{e.type} · {e.path}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-4 text-sm text-black/30 dark:text-white/30">
|
||||
Noch keine APIs gefunden. Einmal scannen, um loszulegen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScannerPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: devices } = useDevices();
|
||||
@@ -315,6 +424,10 @@ export function ScannerPage() {
|
||||
"scanner:bulkStaleServices",
|
||||
[]
|
||||
);
|
||||
const [bulkNewServices, setBulkNewServices] = usePersistedState<Service[]>(
|
||||
"scanner:bulkNewServices",
|
||||
[]
|
||||
);
|
||||
const [bulkNameChanges, setBulkNameChanges] = usePersistedState<ScanNameChange[]>(
|
||||
"scanner:bulkNameChanges",
|
||||
[]
|
||||
@@ -352,11 +465,13 @@ async function scanAllDevices() {
|
||||
setBulkRunning(true);
|
||||
setBulkStaleServices([]);
|
||||
setBulkNameChanges([]);
|
||||
setBulkNewServices([]);
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let scannedCount = 0;
|
||||
const allStale: Service[] = [];
|
||||
const allNameChanges: ScanNameChange[] = [];
|
||||
const allNew: Service[] = [];
|
||||
|
||||
for (const device of devices) {
|
||||
if (controller.signal.aborted) break;
|
||||
@@ -367,6 +482,7 @@ async function scanAllDevices() {
|
||||
scannedCount++;
|
||||
allStale.push(...result.staleServices);
|
||||
allNameChanges.push(...result.nameChanges);
|
||||
allNew.push(...result.newServices);
|
||||
setBulkStatus(`Scanne ${device.hostname} … (${created} neu, ${updated} aktualisiert bisher)`);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") break;
|
||||
@@ -386,6 +502,7 @@ async function scanAllDevices() {
|
||||
);
|
||||
setBulkStaleServices(allStale);
|
||||
setBulkNameChanges(allNameChanges);
|
||||
setBulkNewServices(allNew);
|
||||
setBulkRunning(false);
|
||||
bulkAbortController = null;
|
||||
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||
@@ -469,6 +586,7 @@ async function scanAllDevices() {
|
||||
{bulkStatus ? (
|
||||
<p className="mt-2 text-sm text-black/50 dark:text-white/50">{bulkStatus}</p>
|
||||
) : null}
|
||||
<NewServicesList newServices={bulkNewServices} />
|
||||
<NameChangesReview
|
||||
nameChanges={bulkNameChanges}
|
||||
onResolve={(key) => setBulkNameChanges((prev) => prev.filter((c) => nameChangeKey(c) !== key))}
|
||||
@@ -478,6 +596,8 @@ async function scanAllDevices() {
|
||||
onResolve={(id) => setBulkStaleServices((prev) => prev.filter((s) => s.id !== id))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ApiScannerCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user