Reset-Button, Service-Reorder, Port/HTTPS-Spalten, Healthcheck in Sidebar, Kategorien-Sync

This commit is contained in:
2026-07-19 15:14:44 +02:00
parent 20ea4d382d
commit fc1ea4fa70
16 changed files with 447 additions and 20 deletions

View File

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

View File

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

View File

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

View File

@@ -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" };

View File

@@ -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<void> {
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 };
});
}

View File

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

View File

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