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,150 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { config } from '../../../config';
import { useAuthStore } from '../../../auth/auth-store';
export interface App {
id: string;
environmentId: string;
slug: string;
displayName: string;
createdAt: string;
}
export interface AppVersion {
id: string;
appId: string;
version: number;
jarPath: string;
jarChecksum: string;
jarFilename: string;
jarSizeBytes: number;
uploadedAt: string;
}
export interface Deployment {
id: string;
appId: string;
appVersionId: string;
environmentId: string;
status: 'STARTING' | 'RUNNING' | 'FAILED' | 'STOPPED';
containerId: string | null;
containerName: string | null;
errorMessage: string | null;
deployedAt: string | null;
stoppedAt: string | null;
createdAt: string;
}
async function appFetch<T>(path: string, options?: RequestInit): Promise<T> {
const token = useAuthStore.getState().accessToken;
const res = await fetch(`${config.apiBaseUrl}/apps${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
'X-Cameleer-Protocol-Version': '1',
...options?.headers,
},
});
if (res.status === 401 || res.status === 403) {
useAuthStore.getState().logout();
throw new Error('Unauthorized');
}
if (!res.ok) throw new Error(`API error: ${res.status}`);
if (res.status === 204) return undefined as T;
const text = await res.text();
if (!text) return undefined as T;
return JSON.parse(text);
}
// --- Apps ---
export function useApps(environmentId: string | undefined) {
return useQuery({
queryKey: ['apps', environmentId],
queryFn: () => appFetch<App[]>(`?environmentId=${environmentId}`),
enabled: !!environmentId,
});
}
export function useCreateApp() {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: { environmentId: string; slug: string; displayName: string }) =>
appFetch<App>('', { method: 'POST', body: JSON.stringify(req) }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
});
}
export function useDeleteApp() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
appFetch<void>(`/${id}`, { method: 'DELETE' }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
});
}
// --- Versions ---
export function useAppVersions(appId: string | undefined) {
return useQuery({
queryKey: ['apps', appId, 'versions'],
queryFn: () => appFetch<AppVersion[]>(`/${appId}/versions`),
enabled: !!appId,
});
}
export function useUploadJar() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ appId, file }: { appId: string; file: File }) => {
const token = useAuthStore.getState().accessToken;
const form = new FormData();
form.append('file', file);
const res = await fetch(`${config.apiBaseUrl}/apps/${appId}/versions`, {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
'X-Cameleer-Protocol-Version': '1',
},
body: form,
});
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
return res.json() as Promise<AppVersion>;
},
onSuccess: (_data, { appId }) =>
qc.invalidateQueries({ queryKey: ['apps', appId, 'versions'] }),
});
}
// --- Deployments ---
export function useDeployments(appId: string | undefined) {
return useQuery({
queryKey: ['apps', appId, 'deployments'],
queryFn: () => appFetch<Deployment[]>(`/${appId}/deployments`),
enabled: !!appId,
refetchInterval: 5000,
});
}
export function useCreateDeployment() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ appId, ...req }: { appId: string; appVersionId: string; environmentId: string }) =>
appFetch<Deployment>(`/${appId}/deployments`, { method: 'POST', body: JSON.stringify(req) }),
onSuccess: (_data, { appId }) =>
qc.invalidateQueries({ queryKey: ['apps', appId, 'deployments'] }),
});
}
export function useStopDeployment() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ appId, deploymentId }: { appId: string; deploymentId: string }) =>
appFetch<Deployment>(`/${appId}/deployments/${deploymentId}/stop`, { method: 'POST' }),
onSuccess: (_data, { appId }) =>
qc.invalidateQueries({ queryKey: ['apps', appId, 'deployments'] }),
});
}

View File

@@ -94,11 +94,12 @@ export function buildAppTreeNodes(
}
/**
* Admin tree — static 6 nodes.
* Admin tree — static nodes.
*/
export function buildAdminTreeNodes(): SidebarTreeNode[] {
return [
{ id: 'admin:environments', label: 'Environments', path: '/admin/environments' },
{ id: 'admin:apps', label: 'Applications', path: '/admin/apps' },
{ id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' },
{ id: 'admin:audit', label: 'Audit Log', path: '/admin/audit' },
{ id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' },

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>
);
}

View File

@@ -18,6 +18,7 @@ const OidcConfigPage = lazy(() => import('./pages/Admin/OidcConfigPage'));
const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage'));
const ClickHouseAdminPage = lazy(() => import('./pages/Admin/ClickHouseAdminPage'));
const EnvironmentsPage = lazy(() => import('./pages/Admin/EnvironmentsPage'));
const AppsPage = lazy(() => import('./pages/Admin/AppsPage'));
const AppConfigPage = lazy(() => import('./pages/Admin/AppConfigPage'));
const LogsPage = lazy(() => import('./pages/LogsTab/LogsPage'));
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
@@ -83,6 +84,7 @@ export const router = createBrowserRouter([
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
{ path: 'clickhouse', element: <SuspenseWrapper><ClickHouseAdminPage /></SuspenseWrapper> },
{ path: 'environments', element: <SuspenseWrapper><EnvironmentsPage /></SuspenseWrapper> },
{ path: 'apps', element: <SuspenseWrapper><AppsPage /></SuspenseWrapper> },
],
}],
},