711 lines
40 KiB
TypeScript
711 lines
40 KiB
TypeScript
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">
|
||
Öffentliche Repositories verbundener Forge-Konten. Installiere den Standard-Branch direkt; MPM prü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 ö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>
|
||
);
|
||
}
|