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

@@ -15,6 +15,7 @@ const NAV_ITEMS: NavItem[] = [
{ to: '/', label: 'Dashboard', icon: '⌂' },
{ to: '/profile', label: 'Mein Profil', icon: '👤' },
{ to: '/admin/users', label: 'Benutzer', icon: '👥', adminOnly: true },
{ to: '/admin/modules', label: 'Module', icon: '🧩', adminOnly: true },
{ to: '/admin/system', label: 'Systemstatus', icon: '⚙', adminOnly: true },
];

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

View File

@@ -0,0 +1,100 @@
import { apiRequest, ApiError } from './api-client';
import { moduleSchema, type Module } from './schemas';
/** Typsichere API-Funktionen für die Modul-Verwaltung. */
export async function fetchModules(): Promise<Module[]> {
const response = await apiRequest<{ modules: unknown[] }>('/api/v1/modules');
return response.modules.map((module) => moduleSchema.parse(module));
}
export async function installModule(file: File): Promise<Module> {
const formData = new FormData();
formData.append('package', file);
const headers: Record<string, string> = {};
const csrfToken = readCsrfToken();
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
const response = await fetch('/api/v1/modules/install', {
method: 'POST',
headers,
credentials: 'same-origin',
body: formData,
});
if (!response.ok) {
let message = 'Installation fehlgeschlagen';
let details: Record<string, string | string[]> | undefined;
try {
const errorBody = (await response.json()) as {
message?: string | string[];
details?: Record<string, string | string[]>;
};
if (typeof errorBody.message === 'string') {
message = errorBody.message;
} else if (Array.isArray(errorBody.message)) {
message = errorBody.message.join(', ');
}
details = errorBody.details;
} catch {
// Kein JSON in der Antwort – Standardmeldung verwenden.
}
throw new ApiError(response.status, message, details);
}
const response_ = (await response.json()) as { module: unknown };
return moduleSchema.parse(response_.module);
}
export async function startModule(id: string): Promise<Module> {
const response = await apiRequest<{ module: unknown }>(`/api/v1/modules/${id}/start`, {
method: 'POST',
});
return moduleSchema.parse(response.module);
}
export async function stopModule(id: string): Promise<Module> {
const response = await apiRequest<{ module: unknown }>(`/api/v1/modules/${id}/stop`, {
method: 'POST',
});
return moduleSchema.parse(response.module);
}
export async function restartModule(id: string): Promise<Module> {
const response = await apiRequest<{ module: unknown }>(`/api/v1/modules/${id}/restart`, {
method: 'POST',
});
return moduleSchema.parse(response.module);
}
export async function setModuleEnabled(id: string, enabled: boolean): Promise<Module> {
const response = await apiRequest<{ module: unknown }>(`/api/v1/modules/${id}/enabled`, {
method: 'PATCH',
body: { enabled },
});
return moduleSchema.parse(response.module);
}
export async function removeModule(id: string): Promise<void> {
await apiRequest(`/api/v1/modules/${id}`, { method: 'DELETE' });
}
export async function checkModuleHealth(
id: string,
): Promise<{ healthy: boolean; latencyMs: number; detail: string }> {
return apiRequest(`/api/v1/modules/${id}/health`);
}
/** Liest das CSRF-Token aus dem nicht-HttpOnly-Cookie. */
function readCsrfToken(): string | null {
for (const part of document.cookie.split(';')) {
const [name, ...value] = part.trim().split('=');
if (name === 'mpm_csrf') {
return decodeURIComponent(value.join('='));
}
}
return null;
}

View File

@@ -106,4 +106,33 @@ export const changePasswordSchema = z
message: 'Passwörter stimmen nicht überein',
path: ['confirmPassword'],
});
export type ChangePasswordDto = z.infer<typeof changePasswordSchema>;
export type ChangePasswordDto = z.infer<typeof changePasswordSchema>;
/** Modul-Lifecycle-Zustände (muss zum Backend passen). */
export const MODULE_STATUSES = [
'INSTALLED',
'STARTING',
'RUNNING',
'STOPPING',
'STOPPED',
'ERROR',
'DISABLED',
] as const;
export type ModuleStatus = (typeof MODULE_STATUSES)[number];
/** Modul-Datensatz der Admin-API (/api/v1/modules). */
export const moduleSchema = z.object({
id: z.string(),
moduleId: z.string(),
name: z.string(),
slug: z.string(),
version: z.string(),
description: z.string(),
author: z.string(),
status: z.enum(MODULE_STATUSES),
internalPort: z.number(),
healthcheckUrl: z.string(),
enabled: z.boolean(),
createdAt: z.string(),
});
export type Module = z.infer<typeof moduleSchema>;

View File

@@ -10,6 +10,7 @@ import { LoginPage } from './features/auth/login-page';
import { DashboardPage } from './features/dashboard/dashboard-page';
import { SystemStatusPage } from './features/admin/system-status-page';
import { UsersPage } from './features/admin/users-page';
import { ModulesPage } from './features/admin/modules-page';
import { ProfilePage } from './features/profile/profile-page';
import { ForbiddenPage, NotFoundPage } from './pages/error-pages';
import './index.css';
@@ -38,6 +39,10 @@ function AppRoutes(): ReactNode {
path="/admin/users"
element={<RequireAdmin><UsersPage /></RequireAdmin>}
/>
<Route
path="/admin/modules"
element={<RequireAdmin><ModulesPage /></RequireAdmin>}
/>
<Route
path="/admin/system"
element={<RequireAdmin><SystemStatusPage /></RequireAdmin>}