Files
mpm/apps/platform-frontend/src/features/admin/modules-page.tsx
2026-10-08 21:13:57 +02:00

711 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { type ReactNode, useEffect, useRef, useState } from 'react';
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Card } from '../../components/ui/card';
import { Label } from '../../components/ui/label';
import { Icon } from '../../components/ui/icon';
import { InputControl } from '../../components/ui/input';
import { Modal } from '../../components/ui/modal';
import { SelectControl } from '../../components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table';
import { useToast } from '../../components/ui/toast';
import { ApiError } from '../../lib/api-client';
import {
checkModuleHealth,
beginMarketplaceConnection,
disconnectMarketplaceProvider,
fetchMarketplaceRepositories,
fetchMarketplaceProviders,
fetchModules,
fetchModuleConfiguration,
installModule,
installMarketplaceRepository,
fetchMarketplaceUpdates,
updateMarketplaceModule,
removeModule,
restartModule,
setModuleEnabled,
saveModuleConfiguration,
startModule,
stopModule,
type MarketplaceProvider,
type MarketplaceUpdateState,
} 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';
}
}
function operationDiagnostic(error: unknown): string | undefined {
if (!(error instanceof ApiError)) return undefined;
const lines = [error.code ? `Fehlercode: ${error.code}` : null, error.diagnostic ?? null]
.filter((line): line is string => Boolean(line));
return lines.length > 0 ? lines.join('\n') : undefined;
}
function operationToastDetails(error: unknown): ReactNode {
const diagnostic = operationDiagnostic(error);
if (!diagnostic) return undefined;
return (
<details className="text-xs">
<summary className="cursor-pointer font-medium">Technische Details</summary>
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap font-mono">{diagnostic}</pre>
</details>
);
}
/** 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 [formDiagnostic, setFormDiagnostic] = useState<string | null>(null);
const removeMutation = useMutation({
mutationFn: () => removeModule(module.id),
onSuccess: async (result) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['modules'] }),
queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] }),
]);
if (result.cleanupWarning) {
showToast('error', `Modul "${module.name}" wurde entfernt, Dateien blieben zurück`, result.cleanupWarning);
} else {
showToast('success', `Modul "${module.name}" wurde entfernt`);
}
onClose();
},
onError: async (error) => {
setFormError(error instanceof ApiError ? error.message : 'Entfernen fehlgeschlagen');
setFormDiagnostic(operationDiagnostic(error) ?? null);
await queryClient.invalidateQueries({ queryKey: ['modules'] });
},
});
return (
<Modal
open
title="Modul entfernen"
description={`Möchten Sie "${module.name}" (Version ${module.version}) wirklich entfernen? App- und Datenbankcontainer, alle zugehörigen Datenvolumes, Dateien und Registrierung werden endgültig gelöscht. Gespeicherte Modul- und Kalenderdaten können danach nicht wiederhergestellt werden.`}
onClose={onClose}
>
{formError && (
<div role="alert" className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
<p>{formError}</p>
{formDiagnostic && <details className="mt-2 text-xs"><summary className="cursor-pointer font-medium">Technische Details</summary><pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap font-mono">{formDiagnostic}</pre></details>}
</div>
)}
<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>
);
}
function ModuleConfigurationModal({ module, onClose }: { module: Module; onClose: () => void }): ReactNode {
const { showToast } = useToast();
const queryClient = useQueryClient();
const [values, setValues] = useState<Record<string, string>>({});
const [clearKeys, setClearKeys] = useState<string[]>([]);
const [formError, setFormError] = useState<string | null>(null);
const configQuery = useQuery({
queryKey: ['module-configuration', module.id],
queryFn: () => fetchModuleConfiguration(module.id),
});
useEffect(() => {
if (configQuery.data) {
setValues(Object.fromEntries(configQuery.data.fields.map((field) => [field.key, field.value ?? ''])));
setClearKeys([]);
}
}, [configQuery.data]);
const saveMutation = useMutation({
mutationFn: () => saveModuleConfiguration(module.id, { values, clearKeys }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['modules'] });
await queryClient.invalidateQueries({ queryKey: ['module-configuration', module.id] });
showToast('success', `Konfiguration für „${module.name}“ gespeichert`);
onClose();
},
onError: (error) => setFormError(error instanceof ApiError ? error.message : 'Konfiguration konnte nicht gespeichert werden'),
});
const inputClass = 'mt-1 h-auto w-full rounded-lg border border-slate-300 px-3 py-2 text-sm';
return (
<Modal open title={`Konfiguration: ${module.name}`} description="Werte werden verschlüsselt gespeichert." onClose={onClose} panelClassName="max-w-2xl">
{configQuery.isLoading && <p className="text-sm text-slate-500">Konfiguration wird geladen…</p>}
{configQuery.isError && <p role="alert" className="text-sm text-red-600">Konfiguration konnte nicht geladen werden.</p>}
{configQuery.data && <form onSubmit={(event) => { event.preventDefault(); setFormError(null); saveMutation.mutate(); }}>
{module.status === 'RUNNING' && <p className="mb-4 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800">Das laufende Modul wird nach dem Speichern neu gestartet.</p>}
<div className="max-h-[60vh] space-y-4 overflow-y-auto pr-1">
{configQuery.data.fields.map((field) => <div key={field.key}>
<Label htmlFor={`config-${field.key}`} className="block text-sm font-medium text-slate-800">{field.label}{field.required && <span className="text-red-600"> *</span>}</Label>
{field.description && <p className="mt-0.5 text-xs text-slate-500">{field.description}</p>}
{field.type === 'boolean' ? <SelectControl id={`config-${field.key}`} className={inputClass} required={field.required} value={values[field.key] ?? ''} placeholder="Bitte auswählen" options={[{ value: 'true', label: 'Ja' }, { value: 'false', label: 'Nein' }]} onValueChange={(value) => setValues((current) => ({ ...current, [field.key]: value }))} /> : <InputControl id={`config-${field.key}`} className={inputClass} type={field.secret ? 'password' : field.type === 'url' ? 'url' : 'text'} required={field.required && (!field.secret || !field.isSet || clearKeys.includes(field.key))} value={values[field.key] ?? ''} placeholder={field.secret && field.isSet ? 'Gespeicherter Wert bleibt erhalten' : ''} autoComplete="new-password" onChange={(event) => { setValues((current) => ({ ...current, [field.key]: event.target.value })); setClearKeys((current) => current.filter((key) => key !== field.key)); }} />}
{field.secret && field.isSet && <Button variant="ghost" size="sm" type="button" className="mt-1 h-auto px-0 py-0 text-xs text-red-700 hover:bg-transparent hover:underline" onClick={() => setClearKeys((current) => current.includes(field.key) ? current.filter((key) => key !== field.key) : [...current, field.key])}>{clearKeys.includes(field.key) ? 'Löschen rückgängig machen' : 'Gespeicherten Wert löschen'}</Button>}
{clearKeys.includes(field.key) && <p className="text-xs text-red-700">Wird beim Speichern gelöscht.</p>}
</div>)}
</div>
{formError && <p role="alert" className="mt-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{formError}</p>}
<div className="mt-5 flex justify-end gap-2"><Button type="button" variant="secondary" onClick={onClose}>Abbrechen</Button><Button type="submit" loading={saveMutation.isPending}>Speichern</Button></div>
</form>}
</Modal>
);
}
function ModuleUpdateModal({ module, state, onClose }: { module: Module; state: MarketplaceUpdateState; onClose: () => void }): ReactNode {
const queryClient = useQueryClient();
const [branch, setBranch] = useState(state.branches[0]?.name ?? '');
const [formError, setFormError] = useState<string | null>(null);
const [completed, setCompleted] = useState<{ module: Module; branch: string; commit: string | null } | null>(null);
const updateMutation = useMutation({
mutationFn: () => updateMarketplaceModule(module.id, branch),
onSuccess: async (updatedModule) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['modules'] }),
queryClient.invalidateQueries({ queryKey: ['module-updates', module.id] }),
]);
setCompleted({ module: updatedModule, branch, commit: state.branches.find((item) => item.name === branch)?.commit ?? null });
},
onError: (error) => setFormError(error instanceof ApiError ? error.message : 'Modulupdate fehlgeschlagen'),
});
const selected = state.branches.find((item) => item.name === branch);
const handleClose = () => { if (!updateMutation.isPending) onClose(); };
return (
<Modal open title={completed ? 'Update erfolgreich' : `Update verfügbar: ${module.name}`}
description={completed ? `${completed.module.name} wurde erfolgreich aktualisiert.` : 'Wähle die getestete Branch für das Update aus.'}
onClose={handleClose}>
{completed ? <div className="space-y-5">
<div role="status" className="flex items-start gap-3 rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-emerald-900">
<Icon name="check" className="mt-0.5 h-5 w-5 shrink-0" />
<div className="min-w-0">
<p className="font-medium">Installation abgeschlossen</p>
<p className="mt-1 text-sm">Branch <span className="font-medium">{completed.branch}</span> ist jetzt installiert.</p>
{completed.commit && <p className="mt-1 break-all text-xs text-emerald-800">Commit {completed.commit}</p>}
</div>
</div>
<div className="flex justify-end"><Button onClick={handleClose}>Schließen</Button></div>
</div> : <div className="space-y-4">
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm">
<p><span className="font-medium">Installierte Branch:</span> {state.installedBranch}</p>
{state.installedCommit && <p className="mt-1 text-xs text-slate-500">Commit {state.installedCommit.slice(0, 12)}</p>}
</div>
<div>
<Label htmlFor="module-update-branch">Neue Branch</Label>
<SelectControl id="module-update-branch" className="mt-1" disabled={updateMutation.isPending} value={branch} onValueChange={(value) => { setBranch(value); setFormError(null); }}
options={state.branches.map((item) => ({ value: item.name, label: item.name }))} />
{selected && <p className="mt-1 text-xs text-slate-500">Commit {selected.commit.slice(0, 12)}</p>}
</div>
<p className="text-xs text-slate-500">Die Konfiguration und persistenten Daten bleiben erhalten. Bei einem fehlgeschlagenen Start stellt MPM die vorherige Modulversion wieder her.</p>
{updateMutation.isPending && <div className="space-y-2" role="status" aria-live="polite">
<div role="progressbar" aria-label="Modulupdate läuft" aria-valuemin={0} aria-valuemax={100} aria-valuetext="Installationsvorgang läuft" className="h-2 overflow-hidden rounded-full bg-brand-100">
<div className="mpm-indeterminate-progress h-full w-2/5 rounded-full bg-brand-600" />
</div>
<p className="text-sm font-medium text-slate-700">Update auf „{branch}“ wird installiert…</p>
<p className="text-xs text-slate-500">MPM aktualisiert das Modul und prüft anschließend den Start. Das kann einige Minuten dauern.</p>
</div>}
{formError && <p role="alert" className="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" disabled={updateMutation.isPending} onClick={handleClose}>Abbrechen</Button>
<Button disabled={!branch} loading={updateMutation.isPending} onClick={() => { setFormError(null); updateMutation.mutate(); }}>Update installieren</Button>
</div>
</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 [configurationTarget, setConfigurationTarget] = useState<Module | null>(null);
const [updateTarget, setUpdateTarget] = useState<Module | null>(null);
const [connectTarget, setConnectTarget] = useState<MarketplaceProvider['provider'] | null>(null);
const [connectionTab, setConnectionTab] = useState<'providers' | 'manual'>('providers');
const [healthResults, setHealthResults] = useState<Record<string, { healthy: boolean; detail: string }>>({});
const modulesQuery = useQuery({
queryKey: ['modules'],
queryFn: fetchModules,
refetchInterval: (query) => query.state.data?.some((module) => module.status === 'STARTING' || module.status === 'STOPPING') ? 2_000 : 15_000,
});
const marketplaceQuery = useQuery({
queryKey: ['marketplace-providers'],
queryFn: fetchMarketplaceProviders,
});
const moduleUpdateQueries = useQueries({
queries: (modulesQuery.data ?? []).map((module) => ({
queryKey: ['module-updates', module.id],
queryFn: () => fetchMarketplaceUpdates(module.id),
refetchInterval: 60 * 1000,
retry: false,
})),
});
const connectedProviders = marketplaceQuery.data?.filter((item) => item.connected) ?? [];
const repositoriesQuery = useQuery({
queryKey: ['marketplace-repositories', connectedProviders.map((item) => item.provider)],
queryFn: async () => Promise.all(connectedProviders.map(async (item) => ({
...item,
repositories: await fetchMarketplaceRepositories(item.provider),
}))),
enabled: connectedProviders.length > 0,
});
const connectMutation = useMutation({
mutationFn: (provider: 'github' | 'gitea' | 'forgejo') => beginMarketplaceConnection(provider),
onSuccess: (authorizationUrl) => window.location.assign(authorizationUrl),
onError: (error) => showToast('error', error instanceof ApiError ? error.message : 'Verbindung konnte nicht gestartet werden'),
});
const disconnectMutation = useMutation({
mutationFn: (provider: 'github' | 'gitea' | 'forgejo') => disconnectMarketplaceProvider(provider),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['marketplace-providers'] });
showToast('success', 'Forge-Verbindung getrennt');
},
onError: (error) => showToast('error', error instanceof ApiError ? error.message : 'Verbindung konnte nicht getrennt werden'),
});
const marketplaceInstallMutation = useMutation({
mutationFn: (input: { provider: MarketplaceProvider['provider']; owner: string; repository: string }) =>
installMarketplaceRepository(input.provider, input.owner, input.repository),
onSuccess: async (module) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['modules'] }),
queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] }),
]);
showToast('success', `Modul "${module.name}" wurde installiert. Zum Starten bitte „Start“ wählen.`);
},
onError: async (error) => {
showToast('error', error instanceof ApiError ? error.message : 'Marketplace-Installation fehlgeschlagen', operationToastDetails(error));
await invalidate();
},
});
const invalidate = async (): Promise<void> => {
await queryClient.invalidateQueries({ queryKey: ['modules'] });
};
const installMutation = useMutation({
mutationFn: (file: File) => installModule(file),
onSuccess: async (module) => {
showToast('success', `Modul "${module.name}" wurde installiert`);
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
await invalidate();
},
onError: async (error) => {
showToast('error', error instanceof ApiError ? error.message : 'Installation fehlgeschlagen', operationToastDetails(error));
await invalidate();
},
});
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: async (module, variables) => {
showToast('success', `Modul "${module.name}" – Aktion "${variables.action}" erfolgreich`);
await invalidate();
},
onError: async (error) => {
showToast('error', error instanceof ApiError ? error.message : 'Aktion fehlgeschlagen', operationToastDetails(error));
await 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-screen-2xl space-y-8 p-8">
<div>
<h1 className="text-3xl font-bold text-slate-900">Modulverwaltung</h1>
<p className="mt-1 text-base text-slate-500">
Module installieren, starten, stoppen und entfernen.
</p>
</div>
{new URLSearchParams(window.location.search).get('marketplace') === 'connected' && (
<p role="status" className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
Forge-Konto wurde erfolgreich verbunden.
</p>
)}
{new URLSearchParams(window.location.search).get('marketplace') === 'denied' && (
<p role="status" className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Autorisierung wurde abgebrochen.
</p>
)}
{new URLSearchParams(window.location.search).get('marketplace') === 'error' && (
<p role="alert" className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{new URLSearchParams(window.location.search).get('reason') === 'session'
? 'MPM konnte deine Sitzung beim OAuth-Rücksprung nicht zuordnen. Verwende vor und nach der Anmeldung dieselbe Adresse (http://127.0.0.1:8081) und starte die Verbindung erneut.'
: new URLSearchParams(window.location.search).get('reason') === 'callback'
? 'Die OAuth-Rückgabe war unvollständig. Bitte starte die Verbindung erneut.'
: 'Verbindung fehlgeschlagen. Prüfe OAuth-Konfiguration und Callback-URL und starte die Verbindung erneut.'}
</p>
)}
<div className="space-y-6">
{/* Compact provider connectors */}
<Card className="p-5">
<h2 className="mb-3 text-lg font-semibold text-slate-900">Verbindungen</h2>
<Tabs value={connectionTab} onValueChange={(value) => setConnectionTab(value as 'providers' | 'manual')}>
<TabsList aria-label="Verbindungen und manuelle Installation" className="mb-4 flex gap-2 border-b border-slate-200">
<TabsTrigger value="providers" className="border-b-2 px-3 py-2 text-sm font-medium data-[state=active]:border-brand-600 data-[state=active]:text-brand-700 data-[state=inactive]:border-transparent data-[state=inactive]:text-slate-500 data-[state=inactive]:hover:text-slate-800">
Verbindungen
</TabsTrigger>
<TabsTrigger value="manual" className="border-b-2 px-3 py-2 text-sm font-medium data-[state=active]:border-brand-600 data-[state=active]:text-brand-700 data-[state=inactive]:border-transparent data-[state=inactive]:text-slate-500 data-[state=inactive]:hover:text-slate-800">
Manuelle Installation
</TabsTrigger>
</TabsList>
<TabsContent value="providers" className="grid grid-cols-1">
{(['github', 'gitea', 'forgejo'] as const).map((provider) => {
const connection = marketplaceQuery.data?.find((item) => item.provider === provider);
const label = connection?.label ?? (provider === 'github' ? 'GitHub' : provider === 'gitea' ? 'Gitea' : 'Forgejo');
const statusText = marketplaceQuery.isLoading
? 'Status wird geladen…'
: connection?.connected
? `Verbunden als ${connection.accountLogin}`
: connection?.configured
? 'Marketplace verbinden'
: 'Nicht eingerichtet';
return (
<div key={provider} className="flex min-w-0 items-center gap-3 border-b border-slate-200 py-3 last:border-b-0">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 text-slate-900">
<ProviderMark provider={provider} />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-900">{label}</p>
<p className="truncate text-xs text-slate-500">{statusText}</p>
</div>
{connection?.connected ? (
<Button
className="h-9 w-9 shrink-0 p-0"
variant="secondary"
size="sm"
aria-label={`${label}-Verbindung trennen`}
title={`${label}-Verbindung trennen`}
loading={disconnectMutation.isPending && disconnectMutation.variables === provider}
onClick={() => disconnectMutation.mutate(provider)}
>
<Icon name="check" className="h-4 w-4 text-emerald-600" />
</Button>
) : (
<Button
className="h-9 w-9 shrink-0 p-0"
size="sm"
aria-label={`Mit ${label} verbinden`}
title={connection?.configured ? `Mit ${label} verbinden` : `OAuth-Konfiguration für ${label} fehlt`}
disabled={!connection?.configured || marketplaceQuery.isLoading}
loading={connectMutation.isPending && connectMutation.variables === provider}
onClick={() => setConnectTarget(provider)}
>
<Icon name="plus" className="h-5 w-5" />
</Button>
)}
</div>
);
})}
</TabsContent>
<TabsContent value="manual" className="flex flex-wrap items-center gap-3">
<InputControl
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleFileChange}
className="h-auto w-auto border-0 bg-transparent px-0 py-0 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>
</TabsContent>
</Tabs>
</Card>
{/* Modul-Liste */}
<Card className="p-5">
<h2 className="mb-4 text-lg font-semibold text-slate-900">Installierte Module</h2>
<div className="max-h-[42rem] overflow-auto rounded-xl border border-slate-200">
<Table className="w-full min-w-[900px] table-fixed text-sm">
<TableHeader className="sticky top-0 z-10">
<TableRow className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
<TableHead className="w-[29%] px-4 py-3 font-semibold">Modul</TableHead>
<TableHead className="w-[12%] px-4 py-3 text-center font-semibold">Status</TableHead>
<TableHead className="w-[13%] px-4 py-3 text-center font-semibold">URL</TableHead>
<TableHead className="w-[22%] px-4 py-3 text-center font-semibold">Konfiguration</TableHead>
<TableHead className="w-[24%] px-4 py-3 font-semibold">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{modulesQuery.isLoading && <TableRow><TableCell colSpan={5} className="px-5 py-10 text-center text-slate-500">Module werden geladen…</TableCell></TableRow>}
{modulesQuery.isError && <TableRow><TableCell colSpan={5} className="px-5 py-10 text-center text-red-600">Module konnten nicht geladen werden.</TableCell></TableRow>}
{modulesQuery.data?.map((module) => {
const actionPending = lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id;
return (
<TableRow key={module.id} className="border-b border-slate-100 last:border-0">
<TableCell className="align-top px-4 py-3">
<div className="font-semibold 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>}
</TableCell>
<TableCell className="align-top px-4 py-3"><div className="flex w-full justify-center"><Badge variant={statusVariant(module.status)}>{module.status}</Badge></div>
{healthResults[module.id] && <div className="mt-1 text-xs text-slate-500">Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}</div>}
</TableCell>
<TableCell className="align-top px-4 py-3"><div className="flex justify-center"><code className="whitespace-nowrap rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">/{module.slug}</code></div></TableCell>
<TableCell className="align-top px-4 py-3">{module.configuration.length > 0 && <div className="flex flex-col items-center gap-1 text-center"><div className={`text-xs font-medium ${module.configurationReady ? 'text-emerald-700' : 'text-amber-700'}`}>{module.configurationReady ? 'Konfig gesetzt' : 'Konfiguration erforderlich'}</div><Button size="sm" variant="ghost" disabled={actionPending} onClick={() => setConfigurationTarget(module)}>{module.configurationReady ? 'Konfiguration ändern' : 'Konfigurieren'}</Button></div>}</TableCell>
<TableCell className="align-top px-4 py-3"><div className="flex flex-wrap gap-1">
{(() => {
const updateState = moduleUpdateQueries[(modulesQuery.data ?? []).findIndex((item) => item.id === module.id)]?.data;
return <Button size="sm" variant="ghost" disabled={!updateState?.branches.length} title={updateState?.branches.length ? 'Neue getestete Branch installieren' : updateState?.installedBranch ? 'Keine neue Branch gefunden' : 'Kein Marketplace-Repository verknüpft'} onClick={() => updateState && setUpdateTarget(module)}>Update verfügbar</Button>;
})()}
{module.status !== 'RUNNING' && module.enabled && <Button size="sm" variant="ghost" disabled={actionPending} loading={actionPending && lifecycleMutation.variables?.action === 'start'} onClick={() => lifecycleMutation.mutate({ module, action: 'start' })}>Start</Button>}
{(module.status === 'RUNNING' || module.status === 'STARTING') && <Button size="sm" variant="ghost" disabled={actionPending} loading={actionPending && lifecycleMutation.variables?.action === 'stop'} onClick={() => lifecycleMutation.mutate({ module, action: 'stop' })}>Stop</Button>}
<Button size="sm" variant="ghost" disabled={actionPending} loading={actionPending && lifecycleMutation.variables?.action === 'restart'} onClick={() => lifecycleMutation.mutate({ module, action: 'restart' })}>Restart</Button>
<Button size="sm" variant="ghost" disabled={actionPending} onClick={() => healthMutation.mutate(module)}>Health</Button>
<Button size="sm" variant="ghost" disabled={actionPending} loading={actionPending && (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" disabled={actionPending} onClick={() => setRemoveTarget(module)}>Remove</Button>
</div></TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{modulesQuery.data?.length === 0 && <p className="px-4 py-8 text-center text-slate-500">Noch keine Module installiert.</p>}
</div>
</Card>
</div>
<Card className="p-5">
<div className="mb-6">
<h2 className="text-lg font-semibold text-slate-900">Marketplace</h2>
<p className="mt-1 text-base text-slate-500">
&Ouml;ffentliche Repositories verbundener Forge-Konten. Installiere den Standard-Branch direkt; MPM pr&uuml;ft das Modulmanifest vor der Installation.
</p>
</div>
{connectedProviders.length === 0 && (
<p className="rounded-lg border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500">
Verbinde zuerst GitHub, Gitea oder Forgejo.
</p>
)}
{repositoriesQuery.isLoading && <p className="py-4 text-sm text-slate-500">Repositories werden geladen...</p>}
{repositoriesQuery.isError && <p role="alert" className="py-4 text-sm text-red-600">Repositories konnten nicht geladen werden.</p>}
<div className="max-h-[42rem] space-y-4 overflow-y-auto pr-2">
{repositoriesQuery.data?.map((provider) => (
<div key={provider.provider}>
<h3 className="mb-3 text-base font-semibold text-slate-800">{provider.label}</h3>
{provider.repositories.length === 0 ? (
<p className="text-sm text-slate-500">Keine &ouml;ffentlichen Repositories gefunden.</p>
) : (
<div className="grid gap-3 md:grid-cols-2">
{provider.repositories.map((repo) => (
<article key={repo.owner + '/' + repo.repository} className="flex items-center justify-between gap-3 rounded-xl border border-slate-200 p-3 transition-colors hover:border-slate-400 hover:bg-slate-50">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-slate-50 text-slate-800"><ProviderMark provider={provider.provider} /></span>
<a className="truncate text-sm font-medium text-brand-700 hover:underline" href={repo.htmlUrl} target="_blank" rel="noreferrer">
{repo.owner}/{repo.repository}
</a>
</div>
{repo.description && <p className="mt-1 line-clamp-2 text-xs text-slate-500">{repo.description}</p>}
<p className="mt-1 text-xs text-slate-500">Branch: {repo.defaultBranch}</p>
</div>
<Button
size="sm"
variant={repo.installed ? 'secondary' : 'primary'}
disabled={repo.installed}
className={repo.installed ? '!border-emerald-600 !bg-emerald-600 !text-white hover:!bg-emerald-600 disabled:!text-white' : ''}
loading={marketplaceInstallMutation.isPending && marketplaceInstallMutation.variables?.repository === repo.repository}
onClick={() => marketplaceInstallMutation.mutate({ provider: provider.provider, owner: repo.owner, repository: repo.repository })}
>
{repo.installed ? 'Installiert' : 'Installieren'}
</Button>
</article>
))}
</div>
)}
</div>
))}
</div>
</Card>
{removeTarget && (
<RemoveModuleModal module={removeTarget} onClose={() => setRemoveTarget(null)} />
)}
{configurationTarget && <ModuleConfigurationModal module={configurationTarget} onClose={() => setConfigurationTarget(null)} />}
{updateTarget && (() => {
const index = (modulesQuery.data ?? []).findIndex((item) => item.id === updateTarget.id);
const updateState = moduleUpdateQueries[index]?.data;
return updateState?.branches.length ? <ModuleUpdateModal module={updateTarget} state={updateState} onClose={() => setUpdateTarget(null)} /> : null;
})()}
{connectTarget && (
<ConnectProviderModal
provider={connectTarget}
label={marketplaceQuery.data?.find((item) => item.provider === connectTarget)?.label ??
(connectTarget === 'github' ? 'GitHub' : connectTarget === 'gitea' ? 'Gitea' : 'Forgejo')}
loading={connectMutation.isPending}
onClose={() => setConnectTarget(null)}
onContinue={() => connectMutation.mutate(connectTarget)}
/>
)}
</div>
);
}
function ProviderMark({ provider }: { provider: 'github' | 'gitea' | 'forgejo' }): ReactNode {
if (provider === 'github') {
return (
<svg viewBox="0 0 24 24" className="h-6 w-6" aria-hidden="true" fill="currentColor">
<path d="M12 .9a11.1 11.1 0 0 0-3.51 21.63c.56.1.76-.24.76-.54v-2.08c-3.1.67-3.76-1.32-3.76-1.32-.51-1.3-1.24-1.65-1.24-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 .1.95 2.17 3.64 1.54.1-.74.39-1.25.7-1.54-2.48-.28-5.09-1.24-5.09-5.52 0-1.22.44-2.22 1.16-3-.12-.29-.5-1.43.11-2.98 0 0 .95-.3 3.05 1.15a10.6 10.6 0 0 1 5.55 0c2.1-1.45 3.04-1.15 3.04-1.15.61 1.55.23 2.69.12 2.98.72.78 1.15 1.78 1.15 3 0 4.3-2.61 5.23-5.1 5.5.4.35.75 1.02.75 2.06v3.04c0 .3.2.65.77.54A11.1 11.1 0 0 0 12 .9Z" />
</svg>
);
}
if (provider === 'gitea') {
return <span className="text-xl font-bold tracking-tight text-orange-500" aria-hidden="true">G</span>;
}
return (
<svg viewBox="0 0 24 24" className="h-6 w-6 text-emerald-500" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 18h16l-3-4h-4l-2-4H8l2 4H7l-3 4Zm5-10 3-4 3 4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function ConnectProviderModal({
provider,
label,
loading,
onClose,
onContinue,
}: {
provider: MarketplaceProvider['provider'];
label: string;
loading: boolean;
onClose: () => void;
onContinue: () => void;
}): ReactNode {
return (
<Modal
open
title={`${label} verbinden`}
onClose={onClose}
hideHeader
panelClassName="max-w-xl overflow-hidden rounded-[28px]"
>
<div className="relative max-h-[90vh] overflow-y-auto px-1 pb-1 pt-2">
<Button
variant="ghost"
type="button"
aria-label="Dialog schließen"
onClick={onClose}
className="absolute right-1 top-0 h-auto rounded-full p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
<Icon name="close" className="h-4 w-4" />
</Button>
<div className="flex items-center justify-center gap-4 pb-5 pt-2">
<span className="flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-900 text-white">
<Icon name="modules" className="h-9 w-9" />
</span>
<span className="flex gap-1.5" aria-hidden="true">
<span className="h-2 w-2 rounded-full bg-slate-300" />
<span className="h-2 w-2 rounded-full bg-slate-300" />
<span className="h-2 w-2 rounded-full bg-slate-300" />
</span>
<span className="flex h-16 w-16 items-center justify-center rounded-2xl border border-slate-200 bg-slate-50 text-slate-900">
<ProviderMark provider={provider} />
</span>
</div>
<div className="mb-6 text-center">
<h2 className="text-2xl font-semibold text-slate-900">{label} verbinden</h2>
<p className="mt-1 text-base text-slate-500">MPM möchte dein {label}-Konto mit dem Marketplace verbinden.</p>
</div>
<div className="space-y-4 rounded-2xl border border-slate-200 px-5 py-4 text-sm">
<section>
<h3 className="font-medium text-slate-900">Berechtigungen werden respektiert</h3>
<p className="mt-1 text-slate-600">
MPM fordert nur den Zugriff auf deine Kontoinformationen an, um die Verbindung deinem Konto zuzuordnen. Schreibzugriff wird nicht angefordert.
</p>
</section>
<section className="border-t border-slate-200 pt-4">
<h3 className="font-medium text-slate-900">Du behältst die Kontrolle</h3>
<p className="mt-1 text-slate-600">
Du kannst die Verbindung jederzeit in MPM trennen oder die App-Berechtigung in den Einstellungen von {label} widerrufen.
</p>
</section>
<section className="border-t border-slate-200 pt-4">
<h3 className="font-medium text-slate-900">Mit dieser App geteilte Daten</h3>
<p className="mt-1 text-slate-600">
MPM erhält deine Forge-Konto-ID, deinen Benutzernamen und ein Zugriffstoken. Das Token wird verschlüsselt gespeichert und nur serverseitig verwendet.
</p>
</section>
</div>
<Button className="mt-6 h-11 w-full rounded-full" loading={loading} onClick={onContinue}>
Weiter zu {label} <span aria-hidden="true">↗</span>
</Button>
</div>
</Modal>
);
}