round27: Ping-Regression rueckgaengig gemacht (Echo/Samsung-Geraete ignorieren ICMP), Aktualisiert-Text entfernt, Suchmaschinen-Hinweis+README, API-Scanner stark erweitert mit eigenem Reiter

This commit is contained in:
2026-07-24 09:24:57 +02:00
parent 0fc063b2a2
commit 430b79763c
10 changed files with 358 additions and 80 deletions

View File

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

View File

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

View File

@@ -0,0 +1,78 @@
import { useServices } from "../../hooks/useServices.js";
import { useDetectedApis, type DetectedApiEntry } from "../../hooks/useDetectedApis.js";
import { AdminPageHeader } from "./AdminPageHeader.js";
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" });
} catch {
return iso;
}
}
export function ApisPage() {
const { data: services } = useServices();
const { data: apis, isLoading, isError } = useDetectedApis();
const serviceById = new Map((services ?? []).map((s) => [s.id, s]));
const groups = new Map<string, DetectedApiEntry[]>();
for (const entry of apis ?? []) {
const list = groups.get(entry.serviceId) ?? [];
list.push(entry);
groups.set(entry.serviceId, list);
}
return (
<div>
<AdminPageHeader
title="APIs"
description='Von einem eigenen, separaten Scan gefundene API-Endpunkte bekannter Dienste (siehe Admin -> Scanner -> "APIs jetzt scannen"). Läuft nie automatisch mit.'
/>
{isLoading ? (
<p className="text-sm text-black/40 dark:text-white/40">Lade </p>
) : isError ? (
<p className="text-sm text-red-500">Konnte nicht geladen werden.</p>
) : groups.size === 0 ? (
<p className="text-sm text-black/40 dark:text-white/40">
Noch keine APIs gefunden. Auf der Scanner-Seite einmal APIs jetzt scannen" ausführen.
</p>
) : (
<div className="space-y-4">
{Array.from(groups.entries()).map(([serviceId, entries]) => {
const service = serviceById.get(serviceId);
return (
<div key={serviceId} className="rounded-2xl border border-black/10 p-4 dark:border-white/10">
<div className="mb-2 flex items-center justify-between">
<h3 className="font-medium text-black dark:text-white">
{service?.displayName ?? "Unbekannter Dienst"}
</h3>
<span className="text-xs text-black/30 dark:text-white/30">
zuletzt geprüft: {formatDate(entries[0].detectedAt)}
</span>
</div>
<ul className="flex flex-wrap gap-2">
{entries.map((e) => (
<li key={e.id}>
<a
href={`${service?.url ?? ""}${e.path}`}
target="_blank"
rel="noopener noreferrer"
title={`HTTP ${e.status}`}
className="flex items-center gap-1.5 rounded-full bg-black/5 px-2.5 py-1 text-xs
text-black/70 hover:bg-black/10 dark:bg-white/10 dark:text-white/70
dark:hover:bg-white/20"
>
{e.type} · {e.path}
</a>
</li>
))}
</ul>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -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<string | null>("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<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.
(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{" "}
<Link to="/admin/apis" className="underline">
Admin APIs
</Link>
.
</p>
<Button
variant="primary"
@@ -377,39 +383,24 @@ function ApiScannerCard() {
</Button>
{status ? <p className="mt-2 text-sm text-black/50 dark:text-white/50">{status}</p> : null}
{groups.size > 0 ? (
{lastNewFindings.length > 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>
);
})}
{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="font-medium text-black dark:text-white">{serviceName}</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>
) : (
<p className="mt-4 text-sm text-black/30 dark:text-white/30">
Noch keine APIs gefunden. Einmal scannen, um loszulegen.
</p>
)}
) : null}
</div>
);
}
@@ -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

View File

@@ -328,6 +328,58 @@ function DangerZone() {
);
}
function SearchEngineHint() {
const [open, setOpen] = useState(false);
const origin = typeof window !== "undefined" ? window.location.origin : "";
return (
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center justify-between text-left"
>
<span>
<span className="font-medium text-black dark:text-white">
💡 Als Browser-Suchmaschine einrichten
</span>
<p className="mt-0.5 text-sm text-black/50 dark:text-white/50">
Tippe künftig z. B. <code className="rounded bg-black/5 px-1 dark:bg-white/10">lp proxmox</code>{" "}
direkt in die Adresszeile deines Browsers.
</p>
</span>
<span className="text-black/30 dark:text-white/30">{open ? "▾" : "▸"}</span>
</button>
{open ? (
<div className="mt-3 space-y-3 text-sm text-black/70 dark:text-white/70">
<div>
<p className="font-medium text-black dark:text-white">Chrome / Edge</p>
<ol className="ml-4 list-decimal space-y-0.5">
<li>Einstellungen Suchmaschine" → „Suchmaschinen verwalten"</li>
<li>Website-Suchmaschine hinzufügen"</li>
<li>
Name: <code className="rounded bg-black/5 px-1 dark:bg-white/10">LaunchPad</code>, Kürzel:{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">lp</code>, URL:{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">{origin}/search?q=%s</code>
</li>
</ol>
</div>
<div>
<p className="font-medium text-black dark:text-white">Firefox</p>
<ol className="ml-4 list-decimal space-y-0.5">
<li>Diese Seite einmal besucht (fertig, Firefox hat den Eintrag bereits erkannt)</li>
<li>Einstellungen → „Suche" bei LaunchPad" ein Schlagwort (z. B. „lp") eintragen</li>
</ol>
</div>
<p className="text-xs text-black/40 dark:text-white/40">
Danach: lp &lt;suchbegriff&gt;" in die Adresszeile bei eindeutigem Treffer geht's direkt
zum Dienst, sonst zur LaunchPad-Suche mit vorbefülltem Begriff.
</p>
</div>
) : null}
</div>
);
}
export function SettingsPage() {
const { health, error } = useBackendHealth();
const [theme, toggleTheme] = useTheme();
@@ -338,6 +390,8 @@ export function SettingsPage() {
<AdminPageHeader title="Einstellungen" />
<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">