Polish module actions and marketplace updates
This commit is contained in:
@@ -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')
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
|||||||
Öffentliche Repositories verbundener Forge-Konten. Installiere den Standard-Branch direkt; MPM prüft das Modulmanifest vor der Installation.
|
Öffentliche Repositories verbundener Forge-Konten. Installiere den Standard-Branch direkt; MPM prü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 ö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) => (
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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'] *));
|
||||||
|
|
||||||
/* =============================================================
|
/* =============================================================
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user