round26: Ping vor Portscan, Liste neuer Dienste, Suchmaschinen-Integration (OpenSearch), API-Scanner

This commit is contained in:
2026-07-24 02:21:58 +02:00
parent 458f58af6f
commit 0fc063b2a2
17 changed files with 507 additions and 4 deletions

View File

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

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
<ShortName>LaunchPad</ShortName>
<Description>Dienste und Lesezeichen in LaunchPad suchen und direkt öffnen</Description>
<InputEncoding>UTF-8</InputEncoding>
<Image height="16" width="16" type="image/svg+xml">/favicon.svg</Image>
<Url type="text/html" method="get" template="/search?q={searchTerms}"/>
</OpenSearchDescription>

View File

@@ -0,0 +1,25 @@
import { useQuery } from "@tanstack/react-query";
export interface DetectedApiEntry {
id: string;
serviceId: string;
path: string;
type: string;
status: number;
detectedAt: string;
}
async function fetchDetectedApis(): Promise<DetectedApiEntry[]> {
const res = await fetch("/api/detected-apis");
if (!res.ok) {
throw new Error(`APIs konnten nicht geladen werden (HTTP ${res.status})`);
}
return res.json();
}
export function useDetectedApis() {
return useQuery({
queryKey: ["detected-apis"],
queryFn: fetchDetectedApis,
});
}

View File

@@ -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,

View File

@@ -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();

View File

@@ -0,0 +1,54 @@
import { useEffect, useState } from "react";
import { rankSearchResults, type SearchResult } from "@launchpad/shared";
import { useServices } from "../hooks/useServices.js";
import { useBookmarks } from "../hooks/useBookmarks.js";
/**
* Ziel des OpenSearch-Eintrags (siehe /opensearch.xml): Browser mit
* zugewiesenem Kürzel (z. B. "lp") rufen "/search?q=<Suchbegriff>" auf.
* Gibt es einen eindeutigen Treffer, wird SOFORT dorthin weitergeleitet
* (window.location.replace, kein Eintrag in der Chronik). Gibt es keinen
* eindeutigen Treffer, landet man stattdessen auf der Startseite mit dem
* Suchbegriff bereits eingetragen - dort lässt sich die volle Trefferliste
* durchsehen.
*
* Bewusst rein client-seitig (kein eigener Backend-Redirect-Endpunkt nötig):
* nutzt exakt dieselben Daten/Rangfolge wie die normale Suche auf der
* Startseite (siehe rankSearchResults in @launchpad/shared).
*/
export function SearchRedirectPage() {
const { data: services, isLoading: servicesLoading } = useServices();
const { data: bookmarks, isLoading: bookmarksLoading } = useBookmarks();
const [failed, setFailed] = useState(false);
const query = new URLSearchParams(window.location.search).get("q") ?? "";
useEffect(() => {
if (servicesLoading || bookmarksLoading) return;
if (!query.trim()) {
window.location.replace("/");
return;
}
const items: SearchResult[] = [
...(services ?? []).filter((s) => s.visible).map((s) => ({ ...s, kind: "service" as const })),
...(bookmarks ?? []).map((b) => ({ ...b, kind: "bookmark" as const })),
];
const results = rankSearchResults(items, query);
if (results.length > 0 && results[0].kind !== "device") {
window.location.replace(results[0].url);
} else {
setFailed(true);
window.location.replace(`/?q=${encodeURIComponent(query)}`);
}
}, [servicesLoading, bookmarksLoading, services, bookmarks, query]);
return (
<div className="flex h-dvh flex-col items-center justify-center gap-2 bg-white text-black dark:bg-black dark:text-white">
<p className="text-sm text-black/50 dark:text-white/50">
{failed ? "Kein eindeutiger Treffer, leite zur Suche weiter …" : `Suche „${query}“ …`}
</p>
</div>
);
}

View File

@@ -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>
);