generated from Dicken/dickendock
Commit 8: Plugin-System (Scanner erweitern, Geräte importieren, Icons)
This commit is contained in:
11
README.md
11
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
|
- ✅ PWA: installierbar (Manifest + Icons für Android/iOS/Desktop), Service
|
||||||
Worker mit App-Shell-Precaching, `/api/*` läuft offline über den letzten
|
Worker mit App-Shell-Precaching, `/api/*` läuft offline über den letzten
|
||||||
Cache-Stand (NetworkFirst, 3s-Timeout)
|
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)
|
- shadcn/ui, React Hook Form (aktuell einfache kontrollierte Formulare)
|
||||||
|
- Eigene Admin-Routen/Menüpunkte pro Plugin (aktuell gesammelt auf einer Seite)
|
||||||
|
|
||||||
### Umgebungsvariablen (.env)
|
### Umgebungsvariablen (.env)
|
||||||
|
|
||||||
@@ -143,6 +147,9 @@ POST /api/scan/fritzbox Liest Geräteliste der FritzBox per TR-064
|
|||||||
(erfordert FRITZBOX_HOST/USERNAME/PASSWORD)
|
(erfordert FRITZBOX_HOST/USERNAME/PASSWORD)
|
||||||
|
|
||||||
GET /api/logs optional ?limit= (Default 100, Max 500)
|
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
|
## Frontend-Routen
|
||||||
|
|||||||
78
apps/backend/plugins/README.md
Normal file
78
apps/backend/plugins/README.md
Normal file
@@ -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).
|
||||||
29
apps/backend/plugins/example-signatures/index.js
Normal file
29
apps/backend/plugins/example-signatures/index.js
Normal file
@@ -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),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
9
apps/backend/plugins/static-import/devices.example.json
Normal file
9
apps/backend/plugins/static-import/devices.example.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"hostname": "nas",
|
||||||
|
"ip": "192.168.1.20",
|
||||||
|
"mac": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"manufacturer": "Synology",
|
||||||
|
"model": "DS920+"
|
||||||
|
}
|
||||||
|
]
|
||||||
1
apps/backend/plugins/static-import/devices.json
Normal file
1
apps/backend/plugins/static-import/devices.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
40
apps/backend/plugins/static-import/index.js
Normal file
40
apps/backend/plugins/static-import/index.js
Normal file
@@ -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 [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -132,6 +132,8 @@ export interface ServiceScanInput {
|
|||||||
suggestedDisplayName: string;
|
suggestedDisplayName: string;
|
||||||
/** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird. */
|
/** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird. */
|
||||||
suggestedCategory?: string | null;
|
suggestedCategory?: string | null;
|
||||||
|
/** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird (z. B. von einem Plugin bereitgestellt). */
|
||||||
|
suggestedIcon?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScanUpsertResult {
|
export interface ScanUpsertResult {
|
||||||
@@ -180,7 +182,7 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult
|
|||||||
favorite: false,
|
favorite: false,
|
||||||
order: 0,
|
order: 0,
|
||||||
alias: "[]",
|
alias: "[]",
|
||||||
icon: null,
|
icon: input.suggestedIcon ?? null,
|
||||||
hostname: input.hostname,
|
hostname: input.hostname,
|
||||||
url: input.url,
|
url: input.url,
|
||||||
https: input.https,
|
https: input.https,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { serviceRoutes } from "./routes/services.js";
|
|||||||
import { categoryRoutes } from "./routes/categories.js";
|
import { categoryRoutes } from "./routes/categories.js";
|
||||||
import { scanRoutes } from "./routes/scan.js";
|
import { scanRoutes } from "./routes/scan.js";
|
||||||
import { logRoutes } from "./routes/logs.js";
|
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 PORT = Number(process.env.PORT ?? 3001);
|
||||||
const HOST = process.env.HOST ?? "0.0.0.0";
|
const HOST = process.env.HOST ?? "0.0.0.0";
|
||||||
@@ -29,12 +31,16 @@ async function main() {
|
|||||||
|
|
||||||
ensureSchema();
|
ensureSchema();
|
||||||
|
|
||||||
|
const plugins = await loadPlugins();
|
||||||
|
app.log.info(`${plugins.length} Plugin(s) geladen`);
|
||||||
|
|
||||||
await app.register(healthRoutes);
|
await app.register(healthRoutes);
|
||||||
await app.register(deviceRoutes);
|
await app.register(deviceRoutes);
|
||||||
await app.register(serviceRoutes);
|
await app.register(serviceRoutes);
|
||||||
await app.register(categoryRoutes);
|
await app.register(categoryRoutes);
|
||||||
await app.register(scanRoutes);
|
await app.register(scanRoutes);
|
||||||
await app.register(logRoutes);
|
await app.register(logRoutes);
|
||||||
|
await app.register(pluginRoutes);
|
||||||
|
|
||||||
app.get("/", async () => {
|
app.get("/", async () => {
|
||||||
return { name: "LaunchPad API", status: "running" };
|
return { name: "LaunchPad API", status: "running" };
|
||||||
|
|||||||
71
apps/backend/src/plugins/loader.ts
Normal file
71
apps/backend/src/plugins/loader.ts
Normal file
@@ -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<LoadedPlugin[]> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
22
apps/backend/src/plugins/types.ts
Normal file
22
apps/backend/src/plugins/types.ts
Normal file
@@ -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/<name>/ 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<void>;
|
||||||
|
/** Optional: eigene Geräte-Importquelle (z. B. Inventarliste, externe API). */
|
||||||
|
importDevices?: () => Promise<DeviceCreateInput[]>;
|
||||||
|
}
|
||||||
61
apps/backend/src/routes/plugins.ts
Normal file
61
apps/backend/src/routes/plugins.ts
Normal file
@@ -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<void> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
description: found.description,
|
description: found.description,
|
||||||
suggestedDisplayName: found.suggestedDisplayName,
|
suggestedDisplayName: found.suggestedDisplayName,
|
||||||
suggestedCategory: found.category,
|
suggestedCategory: found.category,
|
||||||
|
suggestedIcon: found.icon,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface DiscoveredService {
|
|||||||
description?: string;
|
description?: string;
|
||||||
suggestedDisplayName: string;
|
suggestedDisplayName: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,6 +60,7 @@ export async function scanDeviceServices(
|
|||||||
description: software ? `${software.name} (automatisch erkannt)` : probe.title,
|
description: software ? `${software.name} (automatisch erkannt)` : probe.title,
|
||||||
suggestedDisplayName: software?.name ?? probe.title ?? `${address}:${port}`,
|
suggestedDisplayName: software?.name ?? probe.title ?? `${address}:${port}`,
|
||||||
category: software?.category,
|
category: software?.category,
|
||||||
|
icon: software?.icon,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export interface SoftwareSignature {
|
|||||||
name: string;
|
name: string;
|
||||||
category: string;
|
category: string;
|
||||||
matches: (input: SoftwareSignatureInput) => boolean;
|
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 {
|
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) },
|
{ 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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
17
apps/frontend/src/hooks/usePlugins.ts
Normal file
17
apps/frontend/src/hooks/usePlugins.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { PluginInfo } from "@launchpad/shared";
|
||||||
|
|
||||||
|
async function fetchPlugins(): Promise<PluginInfo[]> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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";
|
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||||
|
|
||||||
export function PluginsPage() {
|
const CAPABILITY_LABELS: Record<string, string> = {
|
||||||
|
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<string | null>(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 (
|
return (
|
||||||
<div>
|
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
|
||||||
<AdminPageHeader title="Plugins" />
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="rounded-2xl border border-dashed border-black/15 p-8 text-center dark:border-white/15">
|
<div>
|
||||||
<p className="text-black/60 dark:text-white/60">
|
<h2 className="font-medium text-black dark:text-white">
|
||||||
Das Plugin-System (Scanner registrieren, Geräte importieren, Menüs erweitern,
|
{plugin.name} <span className="text-black/40 dark:text-white/40">v{plugin.version}</span>
|
||||||
Icons bereitstellen) ist noch nicht gebaut.
|
</h2>
|
||||||
</p>
|
{plugin.description ? (
|
||||||
<p className="mt-2 text-sm text-black/40 dark:text-white/40">
|
<p className="mt-1 text-sm text-black/50 dark:text-white/50">{plugin.description}</p>
|
||||||
Geplant als eigener Commit – siehe <code className="rounded bg-black/5 px-1 dark:bg-white/10">docs/ROADMAP.md</code>.
|
) : null}
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{plugin.capabilities.length === 0 ? (
|
||||||
|
<span className="text-xs text-black/30 dark:text-white/30">Keine Capabilities</span>
|
||||||
|
) : (
|
||||||
|
plugin.capabilities.map((cap) => (
|
||||||
|
<span
|
||||||
|
key={cap}
|
||||||
|
className="rounded-full bg-black/5 px-2.5 py-1 text-xs text-black/60 dark:bg-white/10 dark:text-white/60"
|
||||||
|
>
|
||||||
|
{CAPABILITY_LABELS[cap] ?? cap}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{plugin.capabilities.includes("device-import") ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button size="sm" variant="primary" onClick={() => importMutation.mutate()} disabled={importMutation.isPending}>
|
||||||
|
{importMutation.isPending ? "Importiere …" : "Jetzt importieren"}
|
||||||
|
</Button>
|
||||||
|
{message ? (
|
||||||
|
<p className="mt-2 text-xs text-black/50 dark:text-white/50">{message}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginsPage() {
|
||||||
|
const { data: plugins, isLoading, isError } = usePlugins();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AdminPageHeader
|
||||||
|
title="Plugins"
|
||||||
|
description="Plugins liegen als Ordner unter apps/backend/plugins/ und werden beim Start des Backends geladen – kein Rebuild nötig, nur ein Container-Neustart."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-black/40 dark:text-white/40">Lade Plugins …</p>
|
||||||
|
) : isError ? (
|
||||||
|
<p className="text-sm text-red-500">Plugins konnten nicht geladen werden.</p>
|
||||||
|
) : plugins && plugins.length > 0 ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{plugins.map((plugin) => (
|
||||||
|
<PluginCard key={plugin.name} plugin={plugin} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-dashed border-black/15 p-8 text-center dark:border-white/15">
|
||||||
|
<p className="text-black/60 dark:text-white/60">Keine Plugins geladen.</p>
|
||||||
|
<p className="mt-2 text-sm text-black/40 dark:text-white/40">
|
||||||
|
Siehe <code className="rounded bg-black/5 px-1 dark:bg-white/10">apps/backend/plugins/README.md</code>{" "}
|
||||||
|
für eine Anleitung, wie du ein eigenes Plugin schreibst.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ services:
|
|||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
volumes:
|
volumes:
|
||||||
- launchpad-data:/data
|
- launchpad-data:/data
|
||||||
|
- ./apps/backend/plugins:/app/plugins:ro
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,38 @@ Grundgerüst aus Commit 1.
|
|||||||
> Docker-Build-Simulation durchlaufen. Die tatsächliche "Zum Homescreen hinzufügen"-
|
> Docker-Build-Simulation durchlaufen. Die tatsächliche "Zum Homescreen hinzufügen"-
|
||||||
> Installation konnte mangels Browser in dieser Umgebung nicht getestet werden.
|
> 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,
|
- Plugins liegen als Ordner unter `apps/backend/plugins/*`, jeweils mit einer
|
||||||
Erweitern von Menüs, Bereitstellen von Icons
|
`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.
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* als auch vom Frontend (apps/frontend) verwendet werden.
|
* als auch vom Frontend (apps/frontend) verwendet werden.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { deviceSourceValues } from "./schemas.js";
|
||||||
|
|
||||||
export * from "./schemas.js";
|
export * from "./schemas.js";
|
||||||
|
|
||||||
|
|
||||||
@@ -20,7 +22,7 @@ export interface Device {
|
|||||||
lastScan: string | null; // ISO-8601 Zeitstempel
|
lastScan: string | null; // ISO-8601 Zeitstempel
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeviceSource = "fritzbox" | "dns" | "http" | "https" | "portscan" | "manual";
|
export type DeviceSource = (typeof deviceSourceValues)[number];
|
||||||
|
|
||||||
export interface Service {
|
export interface Service {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -61,6 +63,14 @@ export interface ScanLogEntry {
|
|||||||
createdAt: string;
|
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:
|
* Ranking-Stufen für die Suche, gemäß Spezifikation:
|
||||||
* 1. Displayname beginnt mit Suchtext
|
* 1. Displayname beginnt mit Suchtext
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const deviceSourceValues = [
|
|||||||
"https",
|
"https",
|
||||||
"portscan",
|
"portscan",
|
||||||
"manual",
|
"manual",
|
||||||
|
"plugin",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const DeviceSourceSchema = z.enum(deviceSourceValues);
|
export const DeviceSourceSchema = z.enum(deviceSourceValues);
|
||||||
|
|||||||
Reference in New Issue
Block a user