MPM marketplace and UI updates
This commit is contained in:
@@ -1,8 +1,15 @@
|
||||
import { type ReactNode, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
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 {
|
||||
@@ -12,14 +19,19 @@ import {
|
||||
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';
|
||||
|
||||
@@ -40,22 +52,49 @@ function statusVariant(status: ModuleStatus): 'success' | 'warning' | 'danger' |
|
||||
}
|
||||
}
|
||||
|
||||
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: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['modules'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] });
|
||||
showToast('success', `Modul "${module.name}" wurde entfernt`);
|
||||
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: (error) => {
|
||||
onError: async (error) => {
|
||||
setFormError(error instanceof ApiError ? error.message : 'Entfernen fehlgeschlagen');
|
||||
setFormDiagnostic(operationDiagnostic(error) ?? null);
|
||||
await queryClient.invalidateQueries({ queryKey: ['modules'] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -63,13 +102,14 @@ function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () =>
|
||||
<Modal
|
||||
open
|
||||
title="Modul entfernen"
|
||||
description={`Möchten Sie "${module.name}" (Version ${module.version}) wirklich entfernen? App- und Datenbankcontainer sowie Dateien und Registrierung werden gelöscht. Datenvolumes bleiben für eine mögliche Neuinstallation erhalten.`}
|
||||
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 && (
|
||||
<p role="alert" className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{formError}
|
||||
</p>
|
||||
<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}>
|
||||
@@ -87,6 +127,116 @@ function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () =>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -94,6 +244,8 @@ export function ModulesPage(): ReactNode {
|
||||
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 }>>({});
|
||||
@@ -101,12 +253,20 @@ export function ModulesPage(): ReactNode {
|
||||
const modulesQuery = useQuery({
|
||||
queryKey: ['modules'],
|
||||
queryFn: fetchModules,
|
||||
refetchInterval: 15_000,
|
||||
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)],
|
||||
@@ -135,30 +295,36 @@ export function ModulesPage(): ReactNode {
|
||||
const marketplaceInstallMutation = useMutation({
|
||||
mutationFn: (input: { provider: MarketplaceProvider['provider']; owner: string; repository: string }) =>
|
||||
installMarketplaceRepository(input.provider, input.owner, input.repository),
|
||||
onSuccess: (module) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['modules'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] });
|
||||
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: (error) => showToast('error', error instanceof ApiError ? error.message : 'Marketplace-Installation fehlgeschlagen'),
|
||||
onError: async (error) => {
|
||||
showToast('error', error instanceof ApiError ? error.message : 'Marketplace-Installation fehlgeschlagen', operationToastDetails(error));
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const invalidate = (): void => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['modules'] });
|
||||
const invalidate = async (): Promise<void> => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['modules'] });
|
||||
};
|
||||
|
||||
const installMutation = useMutation({
|
||||
mutationFn: (file: File) => installModule(file),
|
||||
onSuccess: (module) => {
|
||||
onSuccess: async (module) => {
|
||||
showToast('success', `Modul "${module.name}" wurde installiert`);
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
invalidate();
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast('error', error instanceof ApiError ? error.message : 'Installation fehlgeschlagen');
|
||||
onError: async (error) => {
|
||||
showToast('error', error instanceof ApiError ? error.message : 'Installation fehlgeschlagen', operationToastDetails(error));
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -177,13 +343,13 @@ export function ModulesPage(): ReactNode {
|
||||
return setModuleEnabled(input.module.id, false);
|
||||
}
|
||||
},
|
||||
onSuccess: (module, variables) => {
|
||||
onSuccess: async (module, variables) => {
|
||||
showToast('success', `Modul "${module.name}" – Aktion "${variables.action}" erfolgreich`);
|
||||
invalidate();
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast('error', error instanceof ApiError ? error.message : 'Aktion fehlgeschlagen');
|
||||
invalidate();
|
||||
onError: async (error) => {
|
||||
showToast('error', error instanceof ApiError ? error.message : 'Aktion fehlgeschlagen', operationToastDetails(error));
|
||||
await invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -242,18 +408,18 @@ export function ModulesPage(): ReactNode {
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Compact provider connectors */}
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<Card className="p-5">
|
||||
<h2 className="mb-3 text-lg font-semibold text-slate-900">Verbindungen</h2>
|
||||
<div role="tablist" aria-label="Verbindungen und manuelle Installation" className="mb-4 flex gap-2 border-b border-slate-200">
|
||||
<button type="button" role="tab" aria-selected={connectionTab === 'providers'} onClick={() => setConnectionTab('providers')} className={`border-b-2 px-3 py-2 text-sm font-medium transition-colors ${connectionTab === 'providers' ? 'border-brand-600 text-brand-700' : 'border-transparent text-slate-500 hover:text-slate-800'}`}>
|
||||
<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
|
||||
</button>
|
||||
<button type="button" role="tab" aria-selected={connectionTab === 'manual'} onClick={() => setConnectionTab('manual')} className={`border-b-2 px-3 py-2 text-sm font-medium transition-colors ${connectionTab === 'manual' ? 'border-brand-600 text-brand-700' : 'border-transparent text-slate-500 hover:text-slate-800'}`}>
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
{connectionTab === 'providers' ? (
|
||||
<div className="grid grid-cols-1">
|
||||
</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');
|
||||
@@ -301,68 +467,76 @@ export function ModulesPage(): ReactNode {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div role="tabpanel" className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
</TabsContent>
|
||||
<TabsContent value="manual" className="flex flex-wrap items-center gap-3">
|
||||
<InputControl
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleFileChange}
|
||||
className="text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-brand-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-brand-700 hover:file:bg-brand-100"
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
{/* Modul-Liste */}
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<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">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||
<th className="w-[34%] px-4 py-3 font-semibold">Modul</th>
|
||||
<th className="w-[13%] px-4 py-3 font-semibold">Status</th>
|
||||
<th className="w-[15%] px-4 py-3 font-semibold">URL</th>
|
||||
<th className="w-[38%] px-4 py-3 font-semibold">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modulesQuery.isLoading && <tr><td colSpan={4} className="px-5 py-10 text-center text-slate-500">Module werden geladen…</td></tr>}
|
||||
{modulesQuery.isError && <tr><td colSpan={4} className="px-5 py-10 text-center text-red-600">Module konnten nicht geladen werden.</td></tr>}
|
||||
{modulesQuery.data?.map((module) => (
|
||||
<tr key={module.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<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>}
|
||||
</td>
|
||||
<td className="px-4 py-3"><span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset ${statusVariant(module.status) === 'success' ? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20' : statusVariant(module.status) === 'warning' ? 'bg-amber-50 text-amber-700 ring-amber-600/20' : statusVariant(module.status) === 'danger' ? 'bg-red-50 text-red-700 ring-red-600/20' : statusVariant(module.status) === 'neutral' ? 'bg-slate-100 text-slate-600 ring-slate-500/20' : 'bg-brand-50 text-brand-700 ring-brand-600/20'}`}>{module.status}</span>
|
||||
{healthResults[module.id] && <div className="mt-1 text-xs text-slate-500">Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}</div>}
|
||||
</td>
|
||||
<td className="px-4 py-3"><code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">/{module.slug}</code></td>
|
||||
<td className="px-4 py-3"><div className="flex flex-wrap gap-1">
|
||||
{module.status !== 'RUNNING' && module.enabled && <Button size="sm" variant="ghost" loading={lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id && lifecycleMutation.variables?.action === 'start'} onClick={() => lifecycleMutation.mutate({ module, action: 'start' })}>Start</Button>}
|
||||
{(module.status === 'RUNNING' || module.status === 'STARTING') && <Button size="sm" variant="ghost" loading={lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id && lifecycleMutation.variables?.action === 'stop'} onClick={() => lifecycleMutation.mutate({ module, action: 'stop' })}>Stop</Button>}
|
||||
<Button size="sm" variant="ghost" loading={lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id && lifecycleMutation.variables?.action === 'restart'} onClick={() => lifecycleMutation.mutate({ module, action: 'restart' })}>Restart</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => healthMutation.mutate(module)}>Health</Button>
|
||||
<Button size="sm" variant="ghost" loading={lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id && (lifecycleMutation.variables?.action === 'enable' || lifecycleMutation.variables?.action === 'disable')} onClick={() => lifecycleMutation.mutate({ module, action: module.enabled ? 'disable' : 'enable' })}>{module.enabled ? 'Disable' : 'Enable'}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setRemoveTarget(module)}>Remove</Button>
|
||||
</div></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</section>
|
||||
</Card>
|
||||
</div>
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<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">
|
||||
@@ -413,10 +587,16 @@ export function ModulesPage(): ReactNode {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</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}
|
||||
@@ -471,14 +651,15 @@ function ConnectProviderModal({
|
||||
panelClassName="max-w-xl overflow-hidden rounded-[28px]"
|
||||
>
|
||||
<div className="relative max-h-[90vh] overflow-y-auto px-1 pb-1 pt-2">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
aria-label="Dialog schließen"
|
||||
onClick={onClose}
|
||||
className="absolute right-1 top-0 rounded-full p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||
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>
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user