Commit 6: Adminbereich (TanStack Router, 8 Menüpunkte, Scan-Logs)

This commit is contained in:
2026-07-19 02:53:04 +02:00
parent b0ff551ca0
commit 2f31996bbb
31 changed files with 1693 additions and 112 deletions

View File

@@ -72,12 +72,17 @@ Dieser erste Commit liefert ein lauffähiges Grundgerüst:
(HTTP-Digest-Auth). Läuft ausschließlich manuell per API-Aufruf nie automatisch. (HTTP-Digest-Auth). Läuft ausschließlich manuell per API-Aufruf nie automatisch.
Benutzeränderungen an Diensten (Name, Kategorie, Favorit, Alias, Icon, Reihenfolge) Benutzeränderungen an Diensten (Name, Kategorie, Favorit, Alias, Icon, Reihenfolge)
bleiben bei erneuten Scans garantiert erhalten. bleiben bei erneuten Scans garantiert erhalten.
- ✅ Adminbereich unter `/admin` (TanStack Router): Dashboard, Geräte (inkl.
„Jetzt scannen"-Button), Dienste (Inline-Bearbeitung), Kategorien (natives
Drag & Drop), Scanner (FritzBox-Trigger + Sammel-Scan), Logs (Scan-Historie),
Einstellungen (Live-Systeminfo + Theme), Plugins (ehrlicher Hinweis auf
zukünftigen Commit)
Noch **nicht** enthalten (folgt in den nächsten Commits): Noch **nicht** enthalten (folgt in den nächsten Commits):
- Adminbereich mit "Jetzt scannen"-Button und Kategorien-Verwaltungsoberfläche - Plugin-System (Scanner registrieren, Geräte importieren, Menüs erweitern, Icons)
— beide Backend-APIs existieren bereits vollständig - PWA-Manifest / Offline-Grundfunktion
- TanStack Router, Plugin-System, PWA-Manifest, shadcn/ui, RHF - shadcn/ui, React Hook Form (aktuell einfache kontrollierte Formulare)
### Umgebungsvariablen (.env) ### Umgebungsvariablen (.env)
@@ -134,6 +139,23 @@ POST /api/scan/devices/:id Netzwerk-Scan für ein Gerät (DNS, Ports, Tit
Favicon, Softwareerkennung); legt/aktualisiert Dienste Favicon, Softwareerkennung); legt/aktualisiert Dienste
POST /api/scan/fritzbox Liest Geräteliste der FritzBox per TR-064 POST /api/scan/fritzbox Liest Geräteliste der FritzBox per TR-064
(erfordert FRITZBOX_HOST/USERNAME/PASSWORD) (erfordert FRITZBOX_HOST/USERNAME/PASSWORD)
GET /api/logs optional ?limit= (Default 100, Max 500)
```
## Frontend-Routen
```
/ Startseite: minimalistische Suche
/admin -> redirect zu /admin/dashboard
/admin/dashboard
/admin/devices
/admin/services
/admin/categories
/admin/scanner
/admin/plugins Hinweis: Plugin-System noch nicht gebaut
/admin/settings
/admin/logs
``` ```
## Deployment auf dem Server (xlc-launchpad) ## Deployment auf dem Server (xlc-launchpad)

View File

@@ -66,5 +66,14 @@ export function ensureSchema(): void {
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS scan_logs (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
target_id TEXT,
level TEXT NOT NULL,
message TEXT NOT NULL,
created_at TEXT NOT NULL
);
`); `);
} }

View File

@@ -0,0 +1,52 @@
import { randomUUID } from "node:crypto";
import { desc } from "drizzle-orm";
import type { ScanLogEntry } from "@launchpad/shared";
import { db } from "../client.js";
import { scanLogs } from "../schema.js";
function mapRow(row: typeof scanLogs.$inferSelect): ScanLogEntry {
return {
id: row.id,
type: row.type as ScanLogEntry["type"],
targetId: row.targetId,
level: row.level as ScanLogEntry["level"],
message: row.message,
createdAt: row.createdAt,
};
}
export interface LogScanInput {
type: ScanLogEntry["type"];
targetId?: string | null;
level: ScanLogEntry["level"];
message: string;
}
/** Schreibt einen Eintrag für einen Scan-Versuch (Erfolg oder Fehler). */
export function logScan(input: LogScanInput): ScanLogEntry {
const id = randomUUID();
const createdAt = new Date().toISOString();
db.insert(scanLogs)
.values({
id,
type: input.type,
targetId: input.targetId ?? null,
level: input.level,
message: input.message,
createdAt,
})
.run();
return { id, type: input.type, targetId: input.targetId ?? null, level: input.level, message: input.message, createdAt };
}
export function listLogs(limit = 100): ScanLogEntry[] {
return db
.select()
.from(scanLogs)
.orderBy(desc(scanLogs.createdAt))
.limit(limit)
.all()
.map(mapRow);
}

View File

@@ -58,3 +58,16 @@ export const categories = sqliteTable("categories", {
createdAt: text("created_at").notNull(), createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(), updatedAt: text("updated_at").notNull(),
}); });
/**
* Protokoll jedes Scan-Versuchs (Geräte-Netzwerk-Scan oder FritzBox-Scan),
* angezeigt im Adminbereich unter "Logs".
*/
export const scanLogs = sqliteTable("scan_logs", {
id: text("id").primaryKey(),
type: text("type").notNull(), // "device" | "fritzbox"
targetId: text("target_id"), // Geräte-ID bei type "device", sonst null
level: text("level").notNull(), // "info" | "error"
message: text("message").notNull(),
createdAt: text("created_at").notNull(),
});

View File

@@ -7,6 +7,7 @@ import { deviceRoutes } from "./routes/devices.js";
import { serviceRoutes } from "./routes/services.js"; import { serviceRoutes } from "./routes/services.js";
import { categoryRoutes } from "./routes/categories.js"; import { categoryRoutes } from "./routes/categories.js";
import { scanRoutes } from "./routes/scan.js"; import { scanRoutes } from "./routes/scan.js";
import { logRoutes } from "./routes/logs.js";
const PORT = Number(process.env.PORT ?? 3001); const PORT = Number(process.env.PORT ?? 3001);
const HOST = process.env.HOST ?? "0.0.0.0"; const HOST = process.env.HOST ?? "0.0.0.0";
@@ -33,6 +34,7 @@ async function main() {
await app.register(serviceRoutes); await app.register(serviceRoutes);
await app.register(categoryRoutes); await app.register(categoryRoutes);
await app.register(scanRoutes); await app.register(scanRoutes);
await app.register(logRoutes);
app.get("/", async () => { app.get("/", async () => {
return { name: "LaunchPad API", status: "running" }; return { name: "LaunchPad API", status: "running" };

View File

@@ -0,0 +1,10 @@
import type { FastifyInstance } from "fastify";
import * as logRepo from "../db/repositories/logs.js";
export async function logRoutes(app: FastifyInstance): Promise<void> {
app.get("/api/logs", async (request) => {
const query = request.query as { limit?: string };
const limit = query.limit ? Math.min(Number(query.limit), 500) : 100;
return logRepo.listLogs(limit);
});
}

View File

@@ -1,17 +1,17 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import * as deviceRepo from "../db/repositories/devices.js"; import * as deviceRepo from "../db/repositories/devices.js";
import * as serviceRepo from "../db/repositories/services.js"; import * as serviceRepo from "../db/repositories/services.js";
import * as logRepo from "../db/repositories/logs.js";
import { scanDeviceServices } from "../scanner/networkScanner.js"; import { scanDeviceServices } from "../scanner/networkScanner.js";
import { fetchFritzBoxHosts } from "../scanner/fritzbox.js"; import { fetchFritzBoxHosts } from "../scanner/fritzbox.js";
/** /**
* Scan-Endpunkte. Werden ausschließlich manuell per Knopfdruck ("Jetzt * Scan-Endpunkte. Werden ausschließlich manuell per Knopfdruck ("Jetzt
* scannen") aus der Admin-UI (Commit 6) ausgelöst es gibt keinerlei * scannen") aus der Admin-UI ausgelöst es gibt keinerlei automatischen/
* automatischen/zeitgesteuerten Scan. * zeitgesteuerten Scan. Jeder Versuch (Erfolg oder Fehler) wird in scan_logs
* protokolliert und ist unter Admin -> Logs einsehbar.
*/ */
export async function scanRoutes(app: FastifyInstance): Promise<void> { export async function scanRoutes(app: FastifyInstance): Promise<void> {
// Netzwerk-Scan für ein einzelnes, bereits bekanntes Gerät: DNS-Kandidaten,
// Port 80/443 + typische Ports, Titel/Favicon, Softwareerkennung.
app.post("/api/scan/devices/:id", async (request, reply) => { app.post("/api/scan/devices/:id", async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const device = deviceRepo.getDevice(id); const device = deviceRepo.getDevice(id);
@@ -19,6 +19,7 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: "Gerät nicht gefunden" }); return reply.code(404).send({ error: "Gerät nicht gefunden" });
} }
try {
const discovered = await scanDeviceServices(device); const discovered = await scanDeviceServices(device);
const results = discovered.map((found) => const results = discovered.map((found) =>
@@ -45,13 +46,34 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
source: device.source, source: device.source,
}); });
const created = results.filter((r) => r.created).length;
const updated = results.filter((r) => !r.created).length;
logRepo.logScan({
type: "device",
targetId: device.id,
level: "info",
message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden, ${created} neu, ${updated} aktualisiert`,
});
return { return {
deviceId: device.id, deviceId: device.id,
scannedPorts: discovered.length, scannedPorts: discovered.length,
created: results.filter((r) => r.created).length, created,
updated: results.filter((r) => !r.created).length, updated,
services: results.map((r) => r.service), services: results.map((r) => r.service),
}; };
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
logRepo.logScan({
type: "device",
targetId: device.id,
level: "error",
message: `${device.hostname} (${device.ip}): Scan fehlgeschlagen ${detail}`,
});
request.log.error(err);
return reply.code(500).send({ error: "Geräte-Scan fehlgeschlagen", detail });
}
}); });
// FritzBox-Scan: liest die Geräteliste per TR-064 und legt/aktualisiert Geräte. // FritzBox-Scan: liest die Geräteliste per TR-064 und legt/aktualisiert Geräte.
@@ -82,13 +104,23 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
source: "fritzbox", source: "fritzbox",
}) })
); );
logRepo.logScan({
type: "fritzbox",
level: "info",
message: `FritzBox-Scan: ${hosts.length} Gerät(e) gefunden`,
});
return { found: hosts.length, devices }; return { found: hosts.length, devices };
} catch (err) { } catch (err) {
request.log.error(err); const detail = err instanceof Error ? err.message : String(err);
return reply.code(502).send({ logRepo.logScan({
error: "FritzBox-Scan fehlgeschlagen", type: "fritzbox",
detail: err instanceof Error ? err.message : String(err), level: "error",
message: `FritzBox-Scan fehlgeschlagen ${detail}`,
}); });
request.log.error(err);
return reply.code(502).send({ error: "FritzBox-Scan fehlgeschlagen", detail });
} }
}); });
} }

View File

@@ -13,6 +13,7 @@
"@launchpad/shared": "workspace:*", "@launchpad/shared": "workspace:*",
"@launchpad/ui": "workspace:*", "@launchpad/ui": "workspace:*",
"@tanstack/react-query": "^5.51.23", "@tanstack/react-query": "^5.51.23",
"@tanstack/react-router": "^1.58.3",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },

View File

@@ -0,0 +1,34 @@
import { useEffect, useState } from "react";
import type { HealthStatus } from "@launchpad/shared";
export function useBackendHealth() {
const [health, setHealth] = useState<HealthStatus | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
async function check() {
try {
const res = await fetch("/api/health");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: HealthStatus = await res.json();
if (!cancelled) {
setHealth(data);
setError(false);
}
} catch {
if (!cancelled) setError(true);
}
}
check();
const interval = setInterval(check, 10_000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
return { health, error };
}

View File

@@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import type { Category } from "@launchpad/shared";
async function fetchCategories(): Promise<Category[]> {
const res = await fetch("/api/categories");
if (!res.ok) {
throw new Error(`Kategorien konnten nicht geladen werden (HTTP ${res.status})`);
}
return res.json();
}
export function useCategories() {
return useQuery({
queryKey: ["categories"],
queryFn: fetchCategories,
});
}

View File

@@ -0,0 +1,21 @@
import { useQuery } from "@tanstack/react-query";
import type { Device, Service } from "@launchpad/shared";
export interface DeviceWithServices extends Device {
services: Service[];
}
async function fetchDevices(): Promise<DeviceWithServices[]> {
const res = await fetch("/api/devices");
if (!res.ok) {
throw new Error(`Geräte konnten nicht geladen werden (HTTP ${res.status})`);
}
return res.json();
}
export function useDevices() {
return useQuery({
queryKey: ["devices"],
queryFn: fetchDevices,
});
}

View File

@@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import type { ScanLogEntry } from "@launchpad/shared";
async function fetchLogs(): Promise<ScanLogEntry[]> {
const res = await fetch("/api/logs");
if (!res.ok) {
throw new Error(`Logs konnten nicht geladen werden (HTTP ${res.status})`);
}
return res.json();
}
export function useLogs() {
return useQuery({
queryKey: ["logs"],
queryFn: fetchLogs,
refetchInterval: 5000,
});
}

View File

@@ -0,0 +1,22 @@
import { useEffect, useState } from "react";
export type Theme = "light" | "dark";
export function useTheme(): [Theme, () => void] {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window === "undefined") return "dark";
const stored = window.localStorage.getItem("launchpad-theme");
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
});
useEffect(() => {
document.documentElement.classList.toggle("dark", theme === "dark");
window.localStorage.setItem("launchpad-theme", theme);
}, [theme]);
const toggle = () => setTheme((t) => (t === "dark" ? "light" : "dark"));
return [theme, toggle];
}

View File

@@ -1,7 +1,8 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App.js"; import { RouterProvider } from "@tanstack/react-router";
import { router } from "./router.js";
import "./index.css"; import "./index.css";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -16,7 +17,7 @@ const queryClient = new QueryClient({
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<App /> <RouterProvider router={router} />
</QueryClientProvider> </QueryClientProvider>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -0,0 +1,107 @@
import { Outlet, createRootRoute, createRoute, createRouter, redirect } from "@tanstack/react-router";
import { HomePage } from "./routes/HomePage.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 { CategoriesPage } from "./routes/admin/CategoriesPage.js";
import { ScannerPage } from "./routes/admin/ScannerPage.js";
import { PluginsPage } from "./routes/admin/PluginsPage.js";
import { SettingsPage } from "./routes/admin/SettingsPage.js";
import { LogsPage } from "./routes/admin/LogsPage.js";
const rootRoute = createRootRoute({
component: () => <Outlet />,
});
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: HomePage,
});
const adminRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/admin",
component: AdminLayout,
});
// /admin ohne weiteren Pfad -> Dashboard
const adminIndexRoute = createRoute({
getParentRoute: () => adminRoute,
path: "/",
loader: () => {
throw redirect({ to: "/admin/dashboard" });
},
});
const adminDashboardRoute = createRoute({
getParentRoute: () => adminRoute,
path: "/dashboard",
component: DashboardPage,
});
const adminDevicesRoute = createRoute({
getParentRoute: () => adminRoute,
path: "/devices",
component: DevicesPage,
});
const adminServicesRoute = createRoute({
getParentRoute: () => adminRoute,
path: "/services",
component: ServicesPage,
});
const adminCategoriesRoute = createRoute({
getParentRoute: () => adminRoute,
path: "/categories",
component: CategoriesPage,
});
const adminScannerRoute = createRoute({
getParentRoute: () => adminRoute,
path: "/scanner",
component: ScannerPage,
});
const adminPluginsRoute = createRoute({
getParentRoute: () => adminRoute,
path: "/plugins",
component: PluginsPage,
});
const adminSettingsRoute = createRoute({
getParentRoute: () => adminRoute,
path: "/settings",
component: SettingsPage,
});
const adminLogsRoute = createRoute({
getParentRoute: () => adminRoute,
path: "/logs",
component: LogsPage,
});
const routeTree = rootRoute.addChildren([
indexRoute,
adminRoute.addChildren([
adminIndexRoute,
adminDashboardRoute,
adminDevicesRoute,
adminServicesRoute,
adminCategoriesRoute,
adminScannerRoute,
adminPluginsRoute,
adminSettingsRoute,
adminLogsRoute,
]),
]);
export const router = createRouter({ routeTree });
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}

View File

@@ -1,61 +1,11 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { SearchInput, StatusBadge, ResultsList } from "@launchpad/ui"; import { SearchInput, StatusBadge, ResultsList } from "@launchpad/ui";
import { rankServices, type Service, type HealthStatus } from "@launchpad/shared"; import { rankServices, type Service } from "@launchpad/shared";
import { useServices } from "./hooks/useServices.js"; import { useServices } from "../hooks/useServices.js";
import { useBackendHealth } from "../hooks/useBackendHealth.js";
type Theme = "light" | "dark"; import { useTheme } from "../hooks/useTheme.js";
function useTheme(): [Theme, () => void] {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window === "undefined") return "dark";
const stored = window.localStorage.getItem("launchpad-theme");
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
});
useEffect(() => {
document.documentElement.classList.toggle("dark", theme === "dark");
window.localStorage.setItem("launchpad-theme", theme);
}, [theme]);
const toggle = () => setTheme((t) => (t === "dark" ? "light" : "dark"));
return [theme, toggle];
}
function useBackendHealth() {
const [health, setHealth] = useState<HealthStatus | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
async function check() {
try {
const res = await fetch("/api/health");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: HealthStatus = await res.json();
if (!cancelled) {
setHealth(data);
setError(false);
}
} catch {
if (!cancelled) setError(true);
}
}
check();
const interval = setInterval(check, 10_000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
return { health, error };
}
function openService(service: Service) { function openService(service: Service) {
window.open(service.url, "_blank", "noopener,noreferrer"); window.open(service.url, "_blank", "noopener,noreferrer");
@@ -73,7 +23,7 @@ async function toggleServiceFavorite(service: Service): Promise<Service> {
return res.json(); return res.json();
} }
export default function App() { export function HomePage() {
const [theme, toggleTheme] = useTheme(); const [theme, toggleTheme] = useTheme();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
@@ -136,14 +86,24 @@ export default function App() {
return ( 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="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">
<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"
>
</Link>
<button <button
onClick={toggleTheme} onClick={toggleTheme}
aria-label="Theme wechseln" aria-label="Theme wechseln"
className="fixed right-6 top-6 rounded-full border border-black/10 p-2 text-black/60 className="rounded-full border border-black/10 p-2 text-black/60 transition-colors
transition-colors hover:bg-black/5 dark:border-white/10 dark:text-white/60 dark:hover:bg-white/5" hover:bg-black/5 dark:border-white/10 dark:text-white/60 dark:hover:bg-white/5"
> >
{theme === "dark" ? "☀️" : "🌙"} {theme === "dark" ? "☀️" : "🌙"}
</button> </button>
</div>
<div className="flex flex-col items-center gap-2 text-center"> <div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-4xl font-semibold tracking-tight text-black dark:text-white"> <h1 className="text-4xl font-semibold tracking-tight text-black dark:text-white">
@@ -181,7 +141,7 @@ export default function App() {
onToggleFavorite={(service) => toggleFavorite.mutate(service)} onToggleFavorite={(service) => toggleFavorite.mutate(service)}
emptyLabel={ emptyLabel={
(services?.length ?? 0) === 0 (services?.length ?? 0) === 0
? "Noch keine Dienste angelegt. Füge welche über die API hinzu." ? "Noch keine Dienste angelegt. Füge welche im Adminbereich hinzu."
: "Keine Treffer für deine Suche." : "Keine Treffer für deine Suche."
} }
/> />

View File

@@ -0,0 +1,64 @@
import { Link, Outlet, useRouterState } from "@tanstack/react-router";
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;
export function AdminLayout() {
const pathname = useRouterState({ select: (s) => s.location.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">
LaunchPad
</Link>
</div>
<nav className="flex flex-1 flex-col gap-0.5 p-3">
{NAV_ITEMS.map((item) => {
const active = pathname.startsWith(item.to);
return (
<Link
key={item.to}
to={item.to}
className={`flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
active
? "bg-black/5 text-black dark:bg-white/10 dark:text-white"
: "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>
{item.label}
</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>
</aside>
<main className="flex-1 overflow-y-auto p-8">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
export interface AdminPageHeaderProps {
title: string;
description?: string;
actions?: ReactNode;
}
export function AdminPageHeader({ title, description, actions }: AdminPageHeaderProps) {
return (
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-black dark:text-white">{title}</h1>
{description ? (
<p className="mt-1 text-sm text-black/50 dark:text-white/50">{description}</p>
) : null}
</div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,243 @@
import { useState, type DragEvent, type FormEvent } from "react";
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> {
const res = await fetch("/api/categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `Kategorie konnte nicht angelegt werden (HTTP ${res.status})`);
}
return res.json();
}
async function renameCategory(id: string, name: string): Promise<Category> {
const res = await fetch(`/api/categories/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) {
throw new Error(`Kategorie konnte nicht umbenannt werden (HTTP ${res.status})`);
}
return res.json();
}
async function deleteCategoryRequest(id: string) {
const res = await fetch(`/api/categories/${id}`, { method: "DELETE" });
if (!res.ok && res.status !== 404) {
throw new Error(`Kategorie konnte nicht gelöscht werden (HTTP ${res.status})`);
}
}
async function reorderCategories(entries: { id: string; order: number }[]) {
const res = await fetch("/api/categories/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();
}
function CategoryRow({
category,
onDragStart,
onDragOver,
onDrop,
isDragging,
}: {
category: Category;
onDragStart: (e: DragEvent<HTMLLIElement>) => void;
onDragOver: (e: DragEvent<HTMLLIElement>) => void;
onDrop: (e: DragEvent<HTMLLIElement>) => void;
isDragging: boolean;
}) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [name, setName] = useState(category.name);
const renameMutation = useMutation({
mutationFn: () => renameCategory(category.id, name.trim()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
setEditing(false);
},
});
const deleteMutation = useMutation({
mutationFn: () => deleteCategoryRequest(category.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
queryClient.invalidateQueries({ queryKey: ["services"] });
},
});
return (
<li
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
data-category-id={category.id}
className={`flex items-center gap-3 border-b border-black/5 px-4 py-3 last:border-0
dark:border-white/5 ${isDragging ? "opacity-40" : ""}`}
>
<span className="cursor-grab select-none text-black/30 dark:text-white/30" aria-hidden>
</span>
{editing ? (
<>
<input
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
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>
</>
) : (
<>
<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>
</>
)}
</li>
);
}
export function CategoriesPage() {
const { data: categories, isLoading, isError } = useCategories();
const queryClient = useQueryClient();
const [newName, setNewName] = useState("");
const [draggedId, setDraggedId] = useState<string | null>(null);
const [localOrder, setLocalOrder] = useState<Category[] | null>(null);
const createMutation = useMutation({
mutationFn: () => createCategory(newName.trim()),
onSuccess: () => {
setNewName("");
queryClient.invalidateQueries({ queryKey: ["categories"] });
},
});
const reorderMutation = useMutation({
mutationFn: reorderCategories,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
setLocalOrder(null);
},
onError: () => setLocalOrder(null),
});
const list = localOrder ?? categories ?? [];
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
createMutation.mutate();
}
function handleDragStart(id: string) {
return (_e: DragEvent<HTMLLIElement>) => setDraggedId(id);
}
function handleDragOver(targetId: string) {
return (e: DragEvent<HTMLLIElement>) => {
e.preventDefault();
if (!draggedId || draggedId === targetId) return;
const current = localOrder ?? categories ?? [];
const fromIndex = current.findIndex((c) => c.id === draggedId);
const toIndex = current.findIndex((c) => c.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<HTMLLIElement>) => {
e.preventDefault();
setDraggedId(null);
const current = localOrder ?? categories ?? [];
reorderMutation.mutate(current.map((c, index) => ({ id: c.id, order: index })));
};
}
return (
<div>
<AdminPageHeader
title="Kategorien"
description="Per Drag & Drop sortieren. Favoriten erscheinen in der Suche trotzdem immer zuerst."
/>
<form onSubmit={handleSubmit} className="mb-6 flex items-end gap-2">
<div>
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
Neue Kategorie
</label>
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="z. B. Medien"
className="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={createMutation.isPending}>
Anlegen
</Button>
{createMutation.isError ? (
<span className="text-xs text-red-500">{(createMutation.error as Error).message}</span>
) : null}
</form>
{isLoading ? (
<p className="text-sm text-black/40 dark:text-white/40">Lade Kategorien </p>
) : isError ? (
<p className="text-sm text-red-500">Kategorien konnten nicht geladen werden.</p>
) : list.length > 0 ? (
<ul className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
{list.map((category) => (
<CategoryRow
key={category.id}
category={category}
isDragging={draggedId === category.id}
onDragStart={handleDragStart(category.id)}
onDragOver={handleDragOver(category.id)}
onDrop={handleDrop()}
/>
))}
</ul>
) : (
<p className="text-sm text-black/40 dark:text-white/40">Noch keine Kategorien angelegt.</p>
)}
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { useServices } from "../../hooks/useServices.js";
import { useDevices } from "../../hooks/useDevices.js";
import { useCategories } from "../../hooks/useCategories.js";
import { AdminPageHeader } from "./AdminPageHeader.js";
function StatCard({ label, value }: { label: string; value: number | string }) {
return (
<div className="rounded-2xl border border-black/10 bg-white p-5 dark:border-white/10 dark:bg-white/5">
<div className="text-3xl font-semibold text-black dark:text-white">{value}</div>
<div className="mt-1 text-sm text-black/50 dark:text-white/50">{label}</div>
</div>
);
}
export function DashboardPage() {
const { data: services } = useServices();
const { data: devices } = useDevices();
const { data: categories } = useCategories();
const onlineDevices = devices?.filter((d) => d.online).length ?? 0;
const favoriteServices = services?.filter((s) => s.favorite).length ?? 0;
const recentlyScanned = [...(devices ?? [])]
.filter((d) => d.lastScan)
.sort((a, b) => (b.lastScan ?? "").localeCompare(a.lastScan ?? ""))
.slice(0, 5);
return (
<div>
<AdminPageHeader
title="Dashboard"
description="Überblick über dein Homelab."
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<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} />
</div>
<div className="mt-8">
<h2 className="mb-3 text-sm font-semibold text-black/60 dark:text-white/60">
Zuletzt gescannte Geräte
</h2>
{recentlyScanned.length === 0 ? (
<p className="text-sm text-black/40 dark:text-white/40">
Noch keine Scans durchgeführt. Starte einen Scan unter Geräte oder Scanner.
</p>
) : (
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
<table className="w-full text-sm">
<tbody>
{recentlyScanned.map((device) => (
<tr
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">
{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">
{device.lastScan ? new Date(device.lastScan).toLocaleString("de-DE") : ""}
</td>
</tr>
))}
</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>
);
}

View File

@@ -0,0 +1,220 @@
import { useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@launchpad/ui";
import { useDevices, type DeviceWithServices } from "../../hooks/useDevices.js";
import { AdminPageHeader } from "./AdminPageHeader.js";
async function createDevice(input: { hostname: string; ip: string }) {
const res = await fetch("/api/devices", {
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 ?? `Gerät konnte nicht angelegt 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) {
throw new Error(`Gerät konnte nicht gelöscht werden (HTTP ${res.status})`);
}
}
interface ScanResult {
scannedPorts: number;
created: number;
updated: number;
}
async function scanDevice(id: string): Promise<ScanResult> {
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST" });
const body = await res.json();
if (!res.ok) {
throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`);
}
return body;
}
function DeviceRow({ device }: { device: DeviceWithServices }) {
const queryClient = useQueryClient();
const [scanMessage, setScanMessage] = useState<string | null>(null);
const scanMutation = useMutation({
mutationFn: () => scanDevice(device.id),
onSuccess: (result) => {
setScanMessage(
`${result.scannedPorts} Port(s) offen · ${result.created} neu · ${result.updated} aktualisiert`
);
queryClient.invalidateQueries({ queryKey: ["devices"] });
queryClient.invalidateQueries({ queryKey: ["services"] });
},
onError: (err: Error) => setScanMessage(err.message),
});
const deleteMutation = useMutation({
mutationFn: () => deleteDevice(device.id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["devices"] }),
});
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>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 text-xs ${
device.online ? "text-emerald-600 dark:text-emerald-400" : "text-black/40 dark:text-white/40"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${device.online ? "bg-emerald-500" : "bg-black/20 dark:bg-white/20"}`}
/>
{device.online ? "Online" : "Offline"}
</span>
</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="sm"
onClick={() => {
setScanMessage(null);
scanMutation.mutate();
}}
disabled={scanMutation.isPending}
>
{scanMutation.isPending ? "Scanne …" : "Jetzt scannen"}
</Button>
<Button
size="sm"
variant="danger"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
>
Löschen
</Button>
</div>
</td>
</tr>
);
}
function AddDeviceForm() {
const queryClient = useQueryClient();
const [hostname, setHostname] = useState("");
const [ip, setIp] = useState("");
const mutation = useMutation({
mutationFn: createDevice,
onSuccess: () => {
setHostname("");
setIp("");
queryClient.invalidateQueries({ queryKey: ["devices"] });
},
});
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!hostname.trim() || !ip.trim()) return;
mutation.mutate({ hostname: hostname.trim(), ip: ip.trim() });
}
return (
<form onSubmit={handleSubmit} className="flex items-end gap-2">
<div>
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
Hostname
</label>
<input
value={hostname}
onChange={(e) => setHostname(e.target.value)}
placeholder="z. B. synology"
className="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">
IP-Adresse
</label>
<input
value={ip}
onChange={(e) => setIp(e.target.value)}
placeholder="192.168.1.10"
className="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}>
Gerät hinzufügen
</Button>
{mutation.isError ? (
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
) : null}
</form>
);
}
export function DevicesPage() {
const { data: devices, isLoading, isError } = useDevices();
return (
<div>
<AdminPageHeader
title="Geräte"
description="Alle bekannten Geräte in deinem Netzwerk. Scans laufen nur auf Knopfdruck."
/>
<div className="mb-6">
<AddDeviceForm />
</div>
{isLoading ? (
<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 ? (
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
<table className="w-full 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>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody>
{devices.map((device) => (
<DeviceRow key={device.id} device={device} />
))}
</tbody>
</table>
</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
unter Scanner.
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { useLogs } from "../../hooks/useLogs.js";
import { AdminPageHeader } from "./AdminPageHeader.js";
export function LogsPage() {
const { data: logs, isLoading, isError } = useLogs();
return (
<div>
<AdminPageHeader
title="Logs"
description="Protokoll aller Scan-Versuche (Geräte-Scan und FritzBox-Scan)."
/>
{isLoading ? (
<p className="text-sm text-black/40 dark:text-white/40">Lade Logs </p>
) : isError ? (
<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">
<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">Zeit</th>
<th className="px-4 py-2 font-medium">Typ</th>
<th className="px-4 py-2 font-medium">Nachricht</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id} className="border-b border-black/5 last:border-0 dark:border-white/5">
<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">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium ${
log.level === "error"
? "bg-red-500/10 text-red-600 dark:text-red-400"
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
}`}
>
{log.type === "fritzbox" ? "FritzBox" : "Gerät"}
</span>
</td>
<td className="px-4 py-3 text-black/70 dark:text-white/70">{log.message}</td>
</tr>
))}
</tbody>
</table>
</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".
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { AdminPageHeader } from "./AdminPageHeader.js";
export function PluginsPage() {
return (
<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>
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@launchpad/ui";
import { useDevices } from "../../hooks/useDevices.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 };
}
async function scanDeviceById(id: string) {
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST" });
const body = await res.json();
if (!res.ok) {
throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`);
}
return body as { created: number; updated: number };
}
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) => {
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)`);
} catch {
// einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen
}
}
setBulkStatus(`Fertig: ${devices.length} Gerät(e) gescannt, ${created} neue Dienste, ${updated} aktualisiert.`);
setBulkRunning(false);
queryClient.invalidateQueries({ queryKey: ["devices"] });
queryClient.invalidateQueries({ queryKey: ["services"] });
queryClient.invalidateQueries({ queryKey: ["logs"] });
}
return (
<div>
<AdminPageHeader
title="Scanner"
description="Scans laufen ausschließlich manuell nie automatisch oder zeitgesteuert."
/>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<h2 className="font-medium text-black dark:text-white">FritzBox-Scan</h2>
<p className="mt-1 text-sm text-black/50 dark:text-white/50">
Liest die Geräteliste der FritzBox per TR-064 und legt/aktualisiert Geräte.
Erfordert <code className="rounded bg-black/5 px-1 dark:bg-white/10">FRITZBOX_HOST</code>,{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">FRITZBOX_USERNAME</code> und{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">FRITZBOX_PASSWORD</code> in
der <code className="rounded bg-black/5 px-1 dark:bg-white/10">.env</code>.
</p>
<Button
variant="primary"
className="mt-4"
onClick={() => fritzboxMutation.mutate()}
disabled={fritzboxMutation.isPending}
>
{fritzboxMutation.isPending ? "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.
</p>
) : null}
{fritzboxMutation.isError ? (
<p className="mt-2 text-sm text-red-500">{(fritzboxMutation.error as Error).message}</p>
) : null}
</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.
</p>
<Button
variant="primary"
className="mt-4"
onClick={scanAllDevices}
disabled={bulkRunning || !devices || devices.length === 0}
>
{bulkRunning ? "Scanne …" : "Alle Geräte jetzt scannen"}
</Button>
{bulkStatus ? (
<p className="mt-2 text-sm text-black/50 dark:text-white/50">{bulkStatus}</p>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,218 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@launchpad/ui";
import type { Service } from "@launchpad/shared";
import { useServices } from "../../hooks/useServices.js";
import { AdminPageHeader } from "./AdminPageHeader.js";
interface ServicePatch {
displayName?: string;
category?: string | null;
alias?: string[];
order?: number;
favorite?: boolean;
}
async function patchService(id: string, patch: ServicePatch): Promise<Service> {
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();
}
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})`);
}
}
function EditForm({ service, onDone }: { service: Service; onDone: () => void }) {
const queryClient = useQueryClient();
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 mutation = useMutation({
mutationFn: () =>
patchService(service.id, {
displayName: displayName.trim(),
category: category.trim() || null,
alias: alias
.split(",")
.map((a) => a.trim())
.filter(Boolean),
order: Number(order) || 0,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["services"] });
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">
<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">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"
/>
</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-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">Reihenfolge</label>
<input
value={order}
onChange={(e) => setOrder(e.target.value)}
type="number"
className="w-20 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>
<Button size="sm" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending}>
Speichern
</Button>
<Button size="sm" variant="ghost" onClick={onDone}>
Abbrechen
</Button>
</div>
</td>
</tr>
);
}
function ServiceRow({ service }: { service: Service }) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const favoriteMutation = useMutation({
mutationFn: () => patchService(service.id, { favorite: !service.favorite }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
});
const deleteMutation = useMutation({
mutationFn: () => deleteServiceRequest(service.id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
});
if (editing) {
return <EditForm service={service} onDone={() => setEditing(false)} />;
}
return (
<tr className="border-b border-black/5 last:border-0 dark:border-white/5">
<td className="px-4 py-3">
<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"}`}
>
</button>
</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>
</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">
<a
href={service.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="sm" onClick={() => setEditing(true)}>
Bearbeiten
</Button>
<Button size="sm" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending}>
Löschen
</Button>
</div>
</td>
</tr>
);
}
export function ServicesPage() {
const { data: services, isLoading, isError } = useServices();
return (
<div>
<AdminPageHeader
title="Dienste"
description="Name, Kategorie, Alias und Reihenfolge bleiben bei erneuten Scans erhalten."
/>
{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 ? (
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
<table className="w-full 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-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} />
))}
</tbody>
</table>
</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.
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { useBackendHealth } from "../../hooks/useBackendHealth.js";
import { useTheme } from "../../hooks/useTheme.js";
import { AdminPageHeader } from "./AdminPageHeader.js";
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">
<span className="text-sm text-black/50 dark:text-white/50">{label}</span>
<span className="text-sm font-medium text-black dark:text-white">{value}</span>
</div>
);
}
export function SettingsPage() {
const { health, error } = useBackendHealth();
const [theme, toggleTheme] = useTheme();
return (
<div>
<AdminPageHeader title="Einstellungen" />
<div className="max-w-lg space-y-6">
<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">
<span className="text-sm text-black/50 dark:text-white/50">Theme</span>
<button
onClick={toggleTheme}
className="rounded-lg border border-black/10 px-3 py-1.5 text-sm text-black
transition-colors hover:bg-black/5 dark:border-white/10 dark:text-white
dark:hover:bg-white/10"
>
{theme === "dark" ? "🌙 Dunkel" : "☀️ Hell"} wechseln
</button>
</div>
</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">Backend</h2>
{error ? (
<p className="text-sm text-red-500">Backend nicht erreichbar.</p>
) : health ? (
<div>
<InfoRow label="Status" value={health.status} />
<InfoRow label="Version" value={health.version} />
<InfoRow label="Läuft seit" value={`${health.uptimeSeconds}s`} />
</div>
) : (
<p className="text-sm text-black/40 dark:text-white/40">Lade </p>
)}
</div>
</div>
</div>
);
}

View File

@@ -50,10 +50,27 @@ Grundgerüst aus Commit 1.
> FritzBox-SOAP-/Digest-Auth-Ablauf inkl. Fehlerfall bei falschem Passwort). > FritzBox-SOAP-/Digest-Auth-Ablauf inkl. Fehlerfall bei falschem Passwort).
> Ein Test gegen eine echte FritzBox war in dieser Umgebung nicht möglich. > Ein Test gegen eine echte FritzBox war in dieser Umgebung nicht möglich.
## Commit 6 — Adminbereich ## Commit 6 — Adminbereich (erledigt)
- Menüpunkte: Dashboard, Geräte, Dienste, Scanner, Kategorien, Plugins, - TanStack Router (code-basiert): `/` (Startseite) + `/admin/*`
Einstellungen, Logs - Menüpunkte wie spezifiziert: Dashboard, Geräte, Dienste, Scanner, Kategorien,
Plugins, Einstellungen, Logs
- Geräte-Seite: anlegen, löschen, **„Jetzt scannen"-Button** pro Gerät
- Dienste-Seite: Inline-Bearbeitung von Name/Kategorie/Alias/Reihenfolge, Favoriten-Toggle
- Kategorien-Seite: anlegen, umbenennen, löschen, **natives Drag & Drop** (kein
zusätzliches Package nötig)
- Scanner-Seite: FritzBox-Trigger + Sammel-Scan aller Geräte
- Logs-Seite: Scan-Historie (neue `scan_logs`-Tabelle, jeder Scan-Versuch wird protokolliert)
- Einstellungen: Live-Backend-Status + Theme-Umschalter
- Plugins: ehrlicher Hinweis, dass das Plugin-System noch nicht existiert
(statt einer vorgetäuschten Funktion)
> Verifiziert per Docker-Build-Simulation (Frontend + Backend inkl.
> `pnpm deploy --prod`) und Backend-Smoke-Test aller von den Admin-Seiten
> verwendeten Endpunkte. Die UI selbst konnte in dieser Umgebung nicht in
> einem echten Browser durchgeklickt werden (kein Browser-Tool verfügbar)
> Verifikation stützt sich auf erfolgreichen TypeScript-/Vite-Build und
> Codeprüfung.
## Commit 7 — PWA ## Commit 7 — PWA

View File

@@ -52,6 +52,15 @@ export interface Category {
order: number; order: number;
} }
export interface ScanLogEntry {
id: string;
type: "device" | "fritzbox";
targetId: string | null;
level: "info" | "error";
message: string;
createdAt: string;
}
/** /**
* Ranking-Stufen für die Suche, gemäß Spezifikation: * Ranking-Stufen für die Suche, gemäß Spezifikation:
* 1. Displayname beginnt mit Suchtext * 1. Displayname beginnt mit Suchtext

View File

@@ -0,0 +1,38 @@
import { forwardRef, type ButtonHTMLAttributes } from "react";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md";
}
const VARIANT_CLASSES: Record<NonNullable<ButtonProps["variant"]>, string> = {
primary:
"bg-black text-white hover:bg-black/80 dark:bg-white dark:text-black dark:hover:bg-white/80",
secondary:
"bg-black/5 text-black hover:bg-black/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/20",
danger: "bg-red-500/10 text-red-600 hover:bg-red-500/20 dark:text-red-400",
ghost:
"bg-transparent text-black/60 hover:bg-black/5 dark:text-white/60 dark:hover:bg-white/10",
};
const SIZE_CLASSES: Record<NonNullable<ButtonProps["size"]>, string> = {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-sm",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = "secondary", size = "md", className = "", disabled, ...props }, ref) => {
return (
<button
ref={ref}
disabled={disabled}
className={`inline-flex items-center justify-center gap-2 rounded-lg font-medium
transition-colors disabled:cursor-not-allowed disabled:opacity-50
${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]} ${className}`}
{...props}
/>
);
}
);
Button.displayName = "Button";

View File

@@ -6,3 +6,6 @@ export type { StatusBadgeProps } from "./StatusBadge.js";
export { ResultsList } from "./ResultsList.js"; export { ResultsList } from "./ResultsList.js";
export type { ResultsListProps } from "./ResultsList.js"; export type { ResultsListProps } from "./ResultsList.js";
export { Button } from "./Button.js";
export type { ButtonProps } from "./Button.js";

90
pnpm-lock.yaml generated
View File

@@ -63,6 +63,9 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.51.23 specifier: ^5.51.23
version: 5.101.2(react@18.3.1) version: 5.101.2(react@18.3.1)
'@tanstack/react-router':
specifier: ^1.58.3
version: 1.170.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: react:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1 version: 18.3.1
@@ -959,6 +962,10 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@tanstack/history@1.162.0':
resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==}
engines: {node: '>=20.19'}
'@tanstack/query-core@5.101.2': '@tanstack/query-core@5.101.2':
resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==}
@@ -967,6 +974,26 @@ packages:
peerDependencies: peerDependencies:
react: ^18 || ^19 react: ^18 || ^19
'@tanstack/react-router@1.170.18':
resolution: {integrity: sha512-wpbGYZEp/fmz1q4bn7BD8VZ+/VZ7GBqSJv5V969pU+chP8y7dquWDmKTFMohvUegb9lg12m1uPVvD6kB2wORvQ==}
engines: {node: '>=20.19'}
peerDependencies:
react: '>=18.0.0 || >=19.0.0'
react-dom: '>=18.0.0 || >=19.0.0'
'@tanstack/react-store@0.9.3':
resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/router-core@1.171.15':
resolution: {integrity: sha512-IILCDcLaItMZQ2jEmCABHY1Nhjjn5XUvwpQp3e4Nmu+vfg0BgYFuu/QASz2SwE2ZNbVMrvt8X/wxa+Gg5aErxA==}
engines: {node: '>=20.19'}
'@tanstack/store@0.9.3':
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -1118,6 +1145,9 @@ packages:
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie-es@3.1.1:
resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==}
cookie@0.7.2: cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -1444,6 +1474,10 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'} engines: {node: '>=0.12.0'}
isbot@5.2.1:
resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==}
engines: {node: '>=18'}
jiti@1.21.7: jiti@1.21.7:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true hasBin: true
@@ -1757,6 +1791,16 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
seroval-plugins@1.5.5:
resolution: {integrity: sha512-+BDhqYM6CEn3x09v44dpa9p6974FuUB2dxk+Ctn04k0cO1Zt6QODTXfmEZK0eBaTe/fJBvP4NMGuNJ+R8T+QMg==}
engines: {node: '>=10'}
peerDependencies:
seroval: ^1.0
seroval@1.5.5:
resolution: {integrity: sha512-bSjOuPcwPKLSJNhr9+bZxA20nQxVle5J5MNsYRVE6cIg7KpRLXGupymePavu0jrxlPiPsr4xGZSB8yUY2sH2sw==}
engines: {node: '>=10'}
set-cookie-parser@2.7.2: set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
@@ -1863,6 +1907,11 @@ packages:
peerDependencies: peerDependencies:
browserslist: '>= 4.21.0' browserslist: '>= 4.21.0'
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -2447,6 +2496,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.62.2': '@rollup/rollup-win32-x64-msvc@4.62.2':
optional: true optional: true
'@tanstack/history@1.162.0': {}
'@tanstack/query-core@5.101.2': {} '@tanstack/query-core@5.101.2': {}
'@tanstack/react-query@5.101.2(react@18.3.1)': '@tanstack/react-query@5.101.2(react@18.3.1)':
@@ -2454,6 +2505,31 @@ snapshots:
'@tanstack/query-core': 5.101.2 '@tanstack/query-core': 5.101.2
react: 18.3.1 react: 18.3.1
'@tanstack/react-router@1.170.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/history': 1.162.0
'@tanstack/react-store': 0.9.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tanstack/router-core': 1.171.15
isbot: 5.2.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@tanstack/react-store@0.9.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/store': 0.9.3
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
use-sync-external-store: 1.6.0(react@18.3.1)
'@tanstack/router-core@1.171.15':
dependencies:
'@tanstack/history': 1.162.0
cookie-es: 3.1.1
seroval: 1.5.5
seroval-plugins: 1.5.5(seroval@1.5.5)
'@tanstack/store@0.9.3': {}
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
dependencies: dependencies:
'@babel/parser': 7.29.7 '@babel/parser': 7.29.7
@@ -2623,6 +2699,8 @@ snapshots:
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
cookie-es@3.1.1: {}
cookie@0.7.2: {} cookie@0.7.2: {}
cssesc@3.0.0: {} cssesc@3.0.0: {}
@@ -2928,6 +3006,8 @@ snapshots:
is-number@7.0.0: {} is-number@7.0.0: {}
isbot@5.2.1: {}
jiti@1.21.7: {} jiti@1.21.7: {}
joycon@3.1.1: {} joycon@3.1.1: {}
@@ -3246,6 +3326,12 @@ snapshots:
semver@7.8.5: {} semver@7.8.5: {}
seroval-plugins@1.5.5(seroval@1.5.5):
dependencies:
seroval: 1.5.5
seroval@1.5.5: {}
set-cookie-parser@2.7.2: {} set-cookie-parser@2.7.2: {}
simple-concat@1.0.1: {} simple-concat@1.0.1: {}
@@ -3379,6 +3465,10 @@ snapshots:
escalade: 3.2.0 escalade: 3.2.0
picocolors: 1.1.1 picocolors: 1.1.1
use-sync-external-store@1.6.0(react@18.3.1):
dependencies:
react: 18.3.1
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
vite@5.4.21(@types/node@20.19.43): vite@5.4.21(@types/node@20.19.43):