diff --git a/README.md b/README.md index a42a978..0fda7b8 100644 --- a/README.md +++ b/README.md @@ -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. Benutzeränderungen an Diensten (Name, Kategorie, Favorit, Alias, Icon, Reihenfolge) 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): -- Adminbereich mit "Jetzt scannen"-Button und Kategorien-Verwaltungsoberfläche - — beide Backend-APIs existieren bereits vollständig -- TanStack Router, Plugin-System, PWA-Manifest, shadcn/ui, RHF +- Plugin-System (Scanner registrieren, Geräte importieren, Menüs erweitern, Icons) +- PWA-Manifest / Offline-Grundfunktion +- shadcn/ui, React Hook Form (aktuell einfache kontrollierte Formulare) ### 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 POST /api/scan/fritzbox Liest Geräteliste der FritzBox per TR-064 (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) diff --git a/apps/backend/src/db/client.ts b/apps/backend/src/db/client.ts index bb899ba..506f6f5 100644 --- a/apps/backend/src/db/client.ts +++ b/apps/backend/src/db/client.ts @@ -66,5 +66,14 @@ export function ensureSchema(): void { created_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 + ); `); } diff --git a/apps/backend/src/db/repositories/logs.ts b/apps/backend/src/db/repositories/logs.ts new file mode 100644 index 0000000..2d502cc --- /dev/null +++ b/apps/backend/src/db/repositories/logs.ts @@ -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); +} diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 1977d6e..d2102fa 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -58,3 +58,16 @@ export const categories = sqliteTable("categories", { createdAt: text("created_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(), +}); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 0f18d9e..eae7b49 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -7,6 +7,7 @@ import { deviceRoutes } from "./routes/devices.js"; import { serviceRoutes } from "./routes/services.js"; import { categoryRoutes } from "./routes/categories.js"; import { scanRoutes } from "./routes/scan.js"; +import { logRoutes } from "./routes/logs.js"; const PORT = Number(process.env.PORT ?? 3001); const HOST = process.env.HOST ?? "0.0.0.0"; @@ -33,6 +34,7 @@ async function main() { await app.register(serviceRoutes); await app.register(categoryRoutes); await app.register(scanRoutes); + await app.register(logRoutes); app.get("/", async () => { return { name: "LaunchPad API", status: "running" }; diff --git a/apps/backend/src/routes/logs.ts b/apps/backend/src/routes/logs.ts new file mode 100644 index 0000000..6106195 --- /dev/null +++ b/apps/backend/src/routes/logs.ts @@ -0,0 +1,10 @@ +import type { FastifyInstance } from "fastify"; +import * as logRepo from "../db/repositories/logs.js"; + +export async function logRoutes(app: FastifyInstance): Promise { + 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); + }); +} diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index fc4e43b..c5866ea 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -1,17 +1,17 @@ import type { FastifyInstance } from "fastify"; import * as deviceRepo from "../db/repositories/devices.js"; import * as serviceRepo from "../db/repositories/services.js"; +import * as logRepo from "../db/repositories/logs.js"; import { scanDeviceServices } from "../scanner/networkScanner.js"; import { fetchFritzBoxHosts } from "../scanner/fritzbox.js"; /** * Scan-Endpunkte. Werden ausschließlich manuell per Knopfdruck ("Jetzt - * scannen") aus der Admin-UI (Commit 6) ausgelöst – es gibt keinerlei - * automatischen/zeitgesteuerten Scan. + * scannen") aus der Admin-UI ausgelöst – es gibt keinerlei automatischen/ + * 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 { - // 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) => { const { id } = request.params as { id: string }; const device = deviceRepo.getDevice(id); @@ -19,39 +19,61 @@ export async function scanRoutes(app: FastifyInstance): Promise { return reply.code(404).send({ error: "Gerät nicht gefunden" }); } - const discovered = await scanDeviceServices(device); + try { + const discovered = await scanDeviceServices(device); - const results = discovered.map((found) => - serviceRepo.upsertServiceFromScan({ + const results = discovered.map((found) => + serviceRepo.upsertServiceFromScan({ + deviceId: device.id, + hostname: found.hostname, + url: found.url, + https: found.https, + port: found.port, + favicon: found.favicon, + description: found.description, + suggestedDisplayName: found.suggestedDisplayName, + suggestedCategory: found.category, + }) + ); + + deviceRepo.upsertDeviceFromScan({ + hostname: device.hostname, + ip: device.ip, + mac: device.mac, + manufacturer: device.manufacturer, + model: device.model, + online: discovered.length > 0, + 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 { deviceId: device.id, - hostname: found.hostname, - url: found.url, - https: found.https, - port: found.port, - favicon: found.favicon, - description: found.description, - suggestedDisplayName: found.suggestedDisplayName, - suggestedCategory: found.category, - }) - ); - - deviceRepo.upsertDeviceFromScan({ - hostname: device.hostname, - ip: device.ip, - mac: device.mac, - manufacturer: device.manufacturer, - model: device.model, - online: discovered.length > 0, - source: device.source, - }); - - return { - deviceId: device.id, - scannedPorts: discovered.length, - created: results.filter((r) => r.created).length, - updated: results.filter((r) => !r.created).length, - services: results.map((r) => r.service), - }; + scannedPorts: discovered.length, + created, + updated, + 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. @@ -82,13 +104,23 @@ export async function scanRoutes(app: FastifyInstance): Promise { source: "fritzbox", }) ); + + logRepo.logScan({ + type: "fritzbox", + level: "info", + message: `FritzBox-Scan: ${hosts.length} Gerät(e) gefunden`, + }); + return { found: hosts.length, devices }; } catch (err) { - request.log.error(err); - return reply.code(502).send({ - error: "FritzBox-Scan fehlgeschlagen", - detail: err instanceof Error ? err.message : String(err), + const detail = err instanceof Error ? err.message : String(err); + logRepo.logScan({ + type: "fritzbox", + level: "error", + message: `FritzBox-Scan fehlgeschlagen – ${detail}`, }); + request.log.error(err); + return reply.code(502).send({ error: "FritzBox-Scan fehlgeschlagen", detail }); } }); } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 380139b..f15a69d 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -13,6 +13,7 @@ "@launchpad/shared": "workspace:*", "@launchpad/ui": "workspace:*", "@tanstack/react-query": "^5.51.23", + "@tanstack/react-router": "^1.58.3", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/apps/frontend/src/hooks/useBackendHealth.ts b/apps/frontend/src/hooks/useBackendHealth.ts new file mode 100644 index 0000000..d1bb757 --- /dev/null +++ b/apps/frontend/src/hooks/useBackendHealth.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react"; +import type { HealthStatus } from "@launchpad/shared"; + +export function useBackendHealth() { + const [health, setHealth] = useState(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 }; +} diff --git a/apps/frontend/src/hooks/useCategories.ts b/apps/frontend/src/hooks/useCategories.ts new file mode 100644 index 0000000..fdfab61 --- /dev/null +++ b/apps/frontend/src/hooks/useCategories.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; +import type { Category } from "@launchpad/shared"; + +async function fetchCategories(): Promise { + 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, + }); +} diff --git a/apps/frontend/src/hooks/useDevices.ts b/apps/frontend/src/hooks/useDevices.ts new file mode 100644 index 0000000..963efa4 --- /dev/null +++ b/apps/frontend/src/hooks/useDevices.ts @@ -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 { + 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, + }); +} diff --git a/apps/frontend/src/hooks/useLogs.ts b/apps/frontend/src/hooks/useLogs.ts new file mode 100644 index 0000000..f8c641c --- /dev/null +++ b/apps/frontend/src/hooks/useLogs.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import type { ScanLogEntry } from "@launchpad/shared"; + +async function fetchLogs(): Promise { + 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, + }); +} diff --git a/apps/frontend/src/hooks/useTheme.ts b/apps/frontend/src/hooks/useTheme.ts new file mode 100644 index 0000000..cfd298d --- /dev/null +++ b/apps/frontend/src/hooks/useTheme.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; + +export type Theme = "light" | "dark"; + +export function useTheme(): [Theme, () => void] { + const [theme, setTheme] = useState(() => { + 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]; +} diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index 7098093..8f37c13 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -1,7 +1,8 @@ import React from "react"; import ReactDOM from "react-dom/client"; 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"; const queryClient = new QueryClient({ @@ -16,7 +17,7 @@ const queryClient = new QueryClient({ ReactDOM.createRoot(document.getElementById("root")!).render( - + ); diff --git a/apps/frontend/src/router.tsx b/apps/frontend/src/router.tsx new file mode 100644 index 0000000..c633cd1 --- /dev/null +++ b/apps/frontend/src/router.tsx @@ -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: () => , +}); + +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; + } +} diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/routes/HomePage.tsx similarity index 69% rename from apps/frontend/src/App.tsx rename to apps/frontend/src/routes/HomePage.tsx index e4a446a..a617535 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/routes/HomePage.tsx @@ -1,61 +1,11 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; import { SearchInput, StatusBadge, ResultsList } from "@launchpad/ui"; -import { rankServices, type Service, type HealthStatus } from "@launchpad/shared"; -import { useServices } from "./hooks/useServices.js"; - -type Theme = "light" | "dark"; - -function useTheme(): [Theme, () => void] { - const [theme, setTheme] = useState(() => { - 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(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 }; -} +import { rankServices, type Service } from "@launchpad/shared"; +import { useServices } from "../hooks/useServices.js"; +import { useBackendHealth } from "../hooks/useBackendHealth.js"; +import { useTheme } from "../hooks/useTheme.js"; function openService(service: Service) { window.open(service.url, "_blank", "noopener,noreferrer"); @@ -73,7 +23,7 @@ async function toggleServiceFavorite(service: Service): Promise { return res.json(); } -export default function App() { +export function HomePage() { const [theme, toggleTheme] = useTheme(); const [query, setQuery] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); @@ -136,14 +86,24 @@ export default function App() { return (
- +
+ + ⚙️ + + +

@@ -181,7 +141,7 @@ export default function App() { onToggleFavorite={(service) => toggleFavorite.mutate(service)} emptyLabel={ (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." } /> diff --git a/apps/frontend/src/routes/admin/AdminLayout.tsx b/apps/frontend/src/routes/admin/AdminLayout.tsx new file mode 100644 index 0000000..33f477d --- /dev/null +++ b/apps/frontend/src/routes/admin/AdminLayout.tsx @@ -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 ( +
+ + +
+ +
+
+ ); +} diff --git a/apps/frontend/src/routes/admin/AdminPageHeader.tsx b/apps/frontend/src/routes/admin/AdminPageHeader.tsx new file mode 100644 index 0000000..10f9c6c --- /dev/null +++ b/apps/frontend/src/routes/admin/AdminPageHeader.tsx @@ -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 ( +
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ {actions ?
{actions}
: null} +
+ ); +} diff --git a/apps/frontend/src/routes/admin/CategoriesPage.tsx b/apps/frontend/src/routes/admin/CategoriesPage.tsx new file mode 100644 index 0000000..bbcfa4f --- /dev/null +++ b/apps/frontend/src/routes/admin/CategoriesPage.tsx @@ -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 { + 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 { + 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) => void; + onDragOver: (e: DragEvent) => void; + onDrop: (e: DragEvent) => 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 ( +
  • + + ⠿⠿ + + + {editing ? ( + <> + 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" + /> + + + + ) : ( + <> + {category.name} + + + + )} +
  • + ); +} + +export function CategoriesPage() { + const { data: categories, isLoading, isError } = useCategories(); + const queryClient = useQueryClient(); + const [newName, setNewName] = useState(""); + const [draggedId, setDraggedId] = useState(null); + const [localOrder, setLocalOrder] = useState(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) => setDraggedId(id); + } + + function handleDragOver(targetId: string) { + return (e: DragEvent) => { + 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) => { + e.preventDefault(); + setDraggedId(null); + const current = localOrder ?? categories ?? []; + reorderMutation.mutate(current.map((c, index) => ({ id: c.id, order: index }))); + }; + } + + return ( +
    + + +
    +
    + + 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" + /> +
    + + {createMutation.isError ? ( + {(createMutation.error as Error).message} + ) : null} +
    + + {isLoading ? ( +

    Lade Kategorien …

    + ) : isError ? ( +

    Kategorien konnten nicht geladen werden.

    + ) : list.length > 0 ? ( +
      + {list.map((category) => ( + + ))} +
    + ) : ( +

    Noch keine Kategorien angelegt.

    + )} +
    + ); +} diff --git a/apps/frontend/src/routes/admin/DashboardPage.tsx b/apps/frontend/src/routes/admin/DashboardPage.tsx new file mode 100644 index 0000000..0640050 --- /dev/null +++ b/apps/frontend/src/routes/admin/DashboardPage.tsx @@ -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 ( +
    +
    {value}
    +
    {label}
    +
    + ); +} + +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 ( +
    + + +
    + + + + +
    + +
    +

    + Zuletzt gescannte Geräte +

    + {recentlyScanned.length === 0 ? ( +

    + Noch keine Scans durchgeführt. Starte einen Scan unter „Geräte“ oder „Scanner“. +

    + ) : ( +
    + + + {recentlyScanned.map((device) => ( + + + + + + ))} + +
    + {device.hostname} + {device.ip} + {device.lastScan ? new Date(device.lastScan).toLocaleString("de-DE") : "–"} +
    +
    + )} +
    + +
    +

    + Kategorien +

    +

    + {categories?.length ?? 0} Kategorie(n) angelegt. +

    +
    +
    + ); +} diff --git a/apps/frontend/src/routes/admin/DevicesPage.tsx b/apps/frontend/src/routes/admin/DevicesPage.tsx new file mode 100644 index 0000000..a85c570 --- /dev/null +++ b/apps/frontend/src/routes/admin/DevicesPage.tsx @@ -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 { + 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(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 ( + + +
    {device.hostname}
    +
    {device.ip}
    + + + + + {device.online ? "Online" : "Offline"} + + + {device.services.length} + + {device.lastScan ? new Date(device.lastScan).toLocaleString("de-DE") : "nie gescannt"} + + +
    + {scanMessage ? ( + + {scanMessage} + + ) : null} + + +
    + + + ); +} + +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 ( +
    +
    + + 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" + /> +
    +
    + + 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" + /> +
    + + {mutation.isError ? ( + {(mutation.error as Error).message} + ) : null} +
    + ); +} + +export function DevicesPage() { + const { data: devices, isLoading, isError } = useDevices(); + + return ( +
    + + +
    + +
    + + {isLoading ? ( +

    Lade Geräte …

    + ) : isError ? ( +

    Geräte konnten nicht geladen werden.

    + ) : devices && devices.length > 0 ? ( +
    + + + + + + + + + + + {devices.map((device) => ( + + ))} + +
    GerätStatusDiensteLetzter Scan +
    +
    + ) : ( +

    + Noch keine Geräte angelegt. Füge oben ein Gerät hinzu oder nutze den FritzBox-Scan + unter „Scanner“. +

    + )} +
    + ); +} diff --git a/apps/frontend/src/routes/admin/LogsPage.tsx b/apps/frontend/src/routes/admin/LogsPage.tsx new file mode 100644 index 0000000..d4a7228 --- /dev/null +++ b/apps/frontend/src/routes/admin/LogsPage.tsx @@ -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 ( +
    + + + {isLoading ? ( +

    Lade Logs …

    + ) : isError ? ( +

    Logs konnten nicht geladen werden.

    + ) : logs && logs.length > 0 ? ( +
    + + + + + + + + + + {logs.map((log) => ( + + + + + + ))} + +
    ZeitTypNachricht
    + {new Date(log.createdAt).toLocaleString("de-DE")} + + + {log.type === "fritzbox" ? "FritzBox" : "Gerät"} + + {log.message}
    +
    + ) : ( +

    + Noch keine Scans durchgeführt. Starte einen Scan unter „Geräte" oder „Scanner". +

    + )} +
    + ); +} diff --git a/apps/frontend/src/routes/admin/PluginsPage.tsx b/apps/frontend/src/routes/admin/PluginsPage.tsx new file mode 100644 index 0000000..ab30fc9 --- /dev/null +++ b/apps/frontend/src/routes/admin/PluginsPage.tsx @@ -0,0 +1,18 @@ +import { AdminPageHeader } from "./AdminPageHeader.js"; + +export function PluginsPage() { + return ( +
    + +
    +

    + Das Plugin-System (Scanner registrieren, Geräte importieren, Menüs erweitern, + Icons bereitstellen) ist noch nicht gebaut. +

    +

    + Geplant als eigener Commit – siehe docs/ROADMAP.md. +

    +
    +
    + ); +} diff --git a/apps/frontend/src/routes/admin/ScannerPage.tsx b/apps/frontend/src/routes/admin/ScannerPage.tsx new file mode 100644 index 0000000..89c24d9 --- /dev/null +++ b/apps/frontend/src/routes/admin/ScannerPage.tsx @@ -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(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 ( +
    + + +
    +
    +

    FritzBox-Scan

    +

    + Liest die Geräteliste der FritzBox per TR-064 und legt/aktualisiert Geräte. + Erfordert FRITZBOX_HOST,{" "} + FRITZBOX_USERNAME und{" "} + FRITZBOX_PASSWORD in + der .env. +

    + + {fritzboxMutation.isSuccess ? ( +

    + {fritzboxMutation.data.found} Gerät(e) gefunden. +

    + ) : null} + {fritzboxMutation.isError ? ( +

    {(fritzboxMutation.error as Error).message}

    + ) : null} +
    + +
    +

    Alle Geräte scannen

    +

    + 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. +

    + + {bulkStatus ? ( +

    {bulkStatus}

    + ) : null} +
    +
    +
    + ); +} diff --git a/apps/frontend/src/routes/admin/ServicesPage.tsx b/apps/frontend/src/routes/admin/ServicesPage.tsx new file mode 100644 index 0000000..0a31a97 --- /dev/null +++ b/apps/frontend/src/routes/admin/ServicesPage.tsx @@ -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 { + 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 ( + + +
    +
    + + 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" + /> +
    +
    + + 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" + /> +
    +
    + + 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" + /> +
    +
    + + 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" + /> +
    + + +
    + + + ); +} + +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 setEditing(false)} />; + } + + return ( + + + + + +
    {service.displayName}
    +
    {service.hostname}
    + + {service.category ?? "–"} + + {service.alias.length > 0 ? service.alias.join(", ") : "–"} + + + + öffnen + + + +
    + + +
    + + + ); +} + +export function ServicesPage() { + const { data: services, isLoading, isError } = useServices(); + + return ( +
    + + + {isLoading ? ( +

    Lade Dienste …

    + ) : isError ? ( +

    Dienste konnten nicht geladen werden.

    + ) : services && services.length > 0 ? ( +
    + + + + + + + + + + + {services.map((service) => ( + + ))} + +
    + DienstKategorieAliasURL +
    +
    + ) : ( +

    + Noch keine Dienste vorhanden. Scanne ein Gerät unter „Geräte“, um automatisch welche + zu finden. +

    + )} +
    + ); +} diff --git a/apps/frontend/src/routes/admin/SettingsPage.tsx b/apps/frontend/src/routes/admin/SettingsPage.tsx new file mode 100644 index 0000000..5461666 --- /dev/null +++ b/apps/frontend/src/routes/admin/SettingsPage.tsx @@ -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 ( +
    + {label} + {value} +
    + ); +} + +export function SettingsPage() { + const { health, error } = useBackendHealth(); + const [theme, toggleTheme] = useTheme(); + + return ( +
    + + +
    +
    +

    Darstellung

    +
    + Theme + +
    +
    + +
    +

    Backend

    + {error ? ( +

    Backend nicht erreichbar.

    + ) : health ? ( +
    + + + +
    + ) : ( +

    Lade …

    + )} +
    +
    +
    + ); +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f94fbd9..875f3f5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -50,10 +50,27 @@ Grundgerüst aus Commit 1. > FritzBox-SOAP-/Digest-Auth-Ablauf inkl. Fehlerfall bei falschem Passwort). > 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, - Einstellungen, Logs +- TanStack Router (code-basiert): `/` (Startseite) + `/admin/*` +- 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 diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 67c3148..a6e3158 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -52,6 +52,15 @@ export interface Category { 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: * 1. Displayname beginnt mit Suchtext diff --git a/packages/ui/src/Button.tsx b/packages/ui/src/Button.tsx new file mode 100644 index 0000000..cb4eba8 --- /dev/null +++ b/packages/ui/src/Button.tsx @@ -0,0 +1,38 @@ +import { forwardRef, type ButtonHTMLAttributes } from "react"; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: "primary" | "secondary" | "danger" | "ghost"; + size?: "sm" | "md"; +} + +const VARIANT_CLASSES: Record, 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, string> = { + sm: "px-3 py-1.5 text-sm", + md: "px-4 py-2 text-sm", +}; + +export const Button = forwardRef( + ({ variant = "secondary", size = "md", className = "", disabled, ...props }, ref) => { + return ( +