feat: Phase 3 – Modul-System (Manifest, ZIP-Installation, Lifecycle, Prozess-Manager)

This commit is contained in:
MPM Dev
2026-10-07 15:37:09 +02:00
parent e87dc1f836
commit 4bbca21fd6
31 changed files with 2055 additions and 19 deletions

View File

@@ -0,0 +1,352 @@
import { type ReactNode, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '../../components/ui/button';
import { Modal } from '../../components/ui/modal';
import { useToast } from '../../components/ui/toast';
import { ApiError } from '../../lib/api-client';
import {
checkModuleHealth,
fetchModules,
installModule,
removeModule,
restartModule,
setModuleEnabled,
startModule,
stopModule,
} from '../../lib/modules-api';
import type { Module, ModuleStatus } from '../../lib/schemas';
/** Status-Badge-Farben je Lifecycle-Zustand. */
function statusVariant(status: ModuleStatus): 'success' | 'warning' | 'danger' | 'neutral' | 'info' {
switch (status) {
case 'RUNNING':
return 'success';
case 'STARTING':
case 'STOPPING':
return 'warning';
case 'ERROR':
return 'danger';
case 'DISABLED':
return 'neutral';
default:
return 'info';
}
}
/** Bestätigungsdialog: Modul entfernen. */
function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () => void }): ReactNode {
const { showToast } = useToast();
const queryClient = useQueryClient();
const [formError, setFormError] = useState<string | null>(null);
const removeMutation = useMutation({
mutationFn: () => removeModule(module.id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['modules'] });
showToast('success', `Modul "${module.name}" wurde entfernt`);
onClose();
},
onError: (error) => {
setFormError(error instanceof ApiError ? error.message : 'Entfernen fehlgeschlagen');
},
});
return (
<Modal
open
title="Modul entfernen"
description={`Möchten Sie "${module.name}" (Version ${module.version}) wirklich entfernen? Dateien und Registrierung werden gelöscht.`}
onClose={onClose}
>
{formError && (
<p role="alert" className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{formError}
</p>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button
variant="danger"
loading={removeMutation.isPending}
onClick={() => removeMutation.mutate()}
>
Endgültig entfernen
</Button>
</div>
</Modal>
);
}
/** Modul-Verwaltung (nur Admin): Installation, Lifecycle, Healthchecks. */
export function ModulesPage(): ReactNode {
const { showToast } = useToast();
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [removeTarget, setRemoveTarget] = useState<Module | null>(null);
const [healthResults, setHealthResults] = useState<Record<string, { healthy: boolean; detail: string }>>({});
const modulesQuery = useQuery({
queryKey: ['modules'],
queryFn: fetchModules,
refetchInterval: 15_000,
});
const invalidate = (): void => {
void queryClient.invalidateQueries({ queryKey: ['modules'] });
};
const installMutation = useMutation({
mutationFn: (file: File) => installModule(file),
onSuccess: (module) => {
showToast('success', `Modul "${module.name}" wurde installiert`);
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
invalidate();
},
onError: (error) => {
showToast('error', error instanceof ApiError ? error.message : 'Installation fehlgeschlagen');
},
});
const lifecycleMutation = useMutation({
mutationFn: async (input: { module: Module; action: 'start' | 'stop' | 'restart' | 'enable' | 'disable' }) => {
switch (input.action) {
case 'start':
return startModule(input.module.id);
case 'stop':
return stopModule(input.module.id);
case 'restart':
return restartModule(input.module.id);
case 'enable':
return setModuleEnabled(input.module.id, true);
case 'disable':
return setModuleEnabled(input.module.id, false);
}
},
onSuccess: (module, variables) => {
showToast('success', `Modul "${module.name}" – Aktion "${variables.action}" erfolgreich`);
invalidate();
},
onError: (error) => {
showToast('error', error instanceof ApiError ? error.message : 'Aktion fehlgeschlagen');
invalidate();
},
});
const healthMutation = useMutation({
mutationFn: (module: Module) => checkModuleHealth(module.id),
onSuccess: (result, module) => {
setHealthResults((current) => ({
...current,
[module.id]: { healthy: result.healthy, detail: result.detail },
}));
showToast(
result.healthy ? 'success' : 'error',
`Healthcheck "${module.name}": ${result.detail}`,
);
},
});
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>): void {
setSelectedFile(event.target.files?.[0] ?? null);
}
function handleInstall(): void {
if (selectedFile) {
installMutation.mutate(selectedFile);
}
}
return (
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Modulverwaltung</h1>
<p className="mt-1 text-sm text-slate-500">
Module installieren, starten, stoppen und entfernen.
</p>
</div>
{/* Installation */}
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-center gap-3">
<input
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleFileChange}
className="text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-brand-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-brand-700 hover:file:bg-brand-100"
aria-label="Modul-Paket (ZIP) auswählen"
/>
<Button
disabled={!selectedFile}
loading={installMutation.isPending}
onClick={handleInstall}
>
Installieren
</Button>
<p className="text-xs text-slate-500">
ZIP-Paket mit module.json (Manifest). Maximal 10 MB.
</p>
</div>
</div>
{/* Modul-Liste */}
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-white shadow-sm">
<table className="w-full min-w-[760px] text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
<th className="px-4 py-3 font-semibold">Modul</th>
<th className="px-4 py-3 font-semibold">Status</th>
<th className="px-4 py-3 font-semibold">URL</th>
<th className="px-4 py-3 font-semibold">Aktionen</th>
</tr>
</thead>
<tbody>
{modulesQuery.isLoading && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-slate-500">
Module werden geladen…
</td>
</tr>
)}
{modulesQuery.isError && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-red-600">
Module konnten nicht geladen werden.
</td>
</tr>
)}
{modulesQuery.data?.map((module) => (
<tr key={module.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-3">
<div className="font-medium text-slate-900">{module.name}</div>
<div className="text-xs text-slate-500">
{module.moduleId} · Version {module.version}
{module.author && ` · ${module.author}`}
</div>
{module.description && (
<div className="mt-0.5 text-xs text-slate-500">{module.description}</div>
)}
</td>
<td className="px-4 py-3">
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset
${
statusVariant(module.status) === 'success'
? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20'
: statusVariant(module.status) === 'warning'
? 'bg-amber-50 text-amber-700 ring-amber-600/20'
: statusVariant(module.status) === 'danger'
? 'bg-red-50 text-red-700 ring-red-600/20'
: statusVariant(module.status) === 'neutral'
? 'bg-slate-100 text-slate-600 ring-slate-500/20'
: 'bg-brand-50 text-brand-700 ring-brand-600/20'
}`}
>
{module.status}
</span>
{healthResults[module.id] && (
<div className="mt-1 text-xs text-slate-500">
Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}
</div>
)}
</td>
<td className="px-4 py-3">
<code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">
/{module.slug}
</code>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{module.status !== 'RUNNING' && module.enabled && (
<Button
size="sm"
variant="ghost"
loading={
lifecycleMutation.isPending &&
lifecycleMutation.variables?.module.id === module.id &&
lifecycleMutation.variables?.action === 'start'
}
onClick={() => lifecycleMutation.mutate({ module, action: 'start' })}
>
Start
</Button>
)}
{(module.status === 'RUNNING' || module.status === 'STARTING') && (
<Button
size="sm"
variant="ghost"
loading={
lifecycleMutation.isPending &&
lifecycleMutation.variables?.module.id === module.id &&
lifecycleMutation.variables?.action === 'stop'
}
onClick={() => lifecycleMutation.mutate({ module, action: 'stop' })}
>
Stop
</Button>
)}
<Button
size="sm"
variant="ghost"
loading={
lifecycleMutation.isPending &&
lifecycleMutation.variables?.module.id === module.id &&
lifecycleMutation.variables?.action === 'restart'
}
onClick={() => lifecycleMutation.mutate({ module, action: 'restart' })}
>
Restart
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => healthMutation.mutate(module)}
>
Health
</Button>
<Button
size="sm"
variant="ghost"
loading={
lifecycleMutation.isPending &&
lifecycleMutation.variables?.module.id === module.id &&
(lifecycleMutation.variables?.action === 'enable' ||
lifecycleMutation.variables?.action === 'disable')
}
onClick={() =>
lifecycleMutation.mutate({
module,
action: module.enabled ? 'disable' : 'enable',
})
}
>
{module.enabled ? 'Disable' : 'Enable'}
</Button>
<Button size="sm" variant="ghost" onClick={() => setRemoveTarget(module)}>
Remove
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{modulesQuery.data?.length === 0 && (
<p className="px-4 py-8 text-center text-slate-500">
Noch keine Module installiert.
</p>
)}
</div>
{removeTarget && (
<RemoveModuleModal module={removeTarget} onClose={() => setRemoveTarget(null)} />
)}
</div>
);
}