feat: Phase 3 – Modul-System (Manifest, ZIP-Installation, Lifecycle, Prozess-Manager)
This commit is contained in:
352
apps/platform-frontend/src/features/admin/modules-page.tsx
Normal file
352
apps/platform-frontend/src/features/admin/modules-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user