diff --git a/apps/platform-backend/src/modules/marketplace.controller.ts b/apps/platform-backend/src/modules/marketplace.controller.ts index bfa5f0d..e87e6c1 100644 --- a/apps/platform-backend/src/modules/marketplace.controller.ts +++ b/apps/platform-backend/src/modules/marketplace.controller.ts @@ -77,19 +77,43 @@ export class MarketplaceController { 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') @Roles('ADMIN') async updateModule( @Param('moduleId') moduleId: string, - @Body() body: { branch?: unknown }, + @Body() body: { branch?: unknown; operationId?: unknown }, @CurrentUser() actor: AuthUser, @Req() request: AuthenticatedRequest, ): Promise<{ module: ModuleRecord }> { if (typeof body.branch !== 'string') throw new BadRequestException('Bitte eine Update-Branch auswählen'); - const update = await this.marketplaceService.updateInstalledBranch(moduleId, body.branch); - const module = await this.modulesService.updateFromMarketplace(moduleId, update.archive, actor, request.ip ?? null); - await this.marketplaceService.commitInstalledBranch(moduleId, update.provider, update.owner, update.repository, update.branch, update.commit); - return { module }; + const operationId = this.marketplaceService.beginOperation(body.operationId, actor.id); + 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); + this.marketplaceService.finishOperation(operationId, actor.id, true); + return { module }; + } catch (error) { + this.marketplaceService.finishOperation(operationId, actor.id, false); + throw error; + } } @Post('connections/:provider/start') diff --git a/apps/platform-backend/src/modules/marketplace.service.ts b/apps/platform-backend/src/modules/marketplace.service.ts index ec91f16..b75a88c 100644 --- a/apps/platform-backend/src/modules/marketplace.service.ts +++ b/apps/platform-backend/src/modules/marketplace.service.ts @@ -54,6 +54,13 @@ export interface MarketplaceUpdatePackage { branch: string; } +export interface MarketplaceOperationProgress { + status: 'running' | 'completed' | 'failed'; + message: string; + phase: string; + progress: number; +} + interface MarketplaceInstallationRow { module_id: string; provider: MarketplaceProvider; @@ -106,6 +113,7 @@ function isNewerVersionBranch(candidate: string, installed: string): boolean { export class MarketplaceService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(MarketplaceService.name); private branchCheckTimer: NodeJS.Timeout | undefined; + private readonly operationProgress = new Map(); constructor( private readonly database: DatabaseService, @@ -123,6 +131,37 @@ export class MarketplaceService implements OnModuleInit, OnModuleDestroy { 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 { const connected = await this.database.query<{ provider: MarketplaceProvider; account_login: string }>( 'SELECT provider, account_login FROM marketplace_connections', @@ -344,7 +383,11 @@ export class MarketplaceService implements OnModuleInit, OnModuleDestroy { return this.normalizeRepositoryArchive(archive); } - async updateInstalledBranch(moduleId: string, branch: string): Promise { + async updateInstalledBranch( + moduleId: string, + branch: string, + onProgress?: (phase: string, message: string, progress: number) => void, + ): Promise { const result = await this.database.query( `SELECT module_id, provider, owner, repository, installed_branch, installed_commit, available_branches 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)) { 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); 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'); } + onProgress?.('download', 'Update-Archiv wird geladen', 28); 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. return { archive, commit: branchInfo.commit, provider: installation.provider, diff --git a/apps/platform-backend/src/modules/modules.service.ts b/apps/platform-backend/src/modules/modules.service.ts index dd8a327..a052243 100644 --- a/apps/platform-backend/src/modules/modules.service.ts +++ b/apps/platform-backend/src/modules/modules.service.ts @@ -149,7 +149,9 @@ export class ModulesService { packageBuffer: Buffer, actor: ActingUser, ipAddress: string | null, + onProgress?: (phase: string, message: string, progress: number) => void, ): Promise { + onProgress?.('validation', 'Update-Paket wird geprüft', 42); const current = await this.getById(id); if (['STARTING', 'STOPPING', 'ERROR'].includes(current.status)) { 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'); } 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; try { + onProgress?.('replacing', 'Moduldateien werden aktualisiert', 66); replacement = await this.installer.replace(packageBuffer, manifest, this.config.runtime.modulesDir); const candidate = { ...current, name: manifest.name, version: manifest.version, description: manifest.description, author: manifest.author, configuration: manifest.configuration }; const configuration = await this.configurationService.state(candidate); 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) => { 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). */ - async start(id: string, actor: ActingUser, ipAddress: string | null): Promise { + async start( + id: string, + actor: ActingUser, + ipAddress: string | null, + onProgress?: (phase: string, message: string, progress: number) => void, + ): Promise { const module = await this.getById(id); this.assertEnabled(module); if (module.status === 'RUNNING' || module.status === 'STARTING') { @@ -219,9 +230,11 @@ export class ModulesService { await this.moduleRepository.updateStatus(id, 'STARTING'); try { + onProgress?.('starting', 'Modulcontainer werden gestartet', 80); await this.processManager.start(module); // Startup-Grace: Der Modul-Prozess braucht einen Moment zum Starten. // Der Healthcheck wird mit Retries wiederholt, bevor er als Fehlschlag gilt. + onProgress?.('healthcheck', 'Healthcheck läuft', 91); const health = await this.waitForHealthy(module); if (!health.healthy) { throw new Error(`Healthcheck fehlgeschlagen: ${health.detail}`); diff --git a/apps/platform-frontend/src/components/layout/app-layout.tsx b/apps/platform-frontend/src/components/layout/app-layout.tsx index d005170..8c54a9c 100644 --- a/apps/platform-frontend/src/components/layout/app-layout.tsx +++ b/apps/platform-frontend/src/components/layout/app-layout.tsx @@ -148,9 +148,8 @@ function AppLayoutContent(): ReactNode { -
+
- {darkMode ? 'Hell' : 'Dunkel'}
diff --git a/apps/platform-frontend/src/components/ui/states.tsx b/apps/platform-frontend/src/components/ui/states.tsx index 422d5df..38c4824 100644 --- a/apps/platform-frontend/src/components/ui/states.tsx +++ b/apps/platform-frontend/src/components/ui/states.tsx @@ -22,15 +22,17 @@ export interface EmptyStateProps { title: string; description?: string; icon?: ReactNode; + action?: ReactNode; } /** 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 (
{icon &&
{icon}
}

{title}

{description &&

{description}

} + {action}
); } @@ -38,12 +40,14 @@ export function EmptyState({ title, description, icon }: EmptyStateProps): React export interface ErrorStateProps { title?: string; message?: string; + action?: ReactNode; } /** Anzeige für Fehlerzustände (Design-System). */ export function ErrorState({ title = 'Ein Fehler ist aufgetreten', message = 'Bitte versuchen Sie es später erneut.', + action, }: ErrorStateProps): ReactNode { return (
@@ -52,6 +56,7 @@ export function ErrorState({

{title}

{message}

+ {action}
); -} \ No newline at end of file +} diff --git a/apps/platform-frontend/src/features/admin/audit-page.tsx b/apps/platform-frontend/src/features/admin/audit-page.tsx index b297a17..440a60d 100644 --- a/apps/platform-frontend/src/features/admin/audit-page.tsx +++ b/apps/platform-frontend/src/features/admin/audit-page.tsx @@ -5,7 +5,7 @@ import { z } from 'zod'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card'; import { Input } from '../../components/ui/input'; 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'; /** Audit-Eintrag (API-Vertrag /api/v1/audit). */ @@ -119,11 +119,9 @@ export function AuditPage(): ReactNode { {auditQuery.isLoading && } - {auditQuery.isError && } + {auditQuery.isError && void auditQuery.refetch()}>Erneut versuchen} />} {auditQuery.data && auditQuery.data.entries.length === 0 && ( -

- Keine Einträge für die gewählten Filter. -

+ )} {auditQuery.data && auditQuery.data.entries.length > 0 && (
diff --git a/apps/platform-frontend/src/features/admin/modules-page.tsx b/apps/platform-frontend/src/features/admin/modules-page.tsx index 4b96aeb..2c32b91 100644 --- a/apps/platform-frontend/src/features/admin/modules-page.tsx +++ b/apps/platform-frontend/src/features/admin/modules-page.tsx @@ -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 ( - {configQuery.isLoading &&

Konfiguration wird geladen…

} - {configQuery.isError &&

Konfiguration konnte nicht geladen werden.

} + {configQuery.isLoading && } + {configQuery.isError && void configQuery.refetch()}>Erneut versuchen} />} {configQuery.data &&
{ event.preventDefault(); setFormError(null); saveMutation.mutate(); }}> {module.status === 'RUNNING' &&

Das laufende Modul wird nach dem Speichern neu gestartet.

}
@@ -181,8 +185,9 @@ function ModuleUpdateModal({ module, state, onClose }: { module: Module; state: const [branch, setBranch] = useState(state.branches[0]?.name ?? ''); const [formError, setFormError] = useState(null); const [completed, setCompleted] = useState<{ module: Module; branch: string; commit: string | null } | null>(null); + const [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 ( Commit {selected.commit.slice(0, 12)}

}

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

- {updateMutation.isPending &&
-
-
+ {updateMutation.isPending &&
+
+
-

Update auf „{branch}“ wird installiert…

-

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

+

{progressQuery.data?.message ?? `Update auf „${branch}“ wird vorbereitet`}

+

Schritt {progressQuery.data?.phase ?? 'starting'} · Das kann je nach Build einige Minuten dauern.

} {formError &&

{formError}

}
@@ -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 ; + } + if (updateState?.installedBranch) return Keine Updates; + if (updateQuery?.isLoading) return ; + return Kein Update; + }; + + const renderModuleActions = (module: Module, compact = false): ReactNode => { + const actionPending = lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id; + const action = lifecycleMutation.variables?.action; + return + + + + + {module.status !== 'RUNNING' && module.enabled && lifecycleMutation.mutate({ module, action: 'start' })}>Start} + {(module.status === 'RUNNING' || module.status === 'STARTING') && lifecycleMutation.mutate({ module, action: 'stop' })}>Stop} + lifecycleMutation.mutate({ module, action: 'restart' })}>Restart + healthMutation.mutate(module)}>{healthMutation.isPending && healthMutation.variables?.id === module.id ? 'Health läuft…' : 'Health prüfen'} + + lifecycleMutation.mutate({ module, action: module.enabled ? 'disable' : 'enable' })}>{module.enabled ? 'Disable' : 'Enable'} + + setRemoveTarget(module)}>Remove + + {actionPending && action && {action} läuft} + ; + }; + function handleFileChange(event: React.ChangeEvent): void { setSelectedFile(event.target.files?.[0] ?? null); } @@ -487,53 +535,68 @@ export function ModulesPage(): ReactNode { {/* Modul-Liste */}

Installierte Module

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

{module.name}

+

{module.moduleId} · Version {module.version}{module.author && ` · ${module.author}`}

+
+ {module.status} +
+ {module.description &&

{module.description}

} +
+

URL

/{module.slug}
+ {module.configuration.length > 0 &&

Konfiguration

{module.configurationReady ? 'Konfig gesetzt' : 'Konfiguration erforderlich'}

} +
+ {healthResults[module.id] &&

Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}

} +
+

Updates

{renderModuleUpdates(module)}
+

Aktionen

{renderModuleActions(module, true)}
+
+
; })} - -
- {modulesQuery.data?.length === 0 &&

Noch keine Module installiert.

} -
+
+
+ + + + Modul + Status + URL + Konfiguration + Updates + Aktionen + + + + {modulesQuery.data.map((module) => { + const actionPending = lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id; + return + +
{module.name}
+
{module.moduleId} · Version {module.version}{module.author && ` · ${module.author}`}
+ {module.description &&
{module.description}
} +
+
{module.status}
{healthResults[module.id] &&
Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}
}
+
/{module.slug}
+ {module.configuration.length > 0 &&
{module.configurationReady ? 'Konfig gesetzt' : 'Konfiguration erforderlich'}
}
+ {renderModuleUpdates(module)} +
{renderModuleActions(module)}
+
; + })} +
+
+
+ }
@@ -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.

- {connectedProviders.length === 0 && ( -

- Verbinde zuerst GitHub, Gitea oder Forgejo. -

- )} - {repositoriesQuery.isLoading &&

Repositories werden geladen...

} - {repositoriesQuery.isError &&

Repositories konnten nicht geladen werden.

} + {connectedProviders.length === 0 && } + {repositoriesQuery.isLoading && } + {repositoriesQuery.isError && void repositoriesQuery.refetch()}>Erneut versuchen} />}
{repositoriesQuery.data?.map((provider) => (

{provider.label}

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

Keine öffentlichen Repositories gefunden.

+ ) : (
{provider.repositories.map((repo) => ( diff --git a/apps/platform-frontend/src/features/admin/system-status-page.tsx b/apps/platform-frontend/src/features/admin/system-status-page.tsx index b9cd77e..b826a91 100644 --- a/apps/platform-frontend/src/features/admin/system-status-page.tsx +++ b/apps/platform-frontend/src/features/admin/system-status-page.tsx @@ -4,7 +4,8 @@ import { apiRequest } from '../../lib/api-client'; import { z } from 'zod'; import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card'; 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). */ const systemStatusSchema = z.object({ @@ -57,10 +58,9 @@ export function SystemStatusPage(): ReactNode { Gesamtstatus {statusQuery.isLoading && } - {statusQuery.isError && ( - - )} - {statusQuery.data && ( + {statusQuery.isError && void statusQuery.refetch()}>Erneut versuchen} />} + {statusQuery.data && statusQuery.data.components.length === 0 && } + {statusQuery.data && statusQuery.data.components.length > 0 && (
{usersQuery.isLoading && ( - - Benutzer werden geladen… - + )} {usersQuery.isError && ( - - Benutzer konnten nicht geladen werden. - + void usersQuery.refetch()}>Erneut versuchen} /> )} + {usersQuery.data?.length === 0 && } {usersQuery.data?.map((user) => ( diff --git a/apps/platform-frontend/src/index.css b/apps/platform-frontend/src/index.css index 633cde3..10e0d32 100644 --- a/apps/platform-frontend/src/index.css +++ b/apps/platform-frontend/src/index.css @@ -1,15 +1,6 @@ @import "tailwindcss"; @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'] *)); /* ============================================================= diff --git a/apps/platform-frontend/src/lib/modules-api.ts b/apps/platform-frontend/src/lib/modules-api.ts index 4a08cb0..7201df8 100644 --- a/apps/platform-frontend/src/lib/modules-api.ts +++ b/apps/platform-frontend/src/lib/modules-api.ts @@ -193,14 +193,25 @@ export interface MarketplaceUpdateState { 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 { return apiRequest(`/api/v1/marketplace/modules/${encodeURIComponent(moduleId)}/updates`); } -export async function updateMarketplaceModule(moduleId: string, branch: string): Promise { +export async function fetchMarketplaceOperationProgress(operationId: string): Promise { + return apiRequest(`/api/v1/marketplace/operations/${encodeURIComponent(operationId)}`); +} + +export async function updateMarketplaceModule(moduleId: string, branch: string, operationId?: string): Promise { const response = await apiRequest<{ module: unknown }>( `/api/v1/marketplace/modules/${encodeURIComponent(moduleId)}/update`, - { method: 'POST', body: { branch } }, + { method: 'POST', body: { branch, operationId } }, ); return moduleSchema.parse(response.module); }