|
|
|
|
@@ -10,6 +10,9 @@ 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 { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
|
|
|
|
|
import { Skeleton } from '../../components/ui/skeleton';
|
|
|
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '../../components/ui/dropdown-menu';
|
|
|
|
|
import { useToast } from '../../components/ui/toast';
|
|
|
|
|
import { ApiError } from '../../lib/api-client';
|
|
|
|
|
import {
|
|
|
|
|
@@ -23,6 +26,7 @@ import {
|
|
|
|
|
installModule,
|
|
|
|
|
installMarketplaceRepository,
|
|
|
|
|
fetchMarketplaceUpdates,
|
|
|
|
|
fetchMarketplaceOperationProgress,
|
|
|
|
|
updateMarketplaceModule,
|
|
|
|
|
removeModule,
|
|
|
|
|
restartModule,
|
|
|
|
|
@@ -156,8 +160,8 @@ function ModuleConfigurationModal({ module, onClose }: { module: Module; onClose
|
|
|
|
|
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.isLoading && <Spinner label="Konfiguration wird geladen…" className="py-8" />}
|
|
|
|
|
{configQuery.isError && <ErrorState title="Konfiguration konnte nicht geladen werden" message="Prüfe die Verbindung und versuche es erneut." action={<Button variant="secondary" onClick={() => void configQuery.refetch()}>Erneut versuchen</Button>} />}
|
|
|
|
|
{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">
|
|
|
|
|
@@ -181,8 +185,9 @@ function ModuleUpdateModal({ module, state, onClose }: { module: Module; state:
|
|
|
|
|
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 [operationId] = useState(() => crypto.randomUUID());
|
|
|
|
|
const updateMutation = useMutation({
|
|
|
|
|
mutationFn: () => updateMarketplaceModule(module.id, branch),
|
|
|
|
|
mutationFn: () => updateMarketplaceModule(module.id, branch, operationId),
|
|
|
|
|
onSuccess: async (updatedModule) => {
|
|
|
|
|
await Promise.all([
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['modules'] }),
|
|
|
|
|
@@ -192,7 +197,15 @@ function ModuleUpdateModal({ module, state, onClose }: { module: Module; state:
|
|
|
|
|
},
|
|
|
|
|
onError: (error) => setFormError(error instanceof ApiError ? error.message : 'Modulupdate fehlgeschlagen'),
|
|
|
|
|
});
|
|
|
|
|
const progressQuery = useQuery({
|
|
|
|
|
queryKey: ['marketplace-operation', operationId],
|
|
|
|
|
queryFn: () => fetchMarketplaceOperationProgress(operationId),
|
|
|
|
|
enabled: updateMutation.isPending,
|
|
|
|
|
refetchInterval: (query) => query.state.data?.status === 'running' ? 750 : false,
|
|
|
|
|
retry: false,
|
|
|
|
|
});
|
|
|
|
|
const selected = state.branches.find((item) => item.name === branch);
|
|
|
|
|
const progress = progressQuery.data?.progress ?? 3;
|
|
|
|
|
const handleClose = () => { if (!updateMutation.isPending) onClose(); };
|
|
|
|
|
return (
|
|
|
|
|
<Modal open title={completed ? 'Update erfolgreich' : `Update verfügbar: ${module.name}`}
|
|
|
|
|
@@ -220,12 +233,12 @@ function ModuleUpdateModal({ module, state, onClose }: { module: Module; state:
|
|
|
|
|
{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" />
|
|
|
|
|
{updateMutation.isPending && <div className="space-y-2 rounded-lg border border-slate-200 bg-slate-50 p-3" role="status" aria-live="polite">
|
|
|
|
|
<div role="progressbar" aria-label="Modulupdate läuft" aria-valuemin={0} aria-valuemax={100} aria-valuenow={progress} aria-valuetext={progressQuery.data?.message ?? 'Update wird vorbereitet'} className="h-2.5 overflow-hidden rounded-full bg-slate-200">
|
|
|
|
|
<div className="h-full rounded-full bg-brand-600 transition-[width] duration-500" style={{ width: `${progress}%` }} />
|
|
|
|
|
</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>
|
|
|
|
|
<p className="text-sm font-medium text-slate-800">{progressQuery.data?.message ?? `Update auf „${branch}“ wird vorbereitet`}</p>
|
|
|
|
|
<p className="text-xs text-slate-500">Schritt {progressQuery.data?.phase ?? 'starting'} · Das kann je nach Build 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">
|
|
|
|
|
@@ -367,6 +380,41 @@ export function ModulesPage(): ReactNode {
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const renderModuleUpdates = (module: Module): ReactNode => {
|
|
|
|
|
const index = (modulesQuery.data ?? []).findIndex((item) => item.id === module.id);
|
|
|
|
|
const updateQuery = moduleUpdateQueries[index];
|
|
|
|
|
const updateState = updateQuery?.data;
|
|
|
|
|
if (updateState?.branches.length) {
|
|
|
|
|
return <Button size="sm" variant="secondary" className="whitespace-nowrap" title="Neue getestete Branch installieren" onClick={() => setUpdateTarget(module)}>Update verfügbar</Button>;
|
|
|
|
|
}
|
|
|
|
|
if (updateState?.installedBranch) return <span className="text-xs text-slate-500">Keine Updates</span>;
|
|
|
|
|
if (updateQuery?.isLoading) return <Skeleton className="my-1 h-5 w-24" aria-label="Updates werden geprüft" />;
|
|
|
|
|
return <span className="text-xs text-slate-500" title="Kein Marketplace-Repository verknüpft">Kein Update</span>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const renderModuleActions = (module: Module, compact = false): ReactNode => {
|
|
|
|
|
const actionPending = lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id;
|
|
|
|
|
const action = lifecycleMutation.variables?.action;
|
|
|
|
|
return <DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<Button size="sm" variant="outline" disabled={actionPending} className={`focus-visible:outline-none focus-visible:ring-0 ${compact ? 'w-full justify-center' : ''}`} aria-label={`Aktionen auswählen für ${module.name}`}>
|
|
|
|
|
<span>Aktionen auswählen</span>
|
|
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="end" className="w-48">
|
|
|
|
|
{module.status !== 'RUNNING' && module.enabled && <DropdownMenuItem disabled={actionPending} onSelect={() => lifecycleMutation.mutate({ module, action: 'start' })}>Start</DropdownMenuItem>}
|
|
|
|
|
{(module.status === 'RUNNING' || module.status === 'STARTING') && <DropdownMenuItem disabled={actionPending} onSelect={() => lifecycleMutation.mutate({ module, action: 'stop' })}>Stop</DropdownMenuItem>}
|
|
|
|
|
<DropdownMenuItem disabled={actionPending} onSelect={() => lifecycleMutation.mutate({ module, action: 'restart' })}>Restart</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuItem disabled={actionPending || healthMutation.isPending} onSelect={() => healthMutation.mutate(module)}>{healthMutation.isPending && healthMutation.variables?.id === module.id ? 'Health läuft…' : 'Health prüfen'}</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
<DropdownMenuItem disabled={actionPending} onSelect={() => lifecycleMutation.mutate({ module, action: module.enabled ? 'disable' : 'enable' })}>{module.enabled ? 'Disable' : 'Enable'}</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
<DropdownMenuItem disabled={actionPending} className="text-red-600 focus:bg-red-50 focus:text-red-700" onSelect={() => setRemoveTarget(module)}>Remove</DropdownMenuItem>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
{actionPending && action && <span className="sr-only" role="status">{action} läuft</span>}
|
|
|
|
|
</DropdownMenu>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>): void {
|
|
|
|
|
setSelectedFile(event.target.files?.[0] ?? null);
|
|
|
|
|
}
|
|
|
|
|
@@ -487,53 +535,68 @@ export function ModulesPage(): ReactNode {
|
|
|
|
|
{/* 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) => {
|
|
|
|
|
{modulesQuery.isLoading && <div className="space-y-3" role="status" aria-label="Module werden geladen">
|
|
|
|
|
{[0, 1, 2].map((item) => <div key={item} className="space-y-2 rounded-lg border border-slate-200 p-4"><Skeleton className="h-5 w-1/3" /><Skeleton className="h-4 w-2/3" /><Skeleton className="h-9 w-full" /></div>)}
|
|
|
|
|
</div>}
|
|
|
|
|
{modulesQuery.isError && <ErrorState title="Module konnten nicht geladen werden" message="Prüfe die Verbindung und lade die Liste erneut." action={<Button variant="secondary" onClick={() => void modulesQuery.refetch()}>Erneut versuchen</Button>} />}
|
|
|
|
|
{modulesQuery.data && modulesQuery.data.length === 0 && <EmptyState title="Noch keine Module installiert" description="Verbinde ein Forge-Konto oder installiere ein Modul als ZIP." />}
|
|
|
|
|
{modulesQuery.data && modulesQuery.data.length > 0 && <>
|
|
|
|
|
<div className="space-y-3 xl:hidden">
|
|
|
|
|
{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>
|
|
|
|
|
);
|
|
|
|
|
return <article key={module.id} className="space-y-4 rounded-xl border border-slate-200 p-4">
|
|
|
|
|
<div className="flex min-w-0 items-start justify-between gap-3">
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<h3 className="truncate font-semibold text-slate-900">{module.name}</h3>
|
|
|
|
|
<p className="break-words text-xs text-slate-500">{module.moduleId} · Version {module.version}{module.author && ` · ${module.author}`}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Badge variant={statusVariant(module.status)}>{module.status}</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
{module.description && <p className="text-sm text-slate-500">{module.description}</p>}
|
|
|
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
|
|
|
<div className="min-w-0"><p className="text-xs font-medium uppercase text-slate-500">URL</p><code className="mt-1 inline-block max-w-full break-all rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">/{module.slug}</code></div>
|
|
|
|
|
{module.configuration.length > 0 && <div className="min-w-0"><p className="text-xs font-medium uppercase text-slate-500">Konfiguration</p><p className={`mt-1 text-xs font-medium ${module.configurationReady ? 'text-emerald-700' : 'text-amber-700'}`}>{module.configurationReady ? 'Konfig gesetzt' : 'Konfiguration erforderlich'}</p><Button size="sm" variant="ghost" disabled={actionPending} className="mt-1 px-0" onClick={() => setConfigurationTarget(module)}>{module.configurationReady ? 'Ändern' : 'Konfigurieren'}</Button></div>}
|
|
|
|
|
</div>
|
|
|
|
|
{healthResults[module.id] && <p className="text-xs text-slate-500">Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}</p>}
|
|
|
|
|
<div className="grid grid-cols-1 gap-3 border-t border-slate-200 pt-3 sm:grid-cols-2">
|
|
|
|
|
<div className="space-y-2"><p className="text-xs font-medium uppercase tracking-wide text-slate-500">Updates</p>{renderModuleUpdates(module)}</div>
|
|
|
|
|
<div className="space-y-2"><p className="text-xs font-medium uppercase tracking-wide text-slate-500">Aktionen</p>{renderModuleActions(module, true)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</article>;
|
|
|
|
|
})}
|
|
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
{modulesQuery.data?.length === 0 && <p className="px-4 py-8 text-center text-slate-500">Noch keine Module installiert.</p>}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="hidden overflow-auto rounded-xl border border-slate-200 xl:block">
|
|
|
|
|
<Table className="w-full min-w-[1040px] 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-[25%] px-4 py-3 font-semibold">Modul</TableHead>
|
|
|
|
|
<TableHead className="w-[10%] px-4 py-3 text-center font-semibold">Status</TableHead>
|
|
|
|
|
<TableHead className="w-[11%] px-4 py-3 text-center font-semibold">URL</TableHead>
|
|
|
|
|
<TableHead className="w-[19%] px-4 py-3 text-center font-semibold">Konfiguration</TableHead>
|
|
|
|
|
<TableHead className="w-[17%] px-4 py-3 text-center font-semibold">Updates</TableHead>
|
|
|
|
|
<TableHead className="w-[18%] px-4 py-3 text-center font-semibold">Aktionen</TableHead>
|
|
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
{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 text-center">{renderModuleUpdates(module)}</TableCell>
|
|
|
|
|
<TableCell className="align-top px-4 py-3"><div className="flex justify-center">{renderModuleActions(module)}</div></TableCell>
|
|
|
|
|
</TableRow>;
|
|
|
|
|
})}
|
|
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
</div>
|
|
|
|
|
</>}
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
<Card className="p-5">
|
|
|
|
|
@@ -543,19 +606,15 @@ export function ModulesPage(): ReactNode {
|
|
|
|
|
Ö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>}
|
|
|
|
|
{connectedProviders.length === 0 && <EmptyState title="Kein Forge-Konto verbunden" description="Verbinde zuerst GitHub, Gitea oder Forgejo, um Repositories zu installieren." />}
|
|
|
|
|
{repositoriesQuery.isLoading && <Spinner label="Repositories werden geladen…" className="py-8" />}
|
|
|
|
|
{repositoriesQuery.isError && <ErrorState title="Repositories konnten nicht geladen werden" message="Prüfe die Forge-Verbindung und versuche es erneut." action={<Button variant="secondary" onClick={() => void repositoriesQuery.refetch()}>Erneut versuchen</Button>} />}
|
|
|
|
|
<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>
|
|
|
|
|
<EmptyState title="Keine öffentlichen Repositories gefunden" description="In diesem Forge-Konto sind derzeit keine installierbaren öffentlichen Repositories verfügbar." />
|
|
|
|
|
) : (
|
|
|
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
|
|
|
{provider.repositories.map((repo) => (
|
|
|
|
|
|