From 986de50963dfd778c4beca88e475bab035b4840e Mon Sep 17 00:00:00 2001 From: Dicken Date: Sun, 19 Jul 2026 10:14:43 +0200 Subject: [PATCH] =?UTF-8?q?Commit=208:=20Plugin-System=20(Scanner=20erweit?= =?UTF-8?q?ern,=20Ger=C3=A4te=20importieren,=20Icons)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 11 +- apps/backend/plugins/README.md | 78 ++++++++++++ .../plugins/example-signatures/index.js | 29 +++++ .../static-import/devices.example.json | 9 ++ .../plugins/static-import/devices.json | 1 + apps/backend/plugins/static-import/index.js | 40 +++++++ apps/backend/src/db/repositories/services.ts | 4 +- apps/backend/src/index.ts | 6 + apps/backend/src/plugins/loader.ts | 71 +++++++++++ apps/backend/src/plugins/types.ts | 22 ++++ apps/backend/src/routes/plugins.ts | 61 ++++++++++ apps/backend/src/routes/scan.ts | 1 + apps/backend/src/scanner/networkScanner.ts | 2 + apps/backend/src/scanner/softwareDetection.ts | 21 +++- apps/frontend/src/hooks/usePlugins.ts | 17 +++ .../frontend/src/routes/admin/PluginsPage.tsx | 113 ++++++++++++++++-- docker-compose.yml | 1 + docs/ROADMAP.md | 37 +++++- packages/shared/src/index.ts | 12 +- packages/shared/src/schemas.ts | 1 + 20 files changed, 517 insertions(+), 20 deletions(-) create mode 100644 apps/backend/plugins/README.md create mode 100644 apps/backend/plugins/example-signatures/index.js create mode 100644 apps/backend/plugins/static-import/devices.example.json create mode 100644 apps/backend/plugins/static-import/devices.json create mode 100644 apps/backend/plugins/static-import/index.js create mode 100644 apps/backend/src/plugins/loader.ts create mode 100644 apps/backend/src/plugins/types.ts create mode 100644 apps/backend/src/routes/plugins.ts create mode 100644 apps/frontend/src/hooks/usePlugins.ts diff --git a/README.md b/README.md index 6abd400..eadfbd4 100644 --- a/README.md +++ b/README.md @@ -80,11 +80,15 @@ Dieser erste Commit liefert ein lauffähiges Grundgerüst: - ✅ PWA: installierbar (Manifest + Icons für Android/iOS/Desktop), Service Worker mit App-Shell-Precaching, `/api/*` läuft offline über den letzten Cache-Stand (NetworkFirst, 3s-Timeout) +- ✅ Plugin-System: echte, ladbare Plugins unter `apps/backend/plugins/*` + (Bind-Mount, kein Rebuild nötig). Plugins können die Softwareerkennung des + Scanners erweitern (inkl. eigenem Icon) und eigene Geräte-Importquellen + bereitstellen. Zwei funktionierende Beispiel-Plugins liegen bei. -Noch **nicht** enthalten (folgt in den nächsten Commits): +Noch **nicht** enthalten: -- Plugin-System (Scanner registrieren, Geräte importieren, Menüs erweitern, Icons) - shadcn/ui, React Hook Form (aktuell einfache kontrollierte Formulare) +- Eigene Admin-Routen/Menüpunkte pro Plugin (aktuell gesammelt auf einer Seite) ### Umgebungsvariablen (.env) @@ -143,6 +147,9 @@ 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) + +GET /api/plugins geladene Plugins mit Capabilities +POST /api/plugins/:name/import löst importDevices() eines Plugins aus ``` ## Frontend-Routen diff --git a/apps/backend/plugins/README.md b/apps/backend/plugins/README.md new file mode 100644 index 0000000..21c038a --- /dev/null +++ b/apps/backend/plugins/README.md @@ -0,0 +1,78 @@ +# LaunchPad-Plugins + +Jeder Unterordner hier ist ein Plugin. Erkannt wird alles, was eine +`index.js` mit einem passenden Default-Export enthält – kein Rebuild des +Docker-Images nötig, dieser Ordner wird zur Laufzeit als Volume eingebunden +(siehe `docker-compose.yml`). Nach dem Hinzufügen/Ändern eines Plugins +reicht ein Neustart des Backend-Containers: + +```bash +docker compose restart backend +``` + +## Minimalstruktur + +``` +apps/backend/plugins/mein-plugin/ +└── index.js +``` + +```js +export default { + name: "mein-plugin", + version: "1.0.0", + description: "Kurze Beschreibung, erscheint unter Admin -> Plugins", + + // optional: Scanner erweitern + setup(ctx) { + ctx.registerSoftwareSignature({ + name: "Meine Software", + category: "Sonstiges", + icon: "🔧", // Emoji oder Icon-URL + matches: (input) => !!input.body && /meine-software/i.test(input.body), + }); + }, + + // optional: eigene Geräte-Importquelle + async importDevices() { + return [ + { hostname: "beispiel", ip: "192.168.1.99", source: "plugin" }, + ]; + }, +}; +``` + +Beide Funktionen (`setup`, `importDevices`) sind optional – ein Plugin kann +nur die eine, nur die andere, oder beide implementieren. Reines JavaScript +(ESM), kein Build-Schritt nötig. + +## Was Plugins aktuell können + +- **Scanner registrieren**: `ctx.registerSoftwareSignature(...)` in `setup()` + fügt der automatischen Softwareerkennung (siehe `apps/backend/src/scanner/softwareDetection.ts`) + eine zusätzliche Signatur hinzu, inklusive eigenem Icon. Wird bei jedem + Geräte-Scan berücksichtigt. +- **Geräte importieren**: `importDevices()` liefert eine Liste von Geräten, + die per Knopfdruck unter Admin -> Plugins importiert werden + (`POST /api/plugins/:name/import`). Abgeglichen wird wie bei Scans über + MAC/IP – ein erneuter Import überschreibt keine Gerätefelder, die der + Nutzer zwischenzeitlich geändert hat, außer den scan-eigenen (siehe + `upsertDeviceFromScan`). +- **Icons bereitstellen**: über `icon` an einer registrierten Signatur. Wird + als Startwert für neu angelegte Dienste übernommen (wie `category` – + niemals nachträglich überschrieben, siehe README Hauptprojekt). + +## Was (noch) nicht geht + +- Eigene Admin-Menüpunkte/Unterseiten (aktuell erscheinen alle Plugins + gesammelt auf einer Seite unter Admin -> Plugins, keine eigene Route pro + Plugin). +- Kein Sandboxing: Plugin-Code läuft mit vollem Zugriff im Backend-Prozess. + Nur Plugins aus vertrauenswürdiger Quelle einbinden. + +## Beispiel-Plugins + +- `example-signatures/` – registriert zwei zusätzliche Software-Signaturen + (Homebridge, Uptime Kuma). +- `static-import/` – importiert Geräte aus einer lokalen `devices.json` + (standardmäßig leer, siehe `devices.example.json` für das Format). diff --git a/apps/backend/plugins/example-signatures/index.js b/apps/backend/plugins/example-signatures/index.js new file mode 100644 index 0000000..b575b8f --- /dev/null +++ b/apps/backend/plugins/example-signatures/index.js @@ -0,0 +1,29 @@ +/** + * Beispiel-Plugin: registriert zwei zusätzliche Softwareerkennungen, die + * nicht in der eingebauten Liste enthalten sind, inklusive eigenem Icon. + * + * Zeigt das setup()-Pattern: ein Plugin bekommt beim Laden einen Kontext mit + * registerSoftwareSignature() und kann darüber den Scanner erweitern. + */ +export default { + name: "example-signatures", + version: "1.0.0", + description: + "Erkennt Homebridge und Uptime Kuma zusätzlich zu den eingebauten Signaturen.", + + setup(ctx) { + ctx.registerSoftwareSignature({ + name: "Homebridge", + category: "Smart Home", + icon: "🏠", + matches: (input) => !!input.body && /homebridge/i.test(input.body), + }); + + ctx.registerSoftwareSignature({ + name: "Uptime Kuma", + category: "Monitoring", + icon: "📈", + matches: (input) => !!input.body && /uptime\s*kuma/i.test(input.body), + }); + }, +}; diff --git a/apps/backend/plugins/static-import/devices.example.json b/apps/backend/plugins/static-import/devices.example.json new file mode 100644 index 0000000..6098591 --- /dev/null +++ b/apps/backend/plugins/static-import/devices.example.json @@ -0,0 +1,9 @@ +[ + { + "hostname": "nas", + "ip": "192.168.1.20", + "mac": "AA:BB:CC:DD:EE:FF", + "manufacturer": "Synology", + "model": "DS920+" + } +] diff --git a/apps/backend/plugins/static-import/devices.json b/apps/backend/plugins/static-import/devices.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/apps/backend/plugins/static-import/devices.json @@ -0,0 +1 @@ +[] diff --git a/apps/backend/plugins/static-import/index.js b/apps/backend/plugins/static-import/index.js new file mode 100644 index 0000000..60ff833 --- /dev/null +++ b/apps/backend/plugins/static-import/index.js @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const currentDir = dirname(fileURLToPath(import.meta.url)); + +/** + * Beispiel-Plugin: importiert Geräte aus einer lokalen devices.json neben + * diesem Plugin. Zeigt das importDevices()-Pattern für eigene Datenquellen + * (z. B. ein Inventar-Export oder eine externe API) – im Unterschied zu den + * Netzwerk-Scannern rein datengetrieben, kein eigener Netzwerkzugriff nötig. + * + * Standardmäßig ist devices.json leer, damit beim ersten Start keine + * Fantasiegeräte auftauchen. Zum Ausprobieren einfach Einträge ergänzen und + * unter Admin -> Plugins auf "Jetzt importieren" klicken. + */ +export default { + name: "static-import", + version: "1.0.0", + description: "Importiert Geräte aus plugins/static-import/devices.json.", + + async importDevices() { + try { + const raw = readFileSync(join(currentDir, "devices.json"), "utf-8"); + const entries = JSON.parse(raw); + + return entries.map((entry) => ({ + hostname: entry.hostname, + ip: entry.ip, + mac: entry.mac ?? undefined, + manufacturer: entry.manufacturer ?? undefined, + model: entry.model ?? undefined, + online: false, + source: "plugin", + })); + } catch { + return []; + } + }, +}; diff --git a/apps/backend/src/db/repositories/services.ts b/apps/backend/src/db/repositories/services.ts index ff9e8ec..871a9df 100644 --- a/apps/backend/src/db/repositories/services.ts +++ b/apps/backend/src/db/repositories/services.ts @@ -132,6 +132,8 @@ export interface ServiceScanInput { suggestedDisplayName: string; /** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird. */ suggestedCategory?: string | null; + /** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird (z. B. von einem Plugin bereitgestellt). */ + suggestedIcon?: string | null; } export interface ScanUpsertResult { @@ -180,7 +182,7 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult favorite: false, order: 0, alias: "[]", - icon: null, + icon: input.suggestedIcon ?? null, hostname: input.hostname, url: input.url, https: input.https, diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index eae7b49..f9af819 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -8,6 +8,8 @@ import { serviceRoutes } from "./routes/services.js"; 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 { loadPlugins } from "./plugins/loader.js"; const PORT = Number(process.env.PORT ?? 3001); const HOST = process.env.HOST ?? "0.0.0.0"; @@ -29,12 +31,16 @@ async function main() { ensureSchema(); + const plugins = await loadPlugins(); + app.log.info(`${plugins.length} Plugin(s) geladen`); + await app.register(healthRoutes); await app.register(deviceRoutes); await app.register(serviceRoutes); await app.register(categoryRoutes); await app.register(scanRoutes); await app.register(logRoutes); + await app.register(pluginRoutes); app.get("/", async () => { return { name: "LaunchPad API", status: "running" }; diff --git a/apps/backend/src/plugins/loader.ts b/apps/backend/src/plugins/loader.ts new file mode 100644 index 0000000..a398c06 --- /dev/null +++ b/apps/backend/src/plugins/loader.ts @@ -0,0 +1,71 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { registerSoftwareSignature } from "../scanner/softwareDetection.js"; +import type { LaunchPadPlugin } from "./types.js"; + +const currentDir = path.dirname(fileURLToPath(import.meta.url)); + +// apps/backend/src/plugins/loader.ts (bzw. dist/plugins/loader.js) -> das +// Plugin-Verzeichnis liegt direkt neben src/dist. Im Docker-Image entspricht +// das /app/plugins (siehe docker-compose.yml Bind-Mount), lokal +// apps/backend/plugins – Plugins lassen sich damit ohne Rebuild hinzufügen. +const PLUGINS_DIR = path.resolve(currentDir, "../../plugins"); + +export interface LoadedPlugin { + plugin: LaunchPadPlugin; + capabilities: string[]; +} + +const loadedPlugins: LoadedPlugin[] = []; + +export async function loadPlugins(): Promise { + loadedPlugins.length = 0; + + if (!existsSync(PLUGINS_DIR)) { + return loadedPlugins; + } + + const entries = readdirSync(PLUGINS_DIR).filter((entry) => { + const full = path.join(PLUGINS_DIR, entry); + return statSync(full).isDirectory(); + }); + + for (const entry of entries) { + const entryPoint = path.join(PLUGINS_DIR, entry, "index.js"); + if (!existsSync(entryPoint)) continue; + + try { + const imported = await import(pathToFileURL(entryPoint).href); + const plugin: LaunchPadPlugin | undefined = imported.default ?? imported.plugin; + + if (!plugin || !plugin.name || !plugin.version) { + console.warn(`Plugin-Ordner "${entry}" liefert kein gültiges Plugin-Objekt, wird übersprungen.`); + continue; + } + + if (plugin.setup) { + await plugin.setup({ registerSoftwareSignature }); + } + + const capabilities: string[] = []; + if (plugin.setup) capabilities.push("scanner"); + if (plugin.importDevices) capabilities.push("device-import"); + + loadedPlugins.push({ plugin, capabilities }); + console.log(`Plugin geladen: ${plugin.name}@${plugin.version} [${capabilities.join(", ") || "keine Capabilities"}]`); + } catch (err) { + console.error(`Plugin "${entry}" konnte nicht geladen werden:`, err); + } + } + + return loadedPlugins; +} + +export function getLoadedPlugins(): LoadedPlugin[] { + return loadedPlugins; +} + +export function getPlugin(name: string): LoadedPlugin | undefined { + return loadedPlugins.find((p) => p.plugin.name === name); +} diff --git a/apps/backend/src/plugins/types.ts b/apps/backend/src/plugins/types.ts new file mode 100644 index 0000000..b4d02a0 --- /dev/null +++ b/apps/backend/src/plugins/types.ts @@ -0,0 +1,22 @@ +import type { DeviceCreateInput } from "@launchpad/shared"; +import type { SoftwareSignature } from "../scanner/softwareDetection.js"; + +export interface PluginContext { + /** Erweitert die Softwareerkennung des Scanners um eine eigene Signatur. */ + registerSoftwareSignature: (signature: SoftwareSignature) => void; +} + +/** + * Vertrag, den jedes Plugin erfüllt. Ein Plugin ist ein Ordner unter + * apps/backend/plugins// mit einer index.js, deren Default-Export + * dieses Objekt ist. Siehe apps/backend/plugins/README.md. + */ +export interface LaunchPadPlugin { + name: string; + version: string; + description?: string; + /** Wird einmal beim Start aufgerufen, z. B. um Scanner-Signaturen zu registrieren. */ + setup?: (ctx: PluginContext) => void | Promise; + /** Optional: eigene Geräte-Importquelle (z. B. Inventarliste, externe API). */ + importDevices?: () => Promise; +} diff --git a/apps/backend/src/routes/plugins.ts b/apps/backend/src/routes/plugins.ts new file mode 100644 index 0000000..2b976a7 --- /dev/null +++ b/apps/backend/src/routes/plugins.ts @@ -0,0 +1,61 @@ +import type { FastifyInstance } from "fastify"; +import * as deviceRepo from "../db/repositories/devices.js"; +import * as logRepo from "../db/repositories/logs.js"; +import { getLoadedPlugins, getPlugin } from "../plugins/loader.js"; + +export async function pluginRoutes(app: FastifyInstance): Promise { + app.get("/api/plugins", async () => { + return getLoadedPlugins().map(({ plugin, capabilities }) => ({ + name: plugin.name, + version: plugin.version, + description: plugin.description, + capabilities, + })); + }); + + // Manueller Geräte-Import über ein Plugin – wie Scans nie automatisch, + // nur per Knopfdruck (Admin -> Plugins). + app.post("/api/plugins/:name/import", async (request, reply) => { + const { name } = request.params as { name: string }; + const loaded = getPlugin(name); + + if (!loaded) { + return reply.code(404).send({ error: "Plugin nicht gefunden" }); + } + if (!loaded.plugin.importDevices) { + return reply.code(400).send({ error: "Dieses Plugin unterstützt keinen Geräte-Import" }); + } + + try { + const devices = await loaded.plugin.importDevices(); + const imported = devices.map((d) => + deviceRepo.upsertDeviceFromScan({ + hostname: d.hostname, + ip: d.ip, + mac: d.mac, + manufacturer: d.manufacturer, + model: d.model, + online: d.online ?? false, + source: "plugin", + }) + ); + + logRepo.logScan({ + type: "device", + level: "info", + message: `Plugin "${name}": ${imported.length} Gerät(e) importiert`, + }); + + return { imported: imported.length, devices: imported }; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + logRepo.logScan({ + type: "device", + level: "error", + message: `Plugin "${name}": Import fehlgeschlagen – ${detail}`, + }); + request.log.error(err); + return reply.code(500).send({ error: "Import fehlgeschlagen", detail }); + } + }); +} diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index c5866ea..0997646 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -33,6 +33,7 @@ export async function scanRoutes(app: FastifyInstance): Promise { description: found.description, suggestedDisplayName: found.suggestedDisplayName, suggestedCategory: found.category, + suggestedIcon: found.icon, }) ); diff --git a/apps/backend/src/scanner/networkScanner.ts b/apps/backend/src/scanner/networkScanner.ts index accc421..a0bdf8f 100644 --- a/apps/backend/src/scanner/networkScanner.ts +++ b/apps/backend/src/scanner/networkScanner.ts @@ -17,6 +17,7 @@ export interface DiscoveredService { description?: string; suggestedDisplayName: string; category?: string; + icon?: string; } /** @@ -59,6 +60,7 @@ export async function scanDeviceServices( description: software ? `${software.name} (automatisch erkannt)` : probe.title, suggestedDisplayName: software?.name ?? probe.title ?? `${address}:${port}`, category: software?.category, + icon: software?.icon, }); } diff --git a/apps/backend/src/scanner/softwareDetection.ts b/apps/backend/src/scanner/softwareDetection.ts index c81b41b..d5f1ccb 100644 --- a/apps/backend/src/scanner/softwareDetection.ts +++ b/apps/backend/src/scanner/softwareDetection.ts @@ -8,6 +8,8 @@ export interface SoftwareSignature { name: string; category: string; matches: (input: SoftwareSignatureInput) => boolean; + /** Optional: Emoji oder Icon-URL, von Plugins bereitgestellt (siehe apps/backend/plugins). */ + icon?: string; } function bodyContains(input: SoftwareSignatureInput, pattern: RegExp): boolean { @@ -44,6 +46,21 @@ export const SOFTWARE_SIGNATURES: SoftwareSignature[] = [ { name: "UniFi Network", category: "Netzwerk", matches: (i) => bodyContains(i, /unifi/i) }, ]; -export function detectSoftware(input: SoftwareSignatureInput): SoftwareSignature | null { - return SOFTWARE_SIGNATURES.find((signature) => signature.matches(input)) ?? null; +/** + * Zusätzliche Signaturen, die von Plugins zur Laufzeit registriert werden + * (siehe apps/backend/src/plugins/loader.ts). Eingebaute Signaturen haben + * Vorrang, falls beide zufällig auf denselben Dienst passen. + */ +const pluginSignatures: SoftwareSignature[] = []; + +export function registerSoftwareSignature(signature: SoftwareSignature): void { + pluginSignatures.push(signature); +} + +export function detectSoftware(input: SoftwareSignatureInput): SoftwareSignature | null { + return ( + SOFTWARE_SIGNATURES.find((signature) => signature.matches(input)) ?? + pluginSignatures.find((signature) => signature.matches(input)) ?? + null + ); } diff --git a/apps/frontend/src/hooks/usePlugins.ts b/apps/frontend/src/hooks/usePlugins.ts new file mode 100644 index 0000000..54c5506 --- /dev/null +++ b/apps/frontend/src/hooks/usePlugins.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; +import type { PluginInfo } from "@launchpad/shared"; + +async function fetchPlugins(): Promise { + const res = await fetch("/api/plugins"); + if (!res.ok) { + throw new Error(`Plugins konnten nicht geladen werden (HTTP ${res.status})`); + } + return res.json(); +} + +export function usePlugins() { + return useQuery({ + queryKey: ["plugins"], + queryFn: fetchPlugins, + }); +} diff --git a/apps/frontend/src/routes/admin/PluginsPage.tsx b/apps/frontend/src/routes/admin/PluginsPage.tsx index ab30fc9..02a6adb 100644 --- a/apps/frontend/src/routes/admin/PluginsPage.tsx +++ b/apps/frontend/src/routes/admin/PluginsPage.tsx @@ -1,18 +1,109 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@launchpad/ui"; +import type { PluginInfo } from "@launchpad/shared"; +import { usePlugins } from "../../hooks/usePlugins.js"; import { AdminPageHeader } from "./AdminPageHeader.js"; -export function PluginsPage() { +const CAPABILITY_LABELS: Record = { + scanner: "Erweitert die Softwareerkennung", + "device-import": "Kann Geräte importieren", +}; + +async function importFromPlugin(name: string) { + const res = await fetch(`/api/plugins/${name}/import`, { method: "POST" }); + const body = await res.json(); + if (!res.ok) { + throw new Error(body.detail ?? body.error ?? `Import fehlgeschlagen (HTTP ${res.status})`); + } + return body as { imported: number }; +} + +function PluginCard({ plugin }: { plugin: PluginInfo }) { + const queryClient = useQueryClient(); + const [message, setMessage] = useState(null); + + const importMutation = useMutation({ + mutationFn: () => importFromPlugin(plugin.name), + onSuccess: (result) => { + setMessage(`${result.imported} Gerät(e) importiert.`); + queryClient.invalidateQueries({ queryKey: ["devices"] }); + queryClient.invalidateQueries({ queryKey: ["logs"] }); + }, + onError: (err: Error) => setMessage(err.message), + }); + 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. -

+
+
+
+

+ {plugin.name} v{plugin.version} +

+ {plugin.description ? ( +

{plugin.description}

+ ) : null} +
+ +
+ {plugin.capabilities.length === 0 ? ( + Keine Capabilities + ) : ( + plugin.capabilities.map((cap) => ( + + {CAPABILITY_LABELS[cap] ?? cap} + + )) + )} +
+ + {plugin.capabilities.includes("device-import") ? ( +
+ + {message ? ( +

{message}

+ ) : null} +
+ ) : null} +
+ ); +} + +export function PluginsPage() { + const { data: plugins, isLoading, isError } = usePlugins(); + + return ( +
+ + + {isLoading ? ( +

Lade Plugins …

+ ) : isError ? ( +

Plugins konnten nicht geladen werden.

+ ) : plugins && plugins.length > 0 ? ( +
+ {plugins.map((plugin) => ( + + ))} +
+ ) : ( +
+

Keine Plugins geladen.

+

+ Siehe apps/backend/plugins/README.md{" "} + für eine Anleitung, wie du ein eigenes Plugin schreibst. +

+
+ )}
); } diff --git a/docker-compose.yml b/docker-compose.yml index 13b5f57..60eeca4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: - NODE_ENV=production volumes: - launchpad-data:/data + - ./apps/backend/plugins:/app/plugins:ro ports: - "3001:3001" diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f0fe55e..278846b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -92,7 +92,38 @@ Grundgerüst aus Commit 1. > Docker-Build-Simulation durchlaufen. Die tatsächliche "Zum Homescreen hinzufügen"- > Installation konnte mangels Browser in dieser Umgebung nicht getestet werden. -## Commit 8 — Plugin-System +## ✅ Commit 8 — Plugin-System (erledigt) -- Plugin-Schnittstelle zum Registrieren von Scannern, Import von Geräten, - Erweitern von Menüs, Bereitstellen von Icons +- Plugins liegen als Ordner unter `apps/backend/plugins/*`, jeweils mit einer + `index.js` (reines ESM, kein Build-Schritt). Werden beim Backend-Start + geladen (`apps/backend/src/plugins/loader.ts`) und per Docker-Volume + eingebunden – neue Plugins brauchen nur einen Container-Neustart, kein + Image-Rebuild +- Plugin-Vertrag (`apps/backend/src/plugins/types.ts`): `setup(ctx)` zum + Registrieren zusätzlicher Softwareerkennung (inkl. Icon), `importDevices()` + für eigene Geräte-Importquellen +- Zwei funktionierende Beispiel-Plugins: `example-signatures` (Homebridge, + Uptime Kuma) und `static-import` (Geräte aus lokaler `devices.json`) +- `GET /api/plugins`, `POST /api/plugins/:name/import` – Import läuft wie + Scans ausschließlich manuell per Knopfdruck +- Frontend-Plugins-Seite zeigt echte geladene Plugins mit Capabilities und + Import-Button (ersetzt den ehrlichen Platzhalter aus Commit 6) +- Von neuen Plugins erkannte Software liefert ein Icon, das – wie `category` + seit Commit 5 – nur beim erstmaligen Anlegen eines Dienstes als Startwert + übernommen wird, nie nachträglich überschrieben + +> Verifiziert: beide Plugins laden nachweislich beim Start; ein echter Scan +> gegen einen Test-Server mit "Homebridge" im Response-Body wurde über die +> Plugin-Signatur erkannt, inkl. korrekt übernommenem Icon; Geräte-Import per +> Plugin getestet (`source: "plugin"`); Fehlerfälle (unbekanntes Plugin → 404, +> Plugin ohne Import-Fähigkeit → 400) geprüft; Docker-Build-Simulation +> bestanden, dabei auch verifiziert, dass ein fehlendes Plugin-Verzeichnis +> nicht zum Absturz führt, sondern nur zu einer leeren Liste. + +> **Bewusst nicht umgesetzt:** eigene Admin-Routen/Menüpunkte pro Plugin +> (alle Plugins erscheinen gesammelt auf einer Seite) und Sandboxing +> (Plugin-Code läuft mit vollem Zugriff im Backend-Prozess – nur Plugins aus +> vertrauenswürdiger Quelle einbinden, siehe `apps/backend/plugins/README.md`). + +Damit ist die komplette in der ursprünglichen Projektübergabe beschriebene +Funktionalität umgesetzt. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a6e3158..6322312 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,6 +5,8 @@ * als auch vom Frontend (apps/frontend) verwendet werden. */ +import { deviceSourceValues } from "./schemas.js"; + export * from "./schemas.js"; @@ -20,7 +22,7 @@ export interface Device { lastScan: string | null; // ISO-8601 Zeitstempel } -export type DeviceSource = "fritzbox" | "dns" | "http" | "https" | "portscan" | "manual"; +export type DeviceSource = (typeof deviceSourceValues)[number]; export interface Service { id: string; @@ -61,6 +63,14 @@ export interface ScanLogEntry { createdAt: string; } +export interface PluginInfo { + name: string; + version: string; + description?: string; + /** z. B. "scanner" (registriert Softwareerkennung), "device-import" */ + capabilities: string[]; +} + /** * Ranking-Stufen für die Suche, gemäß Spezifikation: * 1. Displayname beginnt mit Suchtext diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 596892f..cb315b1 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -7,6 +7,7 @@ export const deviceSourceValues = [ "https", "portscan", "manual", + "plugin", ] as const; export const DeviceSourceSchema = z.enum(deviceSourceValues);