diff --git a/ui/src/api/queries/admin/apps.ts b/ui/src/api/queries/admin/apps.ts new file mode 100644 index 00000000..7752a049 --- /dev/null +++ b/ui/src/api/queries/admin/apps.ts @@ -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(path: string, options?: RequestInit): Promise { + 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(`?environmentId=${environmentId}`), + enabled: !!environmentId, + }); +} + +export function useCreateApp() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (req: { environmentId: string; slug: string; displayName: string }) => + appFetch('', { method: 'POST', body: JSON.stringify(req) }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }), + }); +} + +export function useDeleteApp() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + appFetch(`/${id}`, { method: 'DELETE' }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }), + }); +} + +// --- Versions --- + +export function useAppVersions(appId: string | undefined) { + return useQuery({ + queryKey: ['apps', appId, 'versions'], + queryFn: () => appFetch(`/${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; + }, + onSuccess: (_data, { appId }) => + qc.invalidateQueries({ queryKey: ['apps', appId, 'versions'] }), + }); +} + +// --- Deployments --- + +export function useDeployments(appId: string | undefined) { + return useQuery({ + queryKey: ['apps', appId, 'deployments'], + queryFn: () => appFetch(`/${appId}/deployments`), + enabled: !!appId, + refetchInterval: 5000, + }); +} + +export function useCreateDeployment() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ appId, ...req }: { appId: string; appVersionId: string; environmentId: string }) => + appFetch(`/${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(`/${appId}/deployments/${deploymentId}/stop`, { method: 'POST' }), + onSuccess: (_data, { appId }) => + qc.invalidateQueries({ queryKey: ['apps', appId, 'deployments'] }), + }); +} diff --git a/ui/src/components/sidebar-utils.ts b/ui/src/components/sidebar-utils.ts index e2dda212..f0b454f1 100644 --- a/ui/src/components/sidebar-utils.ts +++ b/ui/src/components/sidebar-utils.ts @@ -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' }, diff --git a/ui/src/pages/Admin/AppsPage.tsx b/ui/src/pages/Admin/AppsPage.tsx new file mode 100644 index 00000000..824aa310 --- /dev/null +++ b/ui/src/pages/Admin/AppsPage.tsx @@ -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 = { + RUNNING: 'running', + STARTING: 'warning', + FAILED: 'error', + STOPPED: 'auto', +}; + +export default function AppsPage() { + const { toast } = useToast(); + const { data: environments = [] } = useEnvironments(); + const [envId, setEnvId] = useState(''); + const { data: apps = [], isLoading } = useApps(envId || undefined); + + const [search, setSearch] = useState(''); + const [selectedId, setSelectedId] = useState(null); + const [creating, setCreating] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(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 ; + + // Auto-select first environment if none selected + if (!envId && environments.length > 0) { + setEnvId(environments[0].id); + return ; + } + + return ( + <> + +
+ setNewSlug(e.target.value)} + /> + {duplicateSlug && ( + Slug already exists + )} + setNewDisplayName(e.target.value)} + /> +
+ + +
+
+ )} + + {isLoading ? ( + + ) : ( + ( + <> + +
+
{app.displayName}
+
{app.slug}
+
+ + )} + 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 ? ( + setDeleteTarget(selected)} + /> + ) : null + } + emptyMessage="Select an app to view details" + /> + + 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(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) { + 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 ( + <> +
+ +
+
{app.displayName}
+
{app.slug}
+
+ +
+ +
+ ID + {app.id} + Slug + {app.slug} + Environment + {envName} + Created + + {new Date(app.createdAt).toLocaleDateString()} + +
+ + {/* Versions */} + + Versions ({versions.length}) + +
+ + +
+ {sortedVersions.length === 0 && ( +

No versions uploaded yet.

+ )} + {sortedVersions.map((v) => ( + handleDeploy(v.id)} /> + ))} + + {/* Deployments */} + + Deployments ({deployments.length}) + + {deployments.length === 0 && ( +

No deployments yet.

+ )} + {deployments.map((d) => ( + handleStop(d.id)} + /> + ))} + + ); +} + +// --- Version Row --- + +function VersionRow({ version, onDeploy }: { version: AppVersion; onDeploy: () => void }) { + return ( +
+ + + {version.jarFilename} ({formatBytes(version.jarSizeBytes)}) + + + {new Date(version.uploadedAt).toLocaleDateString()} + + +
+ ); +} + +// --- 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 ( +
+ + + {version ? `v${version.version}` : '?'} in {env?.displayName ?? '?'} + + {deployment.containerName && ( + {deployment.containerName} + )} + {deployment.errorMessage && ( + {deployment.errorMessage} + )} + {canStop && ( + + )} +
+ ); +} diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 7cceaeb9..bed2ab1b 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -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: }, { path: 'clickhouse', element: }, { path: 'environments', element: }, + { path: 'apps', element: }, ], }], },