Commit 8: Plugin-System (Scanner erweitern, Geräte importieren, Icons)

This commit is contained in:
2026-07-19 10:14:43 +02:00
parent 6a7eb3dfe5
commit 986de50963
20 changed files with 517 additions and 20 deletions

View 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,
});
}

View File

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