feat: add Applications admin page with version upload and deployments
- 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:
150
ui/src/api/queries/admin/apps.ts
Normal file
150
ui/src/api/queries/admin/apps.ts
Normal 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'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -94,11 +94,12 @@ export function buildAppTreeNodes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin tree — static 6 nodes.
|
* Admin tree — static nodes.
|
||||||
*/
|
*/
|
||||||
export function buildAdminTreeNodes(): SidebarTreeNode[] {
|
export function buildAdminTreeNodes(): SidebarTreeNode[] {
|
||||||
return [
|
return [
|
||||||
{ id: 'admin:environments', label: 'Environments', path: '/admin/environments' },
|
{ 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:rbac', label: 'Users & Roles', path: '/admin/rbac' },
|
||||||
{ id: 'admin:audit', label: 'Audit Log', path: '/admin/audit' },
|
{ id: 'admin:audit', label: 'Audit Log', path: '/admin/audit' },
|
||||||
{ id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' },
|
{ id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' },
|
||||||
|
|||||||
405
ui/src/pages/Admin/AppsPage.tsx
Normal file
405
ui/src/pages/Admin/AppsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ const OidcConfigPage = lazy(() => import('./pages/Admin/OidcConfigPage'));
|
|||||||
const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage'));
|
const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage'));
|
||||||
const ClickHouseAdminPage = lazy(() => import('./pages/Admin/ClickHouseAdminPage'));
|
const ClickHouseAdminPage = lazy(() => import('./pages/Admin/ClickHouseAdminPage'));
|
||||||
const EnvironmentsPage = lazy(() => import('./pages/Admin/EnvironmentsPage'));
|
const EnvironmentsPage = lazy(() => import('./pages/Admin/EnvironmentsPage'));
|
||||||
|
const AppsPage = lazy(() => import('./pages/Admin/AppsPage'));
|
||||||
const AppConfigPage = lazy(() => import('./pages/Admin/AppConfigPage'));
|
const AppConfigPage = lazy(() => import('./pages/Admin/AppConfigPage'));
|
||||||
const LogsPage = lazy(() => import('./pages/LogsTab/LogsPage'));
|
const LogsPage = lazy(() => import('./pages/LogsTab/LogsPage'));
|
||||||
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
|
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
|
||||||
@@ -83,6 +84,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
|
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
|
||||||
{ path: 'clickhouse', element: <SuspenseWrapper><ClickHouseAdminPage /></SuspenseWrapper> },
|
{ path: 'clickhouse', element: <SuspenseWrapper><ClickHouseAdminPage /></SuspenseWrapper> },
|
||||||
{ path: 'environments', element: <SuspenseWrapper><EnvironmentsPage /></SuspenseWrapper> },
|
{ path: 'environments', element: <SuspenseWrapper><EnvironmentsPage /></SuspenseWrapper> },
|
||||||
|
{ path: 'apps', element: <SuspenseWrapper><AppsPage /></SuspenseWrapper> },
|
||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user