generated from Dicken/dickendock
Commit 8: Plugin-System (Scanner erweitern, Geräte importieren, Icons)
This commit is contained in:
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;
|
||||
/** 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
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<AdminPageHeader title="Plugins" />
|
||||
<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">
|
||||
Das Plugin-System (Scanner registrieren, Geräte importieren, Menüs erweitern,
|
||||
Icons bereitstellen) ist noch nicht gebaut.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-black/40 dark:text-white/40">
|
||||
Geplant als eigener Commit – siehe <code className="rounded bg-black/5 px-1 dark:bg-white/10">docs/ROADMAP.md</code>.
|
||||
</p>
|
||||
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-medium text-black dark:text-white">
|
||||
{plugin.name} <span className="text-black/40 dark:text-white/40">v{plugin.version}</span>
|
||||
</h2>
|
||||
{plugin.description ? (
|
||||
<p className="mt-1 text-sm text-black/50 dark:text-white/50">{plugin.description}</p>
|
||||
) : null}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user