Polish module actions and marketplace updates

This commit is contained in:
leon
2026-10-09 23:43:05 +02:00
parent fd9de823ea
commit 84d8697f23
11 changed files with 244 additions and 101 deletions

View File

@@ -77,19 +77,43 @@ export class MarketplaceController {
return this.marketplaceService.availableUpdates(moduleId); return this.marketplaceService.availableUpdates(moduleId);
} }
@Get('operations/:operationId')
@Roles('ADMIN')
operationProgress(@Param('operationId') operationId: string, @CurrentUser() actor: AuthUser) {
return this.marketplaceService.getOperationProgress(operationId, actor.id);
}
@Post('modules/:moduleId/update') @Post('modules/:moduleId/update')
@Roles('ADMIN') @Roles('ADMIN')
async updateModule( async updateModule(
@Param('moduleId') moduleId: string, @Param('moduleId') moduleId: string,
@Body() body: { branch?: unknown }, @Body() body: { branch?: unknown; operationId?: unknown },
@CurrentUser() actor: AuthUser, @CurrentUser() actor: AuthUser,
@Req() request: AuthenticatedRequest, @Req() request: AuthenticatedRequest,
): Promise<{ module: ModuleRecord }> { ): Promise<{ module: ModuleRecord }> {
if (typeof body.branch !== 'string') throw new BadRequestException('Bitte eine Update-Branch auswählen'); if (typeof body.branch !== 'string') throw new BadRequestException('Bitte eine Update-Branch auswählen');
const update = await this.marketplaceService.updateInstalledBranch(moduleId, body.branch); const operationId = this.marketplaceService.beginOperation(body.operationId, actor.id);
const module = await this.modulesService.updateFromMarketplace(moduleId, update.archive, actor, request.ip ?? null); const report = (phase: string, message: string, progress: number) =>
this.marketplaceService.reportOperation(operationId, actor.id, phase, message, progress);
try {
report('branch', 'Branch wird geprüft', 8);
const update = await this.marketplaceService.updateInstalledBranch(moduleId, body.branch, report);
report('validation', 'Update-Paket wird geprüft', 40);
const module = await this.modulesService.updateFromMarketplace(
moduleId,
update.archive,
actor,
request.ip ?? null,
report,
);
report('commit', 'Neue Version wird registriert', 96);
await this.marketplaceService.commitInstalledBranch(moduleId, update.provider, update.owner, update.repository, update.branch, update.commit); await this.marketplaceService.commitInstalledBranch(moduleId, update.provider, update.owner, update.repository, update.branch, update.commit);
this.marketplaceService.finishOperation(operationId, actor.id, true);
return { module }; return { module };
} catch (error) {
this.marketplaceService.finishOperation(operationId, actor.id, false);
throw error;
}
} }
@Post('connections/:provider/start') @Post('connections/:provider/start')

View File

@@ -54,6 +54,13 @@ export interface MarketplaceUpdatePackage {
branch: string; branch: string;
} }
export interface MarketplaceOperationProgress {
status: 'running' | 'completed' | 'failed';
message: string;
phase: string;
progress: number;
}
interface MarketplaceInstallationRow { interface MarketplaceInstallationRow {
module_id: string; module_id: string;
provider: MarketplaceProvider; provider: MarketplaceProvider;
@@ -106,6 +113,7 @@ function isNewerVersionBranch(candidate: string, installed: string): boolean {
export class MarketplaceService implements OnModuleInit, OnModuleDestroy { export class MarketplaceService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(MarketplaceService.name); private readonly logger = new Logger(MarketplaceService.name);
private branchCheckTimer: NodeJS.Timeout | undefined; private branchCheckTimer: NodeJS.Timeout | undefined;
private readonly operationProgress = new Map<string, { userId: string; state: MarketplaceOperationProgress }>();
constructor( constructor(
private readonly database: DatabaseService, private readonly database: DatabaseService,
@@ -123,6 +131,37 @@ export class MarketplaceService implements OnModuleInit, OnModuleDestroy {
if (this.branchCheckTimer) clearInterval(this.branchCheckTimer); if (this.branchCheckTimer) clearInterval(this.branchCheckTimer);
} }
beginOperation(operationId: unknown, userId: string): string | null {
if (typeof operationId !== 'string' || !/^[0-9a-f-]{36}$/i.test(operationId)) return null;
const state: MarketplaceOperationProgress = { status: 'running', phase: 'starting', message: 'Update wird vorbereitet', progress: 3 };
this.operationProgress.set(operationId, { userId, state });
return operationId;
}
reportOperation(operationId: string | null, userId: string, phase: string, message: string, progress: number): void {
if (!operationId) return;
const operation = this.operationProgress.get(operationId);
if (!operation || operation.userId !== userId || operation.state.status !== 'running') return;
operation.state = { status: 'running', phase, message, progress: Math.max(0, Math.min(99, progress)) };
}
finishOperation(operationId: string | null, userId: string, success: boolean): void {
if (!operationId) return;
const operation = this.operationProgress.get(operationId);
if (!operation || operation.userId !== userId) return;
operation.state = success
? { status: 'completed', phase: 'completed', message: 'Installation abgeschlossen', progress: 100 }
: { ...operation.state, status: 'failed', phase: 'failed', message: 'Installation fehlgeschlagen' };
const cleanup = setTimeout(() => this.operationProgress.delete(operationId), 15 * 60 * 1000);
cleanup.unref();
}
getOperationProgress(operationId: string, userId: string): MarketplaceOperationProgress {
const operation = this.operationProgress.get(operationId);
if (!operation || operation.userId !== userId) throw new NotFoundException('Installationsvorgang wurde nicht gefunden');
return operation.state;
}
async providers(): Promise<ProviderStatus[]> { async providers(): Promise<ProviderStatus[]> {
const connected = await this.database.query<{ provider: MarketplaceProvider; account_login: string }>( const connected = await this.database.query<{ provider: MarketplaceProvider; account_login: string }>(
'SELECT provider, account_login FROM marketplace_connections', 'SELECT provider, account_login FROM marketplace_connections',
@@ -344,7 +383,11 @@ export class MarketplaceService implements OnModuleInit, OnModuleDestroy {
return this.normalizeRepositoryArchive(archive); return this.normalizeRepositoryArchive(archive);
} }
async updateInstalledBranch(moduleId: string, branch: string): Promise<MarketplaceUpdatePackage> { async updateInstalledBranch(
moduleId: string,
branch: string,
onProgress?: (phase: string, message: string, progress: number) => void,
): Promise<MarketplaceUpdatePackage> {
const result = await this.database.query<MarketplaceInstallationRow>( const result = await this.database.query<MarketplaceInstallationRow>(
`SELECT module_id, provider, owner, repository, installed_branch, installed_commit, available_branches `SELECT module_id, provider, owner, repository, installed_branch, installed_commit, available_branches
FROM marketplace_module_installations WHERE module_id = $1`, [moduleId], FROM marketplace_module_installations WHERE module_id = $1`, [moduleId],
@@ -359,10 +402,12 @@ export class MarketplaceService implements OnModuleInit, OnModuleDestroy {
if (!Array.isArray(installation.available_branches) || !installation.available_branches.some((item) => item.name === branch)) { if (!Array.isArray(installation.available_branches) || !installation.available_branches.some((item) => item.name === branch)) {
throw new BadRequestException('Diese Branch wurde bei der letzten Repository-Prüfung nicht als Update gefunden'); throw new BadRequestException('Diese Branch wurde bei der letzten Repository-Prüfung nicht als Update gefunden');
} }
onProgress?.('branch', 'Branch wird geprüft', 12);
const branchInfo = await this.getBranch(installation.provider, installation.owner, installation.repository, branch); const branchInfo = await this.getBranch(installation.provider, installation.owner, installation.repository, branch);
if (installation.installed_commit && branchInfo.commit.toLowerCase() === installation.installed_commit.toLowerCase()) { if (installation.installed_commit && branchInfo.commit.toLowerCase() === installation.installed_commit.toLowerCase()) {
throw new BadRequestException('Diese Branch enthält keine Änderungen gegenüber der installierten Version'); throw new BadRequestException('Diese Branch enthält keine Änderungen gegenüber der installierten Version');
} }
onProgress?.('download', 'Update-Archiv wird geladen', 28);
const archive = await this.downloadRepositoryArchive(installation.provider, installation.owner, installation.repository, branch); const archive = await this.downloadRepositoryArchive(installation.provider, installation.owner, installation.repository, branch);
// Caller updates and validates the module files before this source record is advanced. // Caller updates and validates the module files before this source record is advanced.
return { archive, commit: branchInfo.commit, provider: installation.provider, return { archive, commit: branchInfo.commit, provider: installation.provider,

View File

@@ -149,7 +149,9 @@ export class ModulesService {
packageBuffer: Buffer, packageBuffer: Buffer,
actor: ActingUser, actor: ActingUser,
ipAddress: string | null, ipAddress: string | null,
onProgress?: (phase: string, message: string, progress: number) => void,
): Promise<ModuleRecord> { ): Promise<ModuleRecord> {
onProgress?.('validation', 'Update-Paket wird geprüft', 42);
const current = await this.getById(id); const current = await this.getById(id);
if (['STARTING', 'STOPPING', 'ERROR'].includes(current.status)) { if (['STARTING', 'STOPPING', 'ERROR'].includes(current.status)) {
throw new ConflictException('Das Modul muss einen stabilen Status haben, bevor ein Update gestartet werden kann'); throw new ConflictException('Das Modul muss einen stabilen Status haben, bevor ein Update gestartet werden kann');
@@ -162,16 +164,20 @@ export class ModulesService {
throw new BadRequestException('Das Update muss Modul-ID, URL-Slug, Port und Compose-Service beibehalten'); throw new BadRequestException('Das Update muss Modul-ID, URL-Slug, Port und Compose-Service beibehalten');
} }
const wasRunning = current.status === 'RUNNING'; const wasRunning = current.status === 'RUNNING';
if (wasRunning) await this.stop(id, actor, ipAddress); if (wasRunning) {
onProgress?.('stopping', 'Laufendes Modul wird gestoppt', 55);
await this.stop(id, actor, ipAddress);
}
let replacement: { directory: string; backupDirectory: string } | undefined; let replacement: { directory: string; backupDirectory: string } | undefined;
try { try {
onProgress?.('replacing', 'Moduldateien werden aktualisiert', 66);
replacement = await this.installer.replace(packageBuffer, manifest, this.config.runtime.modulesDir); replacement = await this.installer.replace(packageBuffer, manifest, this.config.runtime.modulesDir);
const candidate = { ...current, name: manifest.name, version: manifest.version, description: manifest.description, const candidate = { ...current, name: manifest.name, version: manifest.version, description: manifest.description,
author: manifest.author, configuration: manifest.configuration }; author: manifest.author, configuration: manifest.configuration };
const configuration = await this.configurationService.state(candidate); const configuration = await this.configurationService.state(candidate);
await this.moduleRepository.updateManifest(id, manifest, configuration.ready); await this.moduleRepository.updateManifest(id, manifest, configuration.ready);
if (wasRunning) await this.start(id, actor, ipAddress); if (wasRunning) await this.start(id, actor, ipAddress, onProgress);
await this.installer.finalizeReplacement(replacement.backupDirectory).catch((cleanupError: unknown) => { await this.installer.finalizeReplacement(replacement.backupDirectory).catch((cleanupError: unknown) => {
this.logger.warn(`Alte Moduldateien für ${current.moduleId} konnten nicht bereinigt werden: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`); this.logger.warn(`Alte Moduldateien für ${current.moduleId} konnten nicht bereinigt werden: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
}); });
@@ -209,7 +215,12 @@ export class ModulesService {
} }
/** Startet ein Modul (INSTALLED/STOPPED → STARTING → RUNNING). */ /** Startet ein Modul (INSTALLED/STOPPED → STARTING → RUNNING). */
async start(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> { async start(
id: string,
actor: ActingUser,
ipAddress: string | null,
onProgress?: (phase: string, message: string, progress: number) => void,
): Promise<ModuleRecord> {
const module = await this.getById(id); const module = await this.getById(id);
this.assertEnabled(module); this.assertEnabled(module);
if (module.status === 'RUNNING' || module.status === 'STARTING') { if (module.status === 'RUNNING' || module.status === 'STARTING') {
@@ -219,9 +230,11 @@ export class ModulesService {
await this.moduleRepository.updateStatus(id, 'STARTING'); await this.moduleRepository.updateStatus(id, 'STARTING');
try { try {
onProgress?.('starting', 'Modulcontainer werden gestartet', 80);
await this.processManager.start(module); await this.processManager.start(module);
// Startup-Grace: Der Modul-Prozess braucht einen Moment zum Starten. // Startup-Grace: Der Modul-Prozess braucht einen Moment zum Starten.
// Der Healthcheck wird mit Retries wiederholt, bevor er als Fehlschlag gilt. // Der Healthcheck wird mit Retries wiederholt, bevor er als Fehlschlag gilt.
onProgress?.('healthcheck', 'Healthcheck läuft', 91);
const health = await this.waitForHealthy(module); const health = await this.waitForHealthy(module);
if (!health.healthy) { if (!health.healthy) {
throw new Error(`Healthcheck fehlgeschlagen: ${health.detail}`); throw new Error(`Healthcheck fehlgeschlagen: ${health.detail}`);

View File

@@ -148,9 +148,8 @@ function AppLayoutContent(): ReactNode {
<SidebarTrigger className="mr-auto h-9 w-9 rounded-lg p-2 text-slate-600 hover:bg-slate-100 md:hidden" aria-label="Menü öffnen"> <SidebarTrigger className="mr-auto h-9 w-9 rounded-lg p-2 text-slate-600 hover:bg-slate-100 md:hidden" aria-label="Menü öffnen">
<Icon name="menu" className="h-5 w-5" /> <Icon name="menu" className="h-5 w-5" />
</SidebarTrigger> </SidebarTrigger>
<div className="ml-auto inline-flex h-9 items-center gap-2 rounded-lg border border-slate-200 px-3 text-sm text-slate-600 transition-colors hover:bg-slate-100"> <div className="ml-auto inline-flex h-9 items-center gap-3">
<Icon name={darkMode ? 'sun' : 'moon'} className="h-4 w-4" /> <Icon name={darkMode ? 'sun' : 'moon'} className="h-4 w-4" />
<span>{darkMode ? 'Hell' : 'Dunkel'}</span>
<Switch checked={darkMode} onCheckedChange={setDarkMode} aria-label="Darkmode" /> <Switch checked={darkMode} onCheckedChange={setDarkMode} aria-label="Darkmode" />
</div> </div>
</header> </header>

View File

@@ -22,15 +22,17 @@ export interface EmptyStateProps {
title: string; title: string;
description?: string; description?: string;
icon?: ReactNode; icon?: ReactNode;
action?: ReactNode;
} }
/** Anzeige für leere Zustände (Design-System). */ /** Anzeige für leere Zustände (Design-System). */
export function EmptyState({ title, description, icon }: EmptyStateProps): ReactNode { export function EmptyState({ title, description, icon, action }: EmptyStateProps): ReactNode {
return ( return (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center"> <div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
{icon && <div className="text-slate-300">{icon}</div>} {icon && <div className="text-slate-300">{icon}</div>}
<h3 className="text-sm font-semibold text-slate-900">{title}</h3> <h3 className="text-sm font-semibold text-slate-900">{title}</h3>
{description && <p className="max-w-sm text-sm text-slate-500">{description}</p>} {description && <p className="max-w-sm text-sm text-slate-500">{description}</p>}
{action}
</div> </div>
); );
} }
@@ -38,12 +40,14 @@ export function EmptyState({ title, description, icon }: EmptyStateProps): React
export interface ErrorStateProps { export interface ErrorStateProps {
title?: string; title?: string;
message?: string; message?: string;
action?: ReactNode;
} }
/** Anzeige für Fehlerzustände (Design-System). */ /** Anzeige für Fehlerzustände (Design-System). */
export function ErrorState({ export function ErrorState({
title = 'Ein Fehler ist aufgetreten', title = 'Ein Fehler ist aufgetreten',
message = 'Bitte versuchen Sie es später erneut.', message = 'Bitte versuchen Sie es später erneut.',
action,
}: ErrorStateProps): ReactNode { }: ErrorStateProps): ReactNode {
return ( return (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center" role="alert"> <div className="flex flex-col items-center justify-center gap-2 py-12 text-center" role="alert">
@@ -52,6 +56,7 @@ export function ErrorState({
</div> </div>
<h3 className="text-sm font-semibold text-slate-900">{title}</h3> <h3 className="text-sm font-semibold text-slate-900">{title}</h3>
<p className="max-w-sm text-sm text-slate-500">{message}</p> <p className="max-w-sm text-sm text-slate-500">{message}</p>
{action}
</div> </div>
); );
} }

View File

@@ -5,7 +5,7 @@ import { z } from 'zod';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { ErrorState, Spinner } from '../../components/ui/states'; import { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table';
/** Audit-Eintrag (API-Vertrag /api/v1/audit). */ /** Audit-Eintrag (API-Vertrag /api/v1/audit). */
@@ -119,11 +119,9 @@ export function AuditPage(): ReactNode {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{auditQuery.isLoading && <Spinner label="Audit-Log wird geladen…" />} {auditQuery.isLoading && <Spinner label="Audit-Log wird geladen…" />}
{auditQuery.isError && <ErrorState message="Audit-Log konnte nicht geladen werden." />} {auditQuery.isError && <ErrorState title="Audit-Log konnte nicht geladen werden" message="Prüfe die Verbindung und lade die Einträge erneut." action={<Button variant="secondary" onClick={() => void auditQuery.refetch()}>Erneut versuchen</Button>} />}
{auditQuery.data && auditQuery.data.entries.length === 0 && ( {auditQuery.data && auditQuery.data.entries.length === 0 && (
<p className="py-8 text-center text-sm text-slate-500"> <EmptyState title="Keine Audit-Einträge gefunden" description="Passe die Filter an oder entferne sie, um weitere Einträge zu sehen." />
Keine Einträge für die gewählten Filter.
</p>
)} )}
{auditQuery.data && auditQuery.data.entries.length > 0 && ( {auditQuery.data && auditQuery.data.entries.length > 0 && (
<div className="overflow-x-auto"> <div className="overflow-x-auto">

View File

@@ -10,6 +10,9 @@ import { Modal } from '../../components/ui/modal';
import { SelectControl } from '../../components/ui/select'; import { SelectControl } from '../../components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table'; 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 { useToast } from '../../components/ui/toast';
import { ApiError } from '../../lib/api-client'; import { ApiError } from '../../lib/api-client';
import { import {
@@ -23,6 +26,7 @@ import {
installModule, installModule,
installMarketplaceRepository, installMarketplaceRepository,
fetchMarketplaceUpdates, fetchMarketplaceUpdates,
fetchMarketplaceOperationProgress,
updateMarketplaceModule, updateMarketplaceModule,
removeModule, removeModule,
restartModule, 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'; const inputClass = 'mt-1 h-auto w-full rounded-lg border border-slate-300 px-3 py-2 text-sm';
return ( return (
<Modal open title={`Konfiguration: ${module.name}`} description="Werte werden verschlüsselt gespeichert." onClose={onClose} panelClassName="max-w-2xl"> <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.isLoading && <Spinner label="Konfiguration wird geladen…" className="py-8" />}
{configQuery.isError && <p role="alert" className="text-sm text-red-600">Konfiguration konnte nicht geladen werden.</p>} {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(); }}> {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>} {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"> <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 [branch, setBranch] = useState(state.branches[0]?.name ?? '');
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [completed, setCompleted] = useState<{ module: Module; branch: string; commit: string | null } | null>(null); const [completed, setCompleted] = useState<{ module: Module; branch: string; commit: string | null } | null>(null);
const [operationId] = useState(() => crypto.randomUUID());
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: () => updateMarketplaceModule(module.id, branch), mutationFn: () => updateMarketplaceModule(module.id, branch, operationId),
onSuccess: async (updatedModule) => { onSuccess: async (updatedModule) => {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: ['modules'] }), 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'), 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 selected = state.branches.find((item) => item.name === branch);
const progress = progressQuery.data?.progress ?? 3;
const handleClose = () => { if (!updateMutation.isPending) onClose(); }; const handleClose = () => { if (!updateMutation.isPending) onClose(); };
return ( return (
<Modal open title={completed ? 'Update erfolgreich' : `Update verfügbar: ${module.name}`} <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>} {selected && <p className="mt-1 text-xs text-slate-500">Commit {selected.commit.slice(0, 12)}</p>}
</div> </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> <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"> {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-valuetext="Installationsvorgang läuft" className="h-2 overflow-hidden rounded-full bg-brand-100"> <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="mpm-indeterminate-progress h-full w-2/5 rounded-full bg-brand-600" /> <div className="h-full rounded-full bg-brand-600 transition-[width] duration-500" style={{ width: `${progress}%` }} />
</div> </div>
<p className="text-sm font-medium text-slate-700">Update auf „{branch}“ wird installiert…</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">MPM aktualisiert das Modul und prüft anschließend den Start. Das kann einige Minuten dauern.</p> <p className="text-xs text-slate-500">Schritt {progressQuery.data?.phase ?? 'starting'} · Das kann je nach Build einige Minuten dauern.</p>
</div>} </div>}
{formError && <p role="alert" className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{formError}</p>} {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"> <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 { function handleFileChange(event: React.ChangeEvent<HTMLInputElement>): void {
setSelectedFile(event.target.files?.[0] ?? null); setSelectedFile(event.target.files?.[0] ?? null);
} }
@@ -487,53 +535,68 @@ export function ModulesPage(): ReactNode {
{/* Modul-Liste */} {/* Modul-Liste */}
<Card className="p-5"> <Card className="p-5">
<h2 className="mb-4 text-lg font-semibold text-slate-900">Installierte Module</h2> <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"> {modulesQuery.isLoading && <div className="space-y-3" role="status" aria-label="Module werden geladen">
<Table className="w-full min-w-[900px] table-fixed text-sm"> {[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 <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>;
})}
</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"> <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"> <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-[25%] 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-[10%] 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-[11%] 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-[19%] px-4 py-3 text-center font-semibold">Konfiguration</TableHead>
<TableHead className="w-[24%] px-4 py-3 font-semibold">Aktionen</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> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{modulesQuery.isLoading && <TableRow><TableCell colSpan={5} className="px-5 py-10 text-center text-slate-500">Module werden geladen…</TableCell></TableRow>} {modulesQuery.data.map((module) => {
{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; const actionPending = lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id;
return ( return <TableRow key={module.id} className="border-b border-slate-100 last:border-0">
<TableRow key={module.id} className="border-b border-slate-100 last:border-0">
<TableCell className="align-top px-4 py-3"> <TableCell className="align-top px-4 py-3">
<div className="font-semibold text-slate-900">{module.name}</div> <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> <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>} {module.description && <div className="mt-0.5 text-xs text-slate-500">{module.description}</div>}
</TableCell> </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> <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>
{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"><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">{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"> <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>
const updateState = moduleUpdateQueries[(modulesQuery.data ?? []).findIndex((item) => item.id === module.id)]?.data; </TableRow>;
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> </TableBody>
</Table> </Table>
{modulesQuery.data?.length === 0 && <p className="px-4 py-8 text-center text-slate-500">Noch keine Module installiert.</p>}
</div> </div>
</>}
</Card> </Card>
</div> </div>
<Card className="p-5"> <Card className="p-5">
@@ -543,19 +606,15 @@ export function ModulesPage(): ReactNode {
&Ouml;ffentliche Repositories verbundener Forge-Konten. Installiere den Standard-Branch direkt; MPM pr&uuml;ft das Modulmanifest vor der Installation. &Ouml;ffentliche Repositories verbundener Forge-Konten. Installiere den Standard-Branch direkt; MPM pr&uuml;ft das Modulmanifest vor der Installation.
</p> </p>
</div> </div>
{connectedProviders.length === 0 && ( {connectedProviders.length === 0 && <EmptyState title="Kein Forge-Konto verbunden" description="Verbinde zuerst GitHub, Gitea oder Forgejo, um Repositories zu installieren." />}
<p className="rounded-lg border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500"> {repositoriesQuery.isLoading && <Spinner label="Repositories werden geladen…" className="py-8" />}
Verbinde zuerst GitHub, Gitea oder Forgejo. {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>} />}
</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"> <div className="max-h-[42rem] space-y-4 overflow-y-auto pr-2">
{repositoriesQuery.data?.map((provider) => ( {repositoriesQuery.data?.map((provider) => (
<div key={provider.provider}> <div key={provider.provider}>
<h3 className="mb-3 text-base font-semibold text-slate-800">{provider.label}</h3> <h3 className="mb-3 text-base font-semibold text-slate-800">{provider.label}</h3>
{provider.repositories.length === 0 ? ( {provider.repositories.length === 0 ? (
<p className="text-sm text-slate-500">Keine &ouml;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"> <div className="grid gap-3 md:grid-cols-2">
{provider.repositories.map((repo) => ( {provider.repositories.map((repo) => (

View File

@@ -4,7 +4,8 @@ import { apiRequest } from '../../lib/api-client';
import { z } from 'zod'; import { z } from 'zod';
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { ErrorState, Spinner } from '../../components/ui/states'; import { Button } from '../../components/ui/button';
import { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
/** Erweiterter Systemstatus (API-Vertrag /api/v1/system/status). */ /** Erweiterter Systemstatus (API-Vertrag /api/v1/system/status). */
const systemStatusSchema = z.object({ const systemStatusSchema = z.object({
@@ -57,10 +58,9 @@ export function SystemStatusPage(): ReactNode {
<CardHeader><CardTitle>Gesamtstatus</CardTitle></CardHeader> <CardHeader><CardTitle>Gesamtstatus</CardTitle></CardHeader>
<CardContent> <CardContent>
{statusQuery.isLoading && <Spinner />} {statusQuery.isLoading && <Spinner />}
{statusQuery.isError && ( {statusQuery.isError && <ErrorState title="Systemstatus nicht erreichbar" message="Prüfe die Verbindung und versuche es erneut." action={<Button variant="secondary" onClick={() => void statusQuery.refetch()}>Erneut versuchen</Button>} />}
<ErrorState message="Der Systemstatus ist nicht erreichbar." /> {statusQuery.data && statusQuery.data.components.length === 0 && <EmptyState title="Keine Komponenten gemeldet" description="Sobald Plattformdienste registriert sind, erscheinen sie hier." />}
)} {statusQuery.data && statusQuery.data.components.length > 0 && (
{statusQuery.data && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Badge <Badge
variant={ variant={

View File

@@ -9,6 +9,7 @@ import { Modal } from '../../components/ui/modal';
import { Select } from '../../components/ui/select'; import { Select } from '../../components/ui/select';
import { useToast } from '../../components/ui/toast'; import { useToast } from '../../components/ui/toast';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table';
import { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
import { ApiError } from '../../lib/api-client'; import { ApiError } from '../../lib/api-client';
import { import {
createUser, createUser,
@@ -418,18 +419,15 @@ export function UsersPage(): ReactNode {
<TableBody> <TableBody>
{usersQuery.isLoading && ( {usersQuery.isLoading && (
<TableRow> <TableRow>
<TableCell colSpan={5} className="px-4 py-8 text-center text-slate-500"> <TableCell colSpan={5}><Spinner label="Benutzer werden geladen…" className="py-8" /></TableCell>
Benutzer werden geladen…
</TableCell>
</TableRow> </TableRow>
)} )}
{usersQuery.isError && ( {usersQuery.isError && (
<TableRow> <TableRow>
<TableCell colSpan={5} className="px-4 py-8 text-center text-red-600"> <TableCell colSpan={5}><ErrorState title="Benutzer konnten nicht geladen werden" message="Prüfe die Verbindung und lade die Liste erneut." action={<Button variant="secondary" onClick={() => void usersQuery.refetch()}>Erneut versuchen</Button>} /></TableCell>
Benutzer konnten nicht geladen werden.
</TableCell>
</TableRow> </TableRow>
)} )}
{usersQuery.data?.length === 0 && <TableRow><TableCell colSpan={5}><EmptyState title="Noch keine Benutzer angelegt" description="Lege den ersten Benutzer über die Schaltfläche oben an." /></TableCell></TableRow>}
{usersQuery.data?.map((user) => ( {usersQuery.data?.map((user) => (
<TableRow key={user.id} className="border-b border-slate-100 last:border-0"> <TableRow key={user.id} className="border-b border-slate-100 last:border-0">
<TableCell className="px-4 py-3"> <TableCell className="px-4 py-3">

View File

@@ -1,15 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@keyframes mpm-progress-sweep {
from { transform: translateX(-110%); }
to { transform: translateX(260%); }
}
.mpm-indeterminate-progress {
animation: mpm-progress-sweep 1.4s ease-in-out infinite;
}
@custom-variant dark (&:where(html[data-theme='dark'], html[data-theme='dark'] *)); @custom-variant dark (&:where(html[data-theme='dark'], html[data-theme='dark'] *));
/* ============================================================= /* =============================================================

View File

@@ -193,14 +193,25 @@ export interface MarketplaceUpdateState {
branches: Array<{ name: string; commit: string }>; branches: Array<{ name: string; commit: string }>;
} }
export interface MarketplaceOperationProgress {
status: 'running' | 'completed' | 'failed';
phase: string;
message: string;
progress: number;
}
export async function fetchMarketplaceUpdates(moduleId: string): Promise<MarketplaceUpdateState> { export async function fetchMarketplaceUpdates(moduleId: string): Promise<MarketplaceUpdateState> {
return apiRequest<MarketplaceUpdateState>(`/api/v1/marketplace/modules/${encodeURIComponent(moduleId)}/updates`); return apiRequest<MarketplaceUpdateState>(`/api/v1/marketplace/modules/${encodeURIComponent(moduleId)}/updates`);
} }
export async function updateMarketplaceModule(moduleId: string, branch: string): Promise<Module> { export async function fetchMarketplaceOperationProgress(operationId: string): Promise<MarketplaceOperationProgress> {
return apiRequest<MarketplaceOperationProgress>(`/api/v1/marketplace/operations/${encodeURIComponent(operationId)}`);
}
export async function updateMarketplaceModule(moduleId: string, branch: string, operationId?: string): Promise<Module> {
const response = await apiRequest<{ module: unknown }>( const response = await apiRequest<{ module: unknown }>(
`/api/v1/marketplace/modules/${encodeURIComponent(moduleId)}/update`, `/api/v1/marketplace/modules/${encodeURIComponent(moduleId)}/update`,
{ method: 'POST', body: { branch } }, { method: 'POST', body: { branch, operationId } },
); );
return moduleSchema.parse(response.module); return moduleSchema.parse(response.module);
} }