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