generated from Dicken/dickendock
Commit 8: Plugin-System (Scanner erweitern, Geräte importieren, Icons)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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" };
|
||||
|
||||
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,
|
||||
suggestedDisplayName: found.suggestedDisplayName,
|
||||
suggestedCategory: found.category,
|
||||
suggestedIcon: found.icon,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user