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 (
Technische Details
{diagnostic}
); } /** Bestätigungsdialog: Modul entfernen. */ function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () => void }): ReactNode { const { showToast } = useToast(); const queryClient = useQueryClient(); const [formError, setFormError] = useState(null); const [formDiagnostic, setFormDiagnostic] = useState(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 ( {formError && (

{formError}

{formDiagnostic &&
Technische Details
{formDiagnostic}
}
)}
); } function ModuleConfigurationModal({ module, onClose }: { module: Module; onClose: () => void }): ReactNode { const { showToast } = useToast(); const queryClient = useQueryClient(); const [values, setValues] = useState>({}); const [clearKeys, setClearKeys] = useState([]); const [formError, setFormError] = useState(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 ( {configQuery.isLoading &&

Konfiguration wird geladen…

} {configQuery.isError &&

Konfiguration konnte nicht geladen werden.

} {configQuery.data &&
{ event.preventDefault(); setFormError(null); saveMutation.mutate(); }}> {module.status === 'RUNNING' &&

Das laufende Modul wird nach dem Speichern neu gestartet.

}
{configQuery.data.fields.map((field) =>
{field.description &&

{field.description}

} {field.type === 'boolean' ? setValues((current) => ({ ...current, [field.key]: value }))} /> : { setValues((current) => ({ ...current, [field.key]: event.target.value })); setClearKeys((current) => current.filter((key) => key !== field.key)); }} />} {field.secret && field.isSet && } {clearKeys.includes(field.key) &&

Wird beim Speichern gelöscht.

}
)}
{formError &&

{formError}

}
}
); } 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(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 ( {completed ?

Installation abgeschlossen

Branch {completed.branch} ist jetzt installiert.

{completed.commit &&

Commit {completed.commit}

}
:

Installierte Branch: {state.installedBranch}

{state.installedCommit &&

Commit {state.installedCommit.slice(0, 12)}

}
{ setBranch(value); setFormError(null); }} options={state.branches.map((item) => ({ value: item.name, label: item.name }))} /> {selected &&

Commit {selected.commit.slice(0, 12)}

}

Die Konfiguration und persistenten Daten bleiben erhalten. Bei einem fehlgeschlagenen Start stellt MPM die vorherige Modulversion wieder her.

{updateMutation.isPending &&

Update auf „{branch}“ wird installiert…

MPM aktualisiert das Modul und prüft anschließend den Start. Das kann einige Minuten dauern.

} {formError &&

{formError}

}
} ); } /** Modul-Verwaltung (nur Admin): Installation, Lifecycle, Healthchecks. */ export function ModulesPage(): ReactNode { const { showToast } = useToast(); const queryClient = useQueryClient(); const fileInputRef = useRef(null); const [selectedFile, setSelectedFile] = useState(null); const [removeTarget, setRemoveTarget] = useState(null); const [configurationTarget, setConfigurationTarget] = useState(null); const [updateTarget, setUpdateTarget] = useState(null); const [connectTarget, setConnectTarget] = useState(null); const [connectionTab, setConnectionTab] = useState<'providers' | 'manual'>('providers'); const [healthResults, setHealthResults] = useState>({}); 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 => { 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): void { setSelectedFile(event.target.files?.[0] ?? null); } function handleInstall(): void { if (selectedFile) { installMutation.mutate(selectedFile); } } return (

Modulverwaltung

Module installieren, starten, stoppen und entfernen.

{new URLSearchParams(window.location.search).get('marketplace') === 'connected' && (

Forge-Konto wurde erfolgreich verbunden.

)} {new URLSearchParams(window.location.search).get('marketplace') === 'denied' && (

Autorisierung wurde abgebrochen.

)} {new URLSearchParams(window.location.search).get('marketplace') === 'error' && (

{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.'}

)}
{/* Compact provider connectors */}

Verbindungen

setConnectionTab(value as 'providers' | 'manual')}> Verbindungen Manuelle Installation {(['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 (

{label}

{statusText}

{connection?.connected ? ( ) : ( )}
); })}

ZIP-Paket mit module.json (Manifest). Maximal 10 MB.

{/* Modul-Liste */}

Installierte Module

Modul Status URL Konfiguration Aktionen {modulesQuery.isLoading && Module werden geladen…} {modulesQuery.isError && Module konnten nicht geladen werden.} {modulesQuery.data?.map((module) => { const actionPending = lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id; return (
{module.name}
{module.moduleId} · Version {module.version}{module.author && ` · ${module.author}`}
{module.description &&
{module.description}
}
{module.status}
{healthResults[module.id] &&
Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}
}
/{module.slug}
{module.configuration.length > 0 &&
{module.configurationReady ? 'Konfig gesetzt' : 'Konfiguration erforderlich'}
}
{(() => { const updateState = moduleUpdateQueries[(modulesQuery.data ?? []).findIndex((item) => item.id === module.id)]?.data; return ; })()} {module.status !== 'RUNNING' && module.enabled && } {(module.status === 'RUNNING' || module.status === 'STARTING') && }
); })}
{modulesQuery.data?.length === 0 &&

Noch keine Module installiert.

}

Marketplace

Öffentliche Repositories verbundener Forge-Konten. Installiere den Standard-Branch direkt; MPM prüft das Modulmanifest vor der Installation.

{connectedProviders.length === 0 && (

Verbinde zuerst GitHub, Gitea oder Forgejo.

)} {repositoriesQuery.isLoading &&

Repositories werden geladen...

} {repositoriesQuery.isError &&

Repositories konnten nicht geladen werden.

}
{repositoriesQuery.data?.map((provider) => (

{provider.label}

{provider.repositories.length === 0 ? (

Keine öffentlichen Repositories gefunden.

) : (
{provider.repositories.map((repo) => (
{repo.description &&

{repo.description}

}

Branch: {repo.defaultBranch}

))}
)}
))}
{removeTarget && ( setRemoveTarget(null)} /> )} {configurationTarget && setConfigurationTarget(null)} />} {updateTarget && (() => { const index = (modulesQuery.data ?? []).findIndex((item) => item.id === updateTarget.id); const updateState = moduleUpdateQueries[index]?.data; return updateState?.branches.length ? setUpdateTarget(null)} /> : null; })()} {connectTarget && ( item.provider === connectTarget)?.label ?? (connectTarget === 'github' ? 'GitHub' : connectTarget === 'gitea' ? 'Gitea' : 'Forgejo')} loading={connectMutation.isPending} onClose={() => setConnectTarget(null)} onContinue={() => connectMutation.mutate(connectTarget)} /> )}
); } function ProviderMark({ provider }: { provider: 'github' | 'gitea' | 'forgejo' }): ReactNode { if (provider === 'github') { return ( ); } if (provider === 'gitea') { return ; } return ( ); } function ConnectProviderModal({ provider, label, loading, onClose, onContinue, }: { provider: MarketplaceProvider['provider']; label: string; loading: boolean; onClose: () => void; onContinue: () => void; }): ReactNode { return (

{label} verbinden

MPM möchte dein {label}-Konto mit dem Marketplace verbinden.

Berechtigungen werden respektiert

MPM fordert nur den Zugriff auf deine Kontoinformationen an, um die Verbindung deinem Konto zuzuordnen. Schreibzugriff wird nicht angefordert.

Du behältst die Kontrolle

Du kannst die Verbindung jederzeit in MPM trennen oder die App-Berechtigung in den Einstellungen von {label} widerrufen.

Mit dieser App geteilte Daten

MPM erhält deine Forge-Konto-ID, deinen Benutzernamen und ein Zugriffstoken. Das Token wird verschlüsselt gespeichert und nur serverseitig verwendet.

); }