feat: add Applications admin page with version upload and deployments
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m12s
CI / docker (push) Successful in 1m3s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s

- SplitPane layout with environment selector, app list, and detail pane
- Create/delete apps with slug uniqueness validation
- Upload JAR versions with file size display
- Deploy versions and stop running deployments with status badges
- Deployment list auto-refreshes every 5s for live status updates
- Registered at /admin/apps with sidebar entry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-08 12:24:22 +02:00
parent 448a63adc9
commit e04dca55aa
4 changed files with 559 additions and 1 deletions

View File

@@ -0,0 +1,405 @@
import { useState, useMemo, useRef } from 'react';
import {
Avatar,
Badge,
Button,
Input,
MonoText,
SectionHeader,
Select,
ConfirmDialog,
SplitPane,
EntityList,
Spinner,
useToast,
} from '@cameleer/design-system';
import { useEnvironments } from '../../api/queries/admin/environments';
import {
useApps,
useCreateApp,
useDeleteApp,
useAppVersions,
useUploadJar,
useDeployments,
useCreateDeployment,
useStopDeployment,
} from '../../api/queries/admin/apps';
import type { App, AppVersion, Deployment } from '../../api/queries/admin/apps';
import styles from './UserManagement.module.css';
function formatBytes(bytes: number): string {
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
const STATUS_COLORS: Record<string, 'success' | 'warning' | 'error' | 'auto' | 'running'> = {
RUNNING: 'running',
STARTING: 'warning',
FAILED: 'error',
STOPPED: 'auto',
};
export default function AppsPage() {
const { toast } = useToast();
const { data: environments = [] } = useEnvironments();
const [envId, setEnvId] = useState<string>('');
const { data: apps = [], isLoading } = useApps(envId || undefined);
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<App | null>(null);
// Create form
const [newSlug, setNewSlug] = useState('');
const [newDisplayName, setNewDisplayName] = useState('');
// Mutations
const createApp = useCreateApp();
const deleteApp = useDeleteApp();
const selected = useMemo(
() => apps.find((a) => a.id === selectedId) ?? null,
[apps, selectedId],
);
const filtered = useMemo(() => {
if (!search) return apps;
const q = search.toLowerCase();
return apps.filter(
(a) =>
a.slug.toLowerCase().includes(q) ||
a.displayName.toLowerCase().includes(q),
);
}, [apps, search]);
const duplicateSlug =
newSlug.trim() !== '' &&
apps.some((a) => a.slug.toLowerCase() === newSlug.trim().toLowerCase());
async function handleCreate() {
if (!newSlug.trim() || !newDisplayName.trim() || !envId) return;
try {
await createApp.mutateAsync({
environmentId: envId,
slug: newSlug.trim(),
displayName: newDisplayName.trim(),
});
toast({ title: 'App created', description: newSlug.trim(), variant: 'success' });
setCreating(false);
setNewSlug('');
setNewDisplayName('');
} catch {
toast({ title: 'Failed to create app', variant: 'error', duration: 86_400_000 });
}
}
async function handleDelete() {
if (!deleteTarget) return;
try {
await deleteApp.mutateAsync(deleteTarget.id);
toast({ title: 'App deleted', description: deleteTarget.slug, variant: 'warning' });
if (selectedId === deleteTarget.id) setSelectedId(null);
setDeleteTarget(null);
} catch {
toast({ title: 'Failed to delete app', variant: 'error', duration: 86_400_000 });
setDeleteTarget(null);
}
}
if (!environments.length) return <Spinner size="md" />;
// Auto-select first environment if none selected
if (!envId && environments.length > 0) {
setEnvId(environments[0].id);
return <Spinner size="md" />;
}
return (
<>
<SplitPane
list={
<>
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border-subtle)' }}>
<Select
value={envId}
onChange={(e) => { setEnvId(e.target.value); setSelectedId(null); }}
options={environments.map((env) => ({
value: env.id,
label: `${env.displayName} (${env.slug})`,
}))}
/>
</div>
{creating && (
<div className={styles.createForm}>
<Input
placeholder="Slug (e.g. my-app) *"
value={newSlug}
onChange={(e) => setNewSlug(e.target.value)}
/>
{duplicateSlug && (
<span className={styles.errorText}>Slug already exists</span>
)}
<Input
placeholder="Display name *"
value={newDisplayName}
onChange={(e) => setNewDisplayName(e.target.value)}
/>
<div className={styles.createFormActions}>
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>
Cancel
</Button>
<Button
size="sm"
variant="primary"
onClick={handleCreate}
loading={createApp.isPending}
disabled={!newSlug.trim() || !newDisplayName.trim() || duplicateSlug}
>
Create
</Button>
</div>
</div>
)}
{isLoading ? (
<Spinner size="md" />
) : (
<EntityList
items={filtered}
renderItem={(app) => (
<>
<Avatar name={app.displayName} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>{app.displayName}</div>
<div className={styles.entityMeta}>{app.slug}</div>
</div>
</>
)}
getItemId={(app) => app.id}
selectedId={selectedId ?? undefined}
onSelect={setSelectedId}
searchPlaceholder="Search apps..."
onSearch={setSearch}
addLabel="+ Add app"
onAdd={() => setCreating(true)}
emptyMessage="No apps in this environment"
/>
)}
</>
}
detail={
selected ? (
<AppDetail
app={selected}
environments={environments}
onDelete={() => setDeleteTarget(selected)}
/>
) : null
}
emptyMessage="Select an app to view details"
/>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
message={`Delete app "${deleteTarget?.displayName}"? All versions and deployments will be removed. This cannot be undone.`}
confirmText={deleteTarget?.slug ?? ''}
loading={deleteApp.isPending}
/>
</>
);
}
// --- App Detail Pane ---
function AppDetail({
app,
environments,
onDelete,
}: {
app: App;
environments: { id: string; slug: string; displayName: string }[];
onDelete: () => void;
}) {
const { toast } = useToast();
const { data: versions = [] } = useAppVersions(app.id);
const { data: deployments = [] } = useDeployments(app.id);
const uploadJar = useUploadJar();
const createDeployment = useCreateDeployment();
const stopDeployment = useStopDeployment();
const fileInputRef = useRef<HTMLInputElement>(null);
const envName = environments.find((e) => e.id === app.environmentId)?.displayName ?? app.environmentId;
const sortedVersions = useMemo(
() => [...versions].sort((a, b) => b.version - a.version),
[versions],
);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
try {
const v = await uploadJar.mutateAsync({ appId: app.id, file });
toast({ title: `Version ${v.version} uploaded`, description: file.name, variant: 'success' });
} catch {
toast({ title: 'Upload failed', variant: 'error', duration: 86_400_000 });
}
if (fileInputRef.current) fileInputRef.current.value = '';
}
async function handleDeploy(versionId: string) {
try {
await createDeployment.mutateAsync({
appId: app.id,
appVersionId: versionId,
environmentId: app.environmentId,
});
toast({ title: 'Deployment started', variant: 'success' });
} catch {
toast({ title: 'Deploy failed', variant: 'error', duration: 86_400_000 });
}
}
async function handleStop(deploymentId: string) {
try {
await stopDeployment.mutateAsync({ appId: app.id, deploymentId });
toast({ title: 'Deployment stopped', variant: 'warning' });
} catch {
toast({ title: 'Stop failed', variant: 'error', duration: 86_400_000 });
}
}
return (
<>
<div className={styles.detailHeader}>
<Avatar name={app.displayName} size="lg" />
<div className={styles.detailHeaderInfo}>
<div className={styles.detailName}>{app.displayName}</div>
<div className={styles.detailEmail}>{app.slug}</div>
</div>
<Button size="sm" variant="danger" onClick={onDelete}>
Delete
</Button>
</div>
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>ID</span>
<MonoText size="xs">{app.id}</MonoText>
<span className={styles.metaLabel}>Slug</span>
<span className={styles.metaValue}>{app.slug}</span>
<span className={styles.metaLabel}>Environment</span>
<span className={styles.metaValue}>{envName}</span>
<span className={styles.metaLabel}>Created</span>
<span className={styles.metaValue}>
{new Date(app.createdAt).toLocaleDateString()}
</span>
</div>
{/* Versions */}
<SectionHeader>
Versions ({versions.length})
</SectionHeader>
<div style={{ marginBottom: 8 }}>
<input
ref={fileInputRef}
type="file"
accept=".jar"
style={{ display: 'none' }}
onChange={handleUpload}
/>
<Button
size="sm"
variant="secondary"
onClick={() => fileInputRef.current?.click()}
loading={uploadJar.isPending}
>
Upload JAR
</Button>
</div>
{sortedVersions.length === 0 && (
<p className={styles.inheritedNote}>No versions uploaded yet.</p>
)}
{sortedVersions.map((v) => (
<VersionRow key={v.id} version={v} onDeploy={() => handleDeploy(v.id)} />
))}
{/* Deployments */}
<SectionHeader>
Deployments ({deployments.length})
</SectionHeader>
{deployments.length === 0 && (
<p className={styles.inheritedNote}>No deployments yet.</p>
)}
{deployments.map((d) => (
<DeploymentRow
key={d.id}
deployment={d}
versions={versions}
environments={environments}
onStop={() => handleStop(d.id)}
/>
))}
</>
);
}
// --- Version Row ---
function VersionRow({ version, onDeploy }: { version: AppVersion; onDeploy: () => void }) {
return (
<div className={styles.securityRow} style={{ padding: '6px 0', borderBottom: '1px solid var(--border-subtle)' }}>
<Badge label={`v${version.version}`} color="auto" />
<span style={{ flex: 1, fontSize: 12 }}>
{version.jarFilename} ({formatBytes(version.jarSizeBytes)})
</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{new Date(version.uploadedAt).toLocaleDateString()}
</span>
<Button size="sm" variant="secondary" onClick={onDeploy}>
Deploy
</Button>
</div>
);
}
// --- Deployment Row ---
function DeploymentRow({
deployment,
versions,
environments,
onStop,
}: {
deployment: Deployment;
versions: AppVersion[];
environments: { id: string; slug: string; displayName: string }[];
onStop: () => void;
}) {
const version = versions.find((v) => v.id === deployment.appVersionId);
const env = environments.find((e) => e.id === deployment.environmentId);
const canStop = deployment.status === 'RUNNING' || deployment.status === 'STARTING';
return (
<div className={styles.securityRow} style={{ padding: '6px 0', borderBottom: '1px solid var(--border-subtle)' }}>
<Badge label={deployment.status} color={STATUS_COLORS[deployment.status] ?? 'auto'} />
<span style={{ flex: 1, fontSize: 12 }}>
{version ? `v${version.version}` : '?'} in {env?.displayName ?? '?'}
</span>
{deployment.containerName && (
<MonoText size="xs">{deployment.containerName}</MonoText>
)}
{deployment.errorMessage && (
<span style={{ fontSize: 11, color: 'var(--error)' }}>{deployment.errorMessage}</span>
)}
{canStop && (
<Button size="sm" variant="danger" onClick={onStop}>
Stop
</Button>
)}
</div>
);
}