diff --git a/README.md b/README.md index eadfbd4..dee152e 100644 --- a/README.md +++ b/README.md @@ -131,13 +131,14 @@ DELETE /api/devices/:id (löscht zugehörige Dienste per Cascade) GET /api/services optional ?deviceId=&category=&favorite=true GET /api/services/:id POST /api/services erfordert existierende deviceId +PATCH /api/services/reorder Body: [{ id, order }, ...] PATCH /api/services/:id DELETE /api/services/:id GET /api/categories POST /api/categories PATCH /api/categories/reorder Body: [{ id, order }, ...] -PATCH /api/categories/:id Umbenennen +PATCH /api/categories/:id Umbenennen (aktualisiert automatisch alle Dienste mit altem Namen) DELETE /api/categories/:id Dienste behalten ihre category nicht mehr (null), werden aber nicht gelöscht @@ -148,6 +149,9 @@ POST /api/scan/fritzbox Liest Geräteliste der FritzBox per TR-064 GET /api/logs optional ?limit= (Default 100, Max 500) +POST /api/reset Löscht ALLE Geräte + Dienste (Cascade). Erfordert + Body { "confirm": true }, sonst 400. + GET /api/plugins geladene Plugins mit Capabilities POST /api/plugins/:name/import löst importDevices() eines Plugins aus ``` diff --git a/apps/backend/src/db/repositories/categories.ts b/apps/backend/src/db/repositories/categories.ts index a328e1c..a7c31d6 100644 --- a/apps/backend/src/db/repositories/categories.ts +++ b/apps/backend/src/db/repositories/categories.ts @@ -50,14 +50,28 @@ export function createCategory(input: CategoryCreateInput): Category { return getCategory(id)!; } +/** + * Benennt eine Kategorie um. Da Dienste ihre Kategorie als freier Text + * speichern (kein Fremdschlüssel), werden alle Dienste mit dem alten Namen + * automatisch auf den neuen Namen mit umgestellt, damit beides synchron bleibt. + */ export function updateCategory(id: string, input: CategoryUpdateInput): Category | null { const existing = getCategory(id); if (!existing) return null; + const timestamp = nowIso(); + + if (input.name !== undefined && input.name !== existing.name) { + db.update(services) + .set({ category: input.name, updatedAt: timestamp }) + .where(eq(services.category, existing.name)) + .run(); + } + db.update(categories) .set({ ...(input.name !== undefined && { name: input.name }), - updatedAt: nowIso(), + updatedAt: timestamp, }) .where(eq(categories.id, id)) .run(); @@ -99,3 +113,41 @@ export function deleteCategory(id: string): boolean { const result = db.delete(categories).where(eq(categories.id, id)).run(); return result.changes > 0; } + +/** + * Legt eine Kategorie an, falls noch keine mit diesem Namen existiert + * (Vergleich ohne Groß-/Kleinschreibung). Wird beim Scannen aufgerufen, + * damit jede automatisch erkannte Kategorie auch im Adminbereich auftaucht. + */ +export function ensureCategory(name: string): Category { + const trimmed = name.trim(); + const existing = listCategories().find( + (c) => c.name.toLowerCase() === trimmed.toLowerCase() + ); + if (existing) return existing; + return createCategory({ name: trimmed }); +} + +/** + * Gleicht eine Liste von Kategorienamen (z. B. alle distinct-Werte aus + * services.category) mit der categories-Tabelle ab und legt fehlende an. + * Wird beim Start aufgerufen, damit bereits vorhandene, aber nie über + * ensureCategory() angelegte Kategorien (z. B. aus Scans vor diesem Fix) + * im Adminbereich sichtbar werden. Gibt die Anzahl neu angelegter zurück. + */ +export function syncCategoriesFromServiceValues(categoryNames: string[]): number { + const existingNames = new Set(listCategories().map((c) => c.name.toLowerCase())); + let created = 0; + + for (const rawName of categoryNames) { + const trimmed = rawName.trim(); + if (!trimmed) continue; + if (existingNames.has(trimmed.toLowerCase())) continue; + + createCategory({ name: trimmed }); + existingNames.add(trimmed.toLowerCase()); + created++; + } + + return created; +} diff --git a/apps/backend/src/db/repositories/devices.ts b/apps/backend/src/db/repositories/devices.ts index 908c8ad..62ee22f 100644 --- a/apps/backend/src/db/repositories/devices.ts +++ b/apps/backend/src/db/repositories/devices.ts @@ -84,6 +84,12 @@ export function deleteDevice(id: string): boolean { return result.changes > 0; } +/** Löscht ALLE Geräte (und per FK-Cascade alle zugehörigen Dienste). Für den Reset-Button im Adminbereich. */ +export function deleteAllDevices(): number { + const result = db.delete(devices).run(); + return result.changes; +} + export interface DeviceScanInput { hostname: string; ip: string; diff --git a/apps/backend/src/db/repositories/services.ts b/apps/backend/src/db/repositories/services.ts index 4dbc44b..7ecf26b 100644 --- a/apps/backend/src/db/repositories/services.ts +++ b/apps/backend/src/db/repositories/services.ts @@ -124,6 +124,23 @@ export function deleteService(id: string): boolean { return result.changes > 0; } +/** + * Setzt die Reihenfolge mehrerer Dienste in einer Transaktion neu + * (z. B. nach Drag & Drop in der Admin-UI). + */ +export function reorderServices(input: { id: string; order: number }[]): Service[] { + db.transaction((tx) => { + for (const entry of input) { + tx.update(services) + .set({ order: entry.order, updatedAt: nowIso() }) + .where(eq(services.id, entry.id)) + .run(); + } + }); + + return listServices(); +} + export interface ServiceScanInput { deviceId: string; hostname: string; diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index f9af819..762d21e 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -9,7 +9,10 @@ import { categoryRoutes } from "./routes/categories.js"; import { scanRoutes } from "./routes/scan.js"; import { logRoutes } from "./routes/logs.js"; import { pluginRoutes } from "./routes/plugins.js"; +import { resetRoutes } from "./routes/reset.js"; import { loadPlugins } from "./plugins/loader.js"; +import * as serviceRepo from "./db/repositories/services.js"; +import * as categoryRepo from "./db/repositories/categories.js"; const PORT = Number(process.env.PORT ?? 3001); const HOST = process.env.HOST ?? "0.0.0.0"; @@ -31,6 +34,23 @@ async function main() { ensureSchema(); + // Kategorien, die bereits auf Diensten stehen (z. B. aus Scans vor diesem + // Fix, oder von Plugins), aber noch nicht in der categories-Tabelle sind, + // hier nachtragen. Damit zeigt Admin -> Kategorien immer das, was auch in + // der Suche als Kategorie auftaucht. + const existingCategoryNames = Array.from( + new Set( + serviceRepo + .listServices() + .map((s) => s.category) + .filter((c): c is string => !!c) + ) + ); + const syncedCount = categoryRepo.syncCategoriesFromServiceValues(existingCategoryNames); + if (syncedCount > 0) { + app.log.info(`${syncedCount} Kategorie(n) aus bestehenden Diensten nachgetragen`); + } + const plugins = await loadPlugins(); app.log.info(`${plugins.length} Plugin(s) geladen`); @@ -41,6 +61,7 @@ async function main() { await app.register(scanRoutes); await app.register(logRoutes); await app.register(pluginRoutes); + await app.register(resetRoutes); app.get("/", async () => { return { name: "LaunchPad API", status: "running" }; diff --git a/apps/backend/src/routes/reset.ts b/apps/backend/src/routes/reset.ts new file mode 100644 index 0000000..97b88af --- /dev/null +++ b/apps/backend/src/routes/reset.ts @@ -0,0 +1,34 @@ +import type { FastifyInstance } from "fastify"; +import * as deviceRepo from "../db/repositories/devices.js"; +import * as logRepo from "../db/repositories/logs.js"; + +/** + * Löscht ALLE Geräte und (per FK-Cascade) alle zugehörigen Dienste. + * Kategorien und Logs bleiben unangetastet. + * + * Erfordert { confirm: true } im Body – zusätzlich zur Bestätigung im + * Frontend eine zweite Sicherung gegen versehentliche Aufrufe (z. B. per + * Skript oder Browser-Erweiterung). + */ +export async function resetRoutes(app: FastifyInstance): Promise { + app.post("/api/reset", async (request, reply) => { + const body = request.body as { confirm?: boolean } | undefined; + + if (body?.confirm !== true) { + return reply.code(400).send({ + error: "Bestätigung erforderlich. Bitte { \"confirm\": true } im Body senden.", + }); + } + + const deviceCount = deviceRepo.listDevices().length; + const deletedCount = deviceRepo.deleteAllDevices(); + + logRepo.logScan({ + type: "device", + level: "info", + message: `Reset: ${deletedCount} Gerät(e) und alle zugehörigen Dienste gelöscht`, + }); + + return { deletedDevices: deletedCount, previousDeviceCount: deviceCount }; + }); +} diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index f45750f..88ca2f3 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from "fastify"; import * as deviceRepo from "../db/repositories/devices.js"; import * as serviceRepo from "../db/repositories/services.js"; +import * as categoryRepo from "../db/repositories/categories.js"; import * as logRepo from "../db/repositories/logs.js"; import { scanDeviceServices } from "../scanner/networkScanner.js"; import { fetchFritzBoxHosts } from "../scanner/fritzbox.js"; @@ -22,6 +23,13 @@ export async function scanRoutes(app: FastifyInstance): Promise { try { const discovered = await scanDeviceServices(device); + // Jede erkannte Kategorie auch in der categories-Tabelle anlegen, damit + // sie unter Admin -> Kategorien auftaucht und dort umbenannt/sortiert + // werden kann (services.category ist reiner Freitext, kein Fremdschlüssel). + for (const category of new Set(discovered.map((d) => d.category).filter((c): c is string => !!c))) { + categoryRepo.ensureCategory(category); + } + const results = discovered.map((found) => serviceRepo.upsertServiceFromScan({ deviceId: device.id, diff --git a/apps/backend/src/routes/services.ts b/apps/backend/src/routes/services.ts index 0a1b9b7..88d2e7c 100644 --- a/apps/backend/src/routes/services.ts +++ b/apps/backend/src/routes/services.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { ServiceCreateSchema, ServiceUpdateSchema } from "@launchpad/shared"; +import { ServiceCreateSchema, ServiceReorderSchema, ServiceUpdateSchema } from "@launchpad/shared"; import * as deviceRepo from "../db/repositories/devices.js"; import * as serviceRepo from "../db/repositories/services.js"; @@ -43,6 +43,15 @@ export async function serviceRoutes(app: FastifyInstance): Promise { return reply.code(201).send(service); }); + // Muss vor der /:id-Route stehen, damit "reorder" nicht als ID interpretiert wird. + app.patch("/api/services/reorder", async (request, reply) => { + const parsed = ServiceReorderSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues }); + } + return serviceRepo.reorderServices(parsed.data); + }); + app.patch("/api/services/:id", async (request, reply) => { const { id } = request.params as { id: string }; const parsed = ServiceUpdateSchema.safeParse(request.body); diff --git a/apps/frontend/src/routes/HomePage.tsx b/apps/frontend/src/routes/HomePage.tsx index d67c4f2..fa43cd6 100644 --- a/apps/frontend/src/routes/HomePage.tsx +++ b/apps/frontend/src/routes/HomePage.tsx @@ -1,7 +1,7 @@ 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 { SearchInput, StatusBadge, ResultsList, FavoritesBar } from "@launchpad/ui"; import { rankServices, type Service } from "@launchpad/shared"; import { useServices } from "../hooks/useServices.js"; import { useBackendHealth } from "../hooks/useBackendHealth.js"; @@ -52,6 +52,14 @@ export function HomePage() { [visibleServices, query] ); + const favoriteServices = useMemo( + () => + visibleServices + .filter((s) => s.favorite) + .sort((a, b) => a.order - b.order), + [visibleServices] + ); + // Auswahl zurücksetzen, sobald sich die Trefferliste ändert useEffect(() => { setSelectedIndex(0); @@ -124,6 +132,12 @@ export function HomePage() {
+ {favoriteServices.length > 0 ? ( +
+ +
+ ) : null} + s.location.pathname }); const [mobileOpen, setMobileOpen] = useState(false); + const { health, error: healthError } = useBackendHealth(); + const isOnline = !healthError && health?.status === "ok"; // Drawer schließen, wenn per Navigation die Seite wechselt useEffect(() => { @@ -98,6 +102,12 @@ export function AdminLayout() {
+
+ +
void }) { const queryClient = useQueryClient(); const [displayName, setDisplayName] = useState(service.displayName); @@ -73,7 +87,7 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void }) return ( - +
@@ -169,7 +183,19 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void }) ); } -function ServiceRow({ service }: { service: Service }) { +function ServiceRow({ + service, + onDragStart, + onDragOver, + onDrop, + isDragging, +}: { + service: Service; + onDragStart: (e: DragEvent) => void; + onDragOver: (e: DragEvent) => void; + onDrop: (e: DragEvent) => void; + isDragging: boolean; +}) { const queryClient = useQueryClient(); const [editing, setEditing] = useState(false); @@ -194,11 +220,20 @@ function ServiceRow({ service }: { service: Service }) { return ( - + + + ⠿⠿ + + +
-
- {service.hostname}:{service.port} -
+
{service.hostname}
{service.category ?? "–"} {service.alias.length > 0 ? service.alias.join(", ") : "–"} + {service.port} + + + {service.https ? "https" : "http"} + + - öffnen{service.https ? " (https)" : ""} + öffnen @@ -260,16 +305,60 @@ function ServiceRow({ service }: { service: Service }) { export function ServicesPage() { const { data: services, isLoading, isError } = useServices(); + const queryClient = useQueryClient(); + const [draggedId, setDraggedId] = useState(null); + const [localOrder, setLocalOrder] = useState(null); + + const reorderMutation = useMutation({ + mutationFn: reorderServicesRequest, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["services"] }); + setLocalOrder(null); + }, + onError: () => setLocalOrder(null), + }); + + const list = localOrder ?? services ?? []; const hiddenCount = services?.filter((s) => !s.visible).length ?? 0; + 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 ?? services ?? []; + const fromIndex = current.findIndex((s) => s.id === draggedId); + const toIndex = current.findIndex((s) => s.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 ?? services ?? []; + reorderMutation.mutate(current.map((s, index) => ({ id: s.id, order: index }))); + }; + } + return (
0 - ? `Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten. ${hiddenCount} Dienst(e) sind aktuell in der Suche ausgeblendet (🙈).` - : "Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten." + ? `Per Drag & Drop sortierbar (⠿⠿). Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten. ${hiddenCount} Dienst(e) sind aktuell in der Suche ausgeblendet (🙈).` + : "Per Drag & Drop sortierbar (⠿⠿). Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten." } /> @@ -277,24 +366,34 @@ export function ServicesPage() {

Lade Dienste …

) : isError ? (

Dienste konnten nicht geladen werden.

- ) : services && services.length > 0 ? ( + ) : list.length > 0 ? (
- +
- + + - {services.map((service) => ( - + {list.map((service) => ( + ))}
+ + Dienst Kategorie AliasPortProtokoll URL
diff --git a/apps/frontend/src/routes/admin/SettingsPage.tsx b/apps/frontend/src/routes/admin/SettingsPage.tsx index 5461666..56f921f 100644 --- a/apps/frontend/src/routes/admin/SettingsPage.tsx +++ b/apps/frontend/src/routes/admin/SettingsPage.tsx @@ -1,3 +1,6 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@launchpad/ui"; import { useBackendHealth } from "../../hooks/useBackendHealth.js"; import { useTheme } from "../../hooks/useTheme.js"; import { AdminPageHeader } from "./AdminPageHeader.js"; @@ -11,6 +14,66 @@ function InfoRow({ label, value }: { label: string; value: string }) { ); } +async function resetEverything() { + const res = await fetch("/api/reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirm: true }), + }); + const body = await res.json(); + if (!res.ok) { + throw new Error(body.error ?? `Reset fehlgeschlagen (HTTP ${res.status})`); + } + return body as { deletedDevices: number }; +} + +function DangerZone() { + const queryClient = useQueryClient(); + const [message, setMessage] = useState(null); + + const mutation = useMutation({ + mutationFn: resetEverything, + onSuccess: (result) => { + setMessage(`${result.deletedDevices} Gerät(e) und alle zugehörigen Dienste gelöscht.`); + queryClient.invalidateQueries({ queryKey: ["devices"] }); + queryClient.invalidateQueries({ queryKey: ["services"] }); + queryClient.invalidateQueries({ queryKey: ["logs"] }); + }, + onError: (err: Error) => setMessage(err.message), + }); + + function handleClick() { + const firstConfirm = window.confirm( + "Wirklich ALLE Geräte und Dienste unwiderruflich löschen? Kategorien bleiben erhalten." + ); + if (!firstConfirm) return; + + const secondConfirm = window.confirm( + "Ganz sicher? Das kann nicht rückgängig gemacht werden." + ); + if (!secondConfirm) return; + + setMessage(null); + mutation.mutate(); + } + + return ( +
+

Gefahrenzone

+

+ Löscht alle Geräte und alle zugehörigen Dienste unwiderruflich. Kategorien und Logs + bleiben erhalten. +

+ + {message ? ( +

{message}

+ ) : null} +
+ ); +} + export function SettingsPage() { const { health, error } = useBackendHealth(); const [theme, toggleTheme] = useTheme(); @@ -49,6 +112,8 @@ export function SettingsPage() {

Lade …

)}
+ +
); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 19595f5..93adf46 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -154,3 +154,30 @@ Geräte): `hostname`), in der Suche wie im Adminbereich. - **Admin-Sidebar ist jetzt echt fixiert** (`fixed` statt `static`) – vorher scrollte "Zurück zur Suche" bei langem Seiteninhalt aus dem Sichtfeld. + +## Weitere Fixes (zweite Testrunde) + +- **Reset-Button** unter Admin -> Einstellungen -> Gefahrenzone: löscht alle + Geräte und (per Cascade) alle Dienste unwiderruflich. Doppelte Bestätigung + im Frontend (`window.confirm`) plus eine Pflicht-Bestätigung auf API-Ebene + (`POST /api/reset` verlangt `{"confirm": true}`, sonst `400`). Kategorien + und Logs bleiben erhalten. +- **Port und Protokoll (http/https) als eigene Spalten** in der + Dienste-Tabelle im Adminbereich, nicht mehr nur implizit in der URL. +- **Favoriten-Leiste über der Suchleiste** auf der Startseite – immer + sichtbar, unabhängig vom Suchfeld, sortiert nach der einstellbaren + Reihenfolge. +- **Dienste per Drag & Drop sortierbar** im Adminbereich (`PATCH + /api/services/reorder`), bestimmt sowohl die Reihenfolge in der + Favoriten-Leiste als auch bei gleichrangigen Suchtreffern. +- **Healthcheck durchgängig in der Admin-Sidebar sichtbar** (vorher nur auf + der Startseite und versteckt unter Einstellungen). +- **Kategorien-Synchronisierung:** Die `categories`-Tabelle war leer, obwohl + Dienste bereits Kategorien-Text trugen (z. B. aus der automatischen + Softwareerkennung) – `services.category` ist reiner Freitext, kein + Fremdschlüssel. Jetzt: (1) beim Start werden alle bereits auf Diensten + vorhandenen Kategorienamen automatisch in die `categories`-Tabelle + nachgetragen, (2) jeder Scan legt neu erkannte Kategorien automatisch an, + (3) Umbenennen einer Kategorie im Adminbereich aktualisiert automatisch + alle Dienste mit dem alten Namen. Einschränkung: manuell per API angelegte + Dienste (nicht über einen Scan) lösen diesen Sync aktuell nicht aus. diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 9afd388..0442df5 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -49,6 +49,17 @@ export const ServiceUpdateSchema = ServiceCreateSchema.omit({ }).partial(); export type ServiceUpdateInput = z.infer; +/** Für Drag & Drop: neue Reihenfolge mehrerer Dienste auf einmal setzen. */ +export const ServiceReorderSchema = z + .array( + z.object({ + id: z.string().min(1), + order: z.number(), + }) + ) + .min(1, "mindestens ein Eintrag erforderlich"); +export type ServiceReorderInput = z.infer; + export const CategoryCreateSchema = z.object({ name: z.string().min(1, "name darf nicht leer sein"), }); diff --git a/packages/ui/src/FavoritesBar.tsx b/packages/ui/src/FavoritesBar.tsx new file mode 100644 index 0000000..2589c8c --- /dev/null +++ b/packages/ui/src/FavoritesBar.tsx @@ -0,0 +1,47 @@ +import type { Service } from "@launchpad/shared"; + +export interface FavoritesBarProps { + services: Service[]; + onOpen: (service: Service) => void; +} + +/** + * Zeigt Favoriten als anklickbare Chips – immer sichtbar, unabhängig vom + * Suchfeld. Reihenfolge folgt service.order (im Adminbereich per Drag & Drop + * änderbar). + */ +export function FavoritesBar({ services, onOpen }: FavoritesBarProps) { + if (services.length === 0) return null; + + return ( +
+ {services.map((service) => ( + + ))} +
+ ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index e122792..0dfe61e 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -9,3 +9,6 @@ export type { ResultsListProps } from "./ResultsList.js"; export { Button } from "./Button.js"; export type { ButtonProps } from "./Button.js"; + +export { FavoritesBar } from "./FavoritesBar.js"; +export type { FavoritesBarProps } from "./FavoritesBar.js";