feat: move Apps from admin to main tab bar with container config
- Apps tab visible to OPERATOR+ (hidden for VIEWER), scoped by sidebar app selection and environment filter - List view: DataTable with name, environment, updated, created columns - Detail view: deployments across all envs, version upload with per-env deploy target, container config form (resources, ports, custom env vars) with explicit Save - Memory reserve field disabled for non-production environments with info hint - Admin sidebar sorted alphabetically, Applications entry removed - Old admin AppsPage deleted Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,9 @@ export interface App {
|
|||||||
environmentId: string;
|
environmentId: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
containerConfig: Record<string, unknown>;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppVersion {
|
export interface AppVersion {
|
||||||
@@ -59,6 +61,13 @@ async function appFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
|||||||
|
|
||||||
// --- Apps ---
|
// --- Apps ---
|
||||||
|
|
||||||
|
export function useAllApps() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['apps', 'all'],
|
||||||
|
queryFn: () => appFetch<App[]>(''),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useApps(environmentId: string | undefined) {
|
export function useApps(environmentId: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['apps', environmentId],
|
queryKey: ['apps', environmentId],
|
||||||
@@ -85,6 +94,15 @@ export function useDeleteApp() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUpdateContainerConfig() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ appId, config }: { appId: string; config: Record<string, unknown> }) =>
|
||||||
|
appFetch<App>(`/${appId}/container-config`, { method: 'PUT', body: JSON.stringify(config) }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Versions ---
|
// --- Versions ---
|
||||||
|
|
||||||
export function useAppVersions(appId: string | undefined) {
|
export function useAppVersions(appId: string | undefined) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface Environment {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
production: boolean;
|
production: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
defaultContainerConfig: Record<string, unknown>;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { Tabs } from '@cameleer/design-system';
|
import { Tabs } from '@cameleer/design-system';
|
||||||
import type { TabKey, Scope } from '../hooks/useScope';
|
import type { TabKey, Scope } from '../hooks/useScope';
|
||||||
import { TabKpis } from './TabKpis';
|
import { TabKpis } from './TabKpis';
|
||||||
|
import { useCanControl } from '../auth/auth-store';
|
||||||
import styles from './ContentTabs.module.css';
|
import styles from './ContentTabs.module.css';
|
||||||
|
|
||||||
const TABS = [
|
const BASE_TABS = [
|
||||||
{ label: 'Exchanges', value: 'exchanges' },
|
{ label: 'Exchanges', value: 'exchanges' },
|
||||||
{ label: 'Dashboard', value: 'dashboard' },
|
{ label: 'Dashboard', value: 'dashboard' },
|
||||||
{ label: 'Runtime', value: 'runtime' },
|
{ label: 'Runtime', value: 'runtime' },
|
||||||
@@ -18,10 +20,16 @@ interface ContentTabsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ContentTabs({ active, onChange, scope }: ContentTabsProps) {
|
export function ContentTabs({ active, onChange, scope }: ContentTabsProps) {
|
||||||
|
const canControl = useCanControl();
|
||||||
|
const tabs = useMemo(() => {
|
||||||
|
if (!canControl) return BASE_TABS;
|
||||||
|
return [...BASE_TABS, { label: 'Apps', value: 'apps' }];
|
||||||
|
}, [canControl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
<Tabs
|
<Tabs
|
||||||
tabs={TABS}
|
tabs={tabs}
|
||||||
active={active}
|
active={active}
|
||||||
onChange={(v) => onChange(v as TabKey)}
|
onChange={(v) => onChange(v as TabKey)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -94,16 +94,15 @@ export function buildAppTreeNodes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin tree — static nodes.
|
* Admin tree — static nodes, alphabetically sorted.
|
||||||
*/
|
*/
|
||||||
export function buildAdminTreeNodes(): SidebarTreeNode[] {
|
export function buildAdminTreeNodes(): SidebarTreeNode[] {
|
||||||
return [
|
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:audit', label: 'Audit Log', path: '/admin/audit' },
|
||||||
{ id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' },
|
|
||||||
{ id: 'admin:database', label: 'Database', path: '/admin/database' },
|
|
||||||
{ id: 'admin:clickhouse', label: 'ClickHouse', path: '/admin/clickhouse' },
|
{ id: 'admin:clickhouse', label: 'ClickHouse', path: '/admin/clickhouse' },
|
||||||
|
{ id: 'admin:database', label: 'Database', path: '/admin/database' },
|
||||||
|
{ id: 'admin:environments', label: 'Environments', path: '/admin/environments' },
|
||||||
|
{ id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' },
|
||||||
|
{ id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
import { useParams, useNavigate, useLocation } from 'react-router';
|
import { useParams, useNavigate, useLocation } from 'react-router';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
export type TabKey = 'exchanges' | 'dashboard' | 'runtime' | 'logs' | 'config';
|
export type TabKey = 'exchanges' | 'dashboard' | 'runtime' | 'logs' | 'config' | 'apps';
|
||||||
|
|
||||||
const VALID_TABS = new Set<TabKey>(['exchanges', 'dashboard', 'runtime', 'logs', 'config']);
|
const VALID_TABS = new Set<TabKey>(['exchanges', 'dashboard', 'runtime', 'logs', 'config', 'apps']);
|
||||||
|
|
||||||
export interface Scope {
|
export interface Scope {
|
||||||
tab: TabKey;
|
tab: TabKey;
|
||||||
|
|||||||
@@ -1,405 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
170
ui/src/pages/AppsTab/AppsTab.module.css
Normal file
170
ui/src/pages/AppsTab/AppsTab.module.css
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
.container {
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createForm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.createActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.envSelect {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellName {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellMeta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailTitle {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailMeta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 6px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaLabel {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowText {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowMeta {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorText {
|
||||||
|
color: var(--error);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyNote {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configForm {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configSection {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configTitle {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 160px 1fr;
|
||||||
|
gap: 8px 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configField {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configHint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.envVarEditor {
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
515
ui/src/pages/AppsTab/AppsTab.tsx
Normal file
515
ui/src/pages/AppsTab/AppsTab.tsx
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
import { useState, useMemo, useRef } from 'react';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
DataTable,
|
||||||
|
Input,
|
||||||
|
MonoText,
|
||||||
|
SectionHeader,
|
||||||
|
Spinner,
|
||||||
|
useToast,
|
||||||
|
} from '@cameleer/design-system';
|
||||||
|
import type { Column } from '@cameleer/design-system';
|
||||||
|
import { useEnvironmentStore } from '../../api/environment-store';
|
||||||
|
import { useEnvironments } from '../../api/queries/admin/environments';
|
||||||
|
import {
|
||||||
|
useAllApps,
|
||||||
|
useApps,
|
||||||
|
useCreateApp,
|
||||||
|
useDeleteApp,
|
||||||
|
useAppVersions,
|
||||||
|
useUploadJar,
|
||||||
|
useDeployments,
|
||||||
|
useCreateDeployment,
|
||||||
|
useStopDeployment,
|
||||||
|
useUpdateContainerConfig,
|
||||||
|
} from '../../api/queries/admin/apps';
|
||||||
|
import type { App, AppVersion, Deployment } from '../../api/queries/admin/apps';
|
||||||
|
import type { Environment } from '../../api/queries/admin/environments';
|
||||||
|
import styles from './AppsTab.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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(date: string): string {
|
||||||
|
const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, 'success' | 'warning' | 'error' | 'auto' | 'running'> = {
|
||||||
|
RUNNING: 'running',
|
||||||
|
STARTING: 'warning',
|
||||||
|
FAILED: 'error',
|
||||||
|
STOPPED: 'auto',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AppsTab() {
|
||||||
|
const { appId } = useParams<{ appId?: string }>();
|
||||||
|
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
||||||
|
const { data: environments = [] } = useEnvironments();
|
||||||
|
|
||||||
|
// If an app is selected via sidebar, show detail view
|
||||||
|
if (appId) {
|
||||||
|
return (
|
||||||
|
<AppDetailView
|
||||||
|
appId={appId}
|
||||||
|
environments={environments}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise show list view
|
||||||
|
return (
|
||||||
|
<AppListView
|
||||||
|
selectedEnv={selectedEnv}
|
||||||
|
environments={environments}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- List View ---
|
||||||
|
|
||||||
|
function AppListView({
|
||||||
|
selectedEnv,
|
||||||
|
environments,
|
||||||
|
}: {
|
||||||
|
selectedEnv: string | undefined;
|
||||||
|
environments: Environment[];
|
||||||
|
}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { data: allApps = [], isLoading: allLoading } = useAllApps();
|
||||||
|
const { data: envApps = [], isLoading: envLoading } = useApps(
|
||||||
|
selectedEnv ? environments.find((e) => e.slug === selectedEnv)?.id : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newSlug, setNewSlug] = useState('');
|
||||||
|
const [newDisplayName, setNewDisplayName] = useState('');
|
||||||
|
const [newEnvId, setNewEnvId] = useState('');
|
||||||
|
const createApp = useCreateApp();
|
||||||
|
|
||||||
|
const apps = selectedEnv ? envApps : allApps;
|
||||||
|
const isLoading = selectedEnv ? envLoading : allLoading;
|
||||||
|
|
||||||
|
// Build enriched rows with environment name and latest deployment status
|
||||||
|
const envMap = useMemo(() => {
|
||||||
|
const m = new Map<string, Environment>();
|
||||||
|
for (const e of environments) m.set(e.id, e);
|
||||||
|
return m;
|
||||||
|
}, [environments]);
|
||||||
|
|
||||||
|
type AppRow = App & { envName: string };
|
||||||
|
const rows: AppRow[] = useMemo(
|
||||||
|
() => apps.map((a) => ({
|
||||||
|
...a,
|
||||||
|
envName: envMap.get(a.environmentId)?.displayName ?? '?',
|
||||||
|
})),
|
||||||
|
[apps, envMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns: Column<AppRow>[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'displayName',
|
||||||
|
header: 'Name',
|
||||||
|
render: (_val: unknown, row: AppRow) => (
|
||||||
|
<div>
|
||||||
|
<div className={styles.cellName}>{row.displayName}</div>
|
||||||
|
<div className={styles.cellMeta}>{row.slug}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
...(!selectedEnv ? [{
|
||||||
|
key: 'envName',
|
||||||
|
header: 'Environment',
|
||||||
|
render: (_val: unknown, row: AppRow) => <Badge label={row.envName} color={'auto' as const} />,
|
||||||
|
sortable: true,
|
||||||
|
}] : []),
|
||||||
|
{
|
||||||
|
key: 'updatedAt',
|
||||||
|
header: 'Updated',
|
||||||
|
render: (_val: unknown, row: AppRow) => <span className={styles.cellMeta}>{timeAgo(row.updatedAt)}</span>,
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createdAt',
|
||||||
|
header: 'Created',
|
||||||
|
render: (_val: unknown, row: AppRow) => <span className={styles.cellMeta}>{new Date(row.createdAt).toLocaleDateString()}</span>,
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
], [selectedEnv]);
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!newSlug.trim() || !newDisplayName.trim() || !newEnvId) return;
|
||||||
|
try {
|
||||||
|
await createApp.mutateAsync({
|
||||||
|
environmentId: newEnvId,
|
||||||
|
slug: newSlug.trim(),
|
||||||
|
displayName: newDisplayName.trim(),
|
||||||
|
});
|
||||||
|
toast({ title: 'App created', description: newSlug.trim(), variant: 'success' });
|
||||||
|
setCreating(false);
|
||||||
|
setNewSlug('');
|
||||||
|
setNewDisplayName('');
|
||||||
|
setNewEnvId('');
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to create app', variant: 'error', duration: 86_400_000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) return <Spinner size="md" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<Button size="sm" variant="primary" onClick={() => {
|
||||||
|
if (!newEnvId && environments.length > 0) setNewEnvId(environments[0].id);
|
||||||
|
setCreating(!creating);
|
||||||
|
}}>
|
||||||
|
+ Create App
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<div className={styles.createForm}>
|
||||||
|
<select
|
||||||
|
className={styles.envSelect}
|
||||||
|
value={newEnvId}
|
||||||
|
onChange={(e) => setNewEnvId(e.target.value)}
|
||||||
|
>
|
||||||
|
{environments.map((env) => (
|
||||||
|
<option key={env.id} value={env.id}>{env.displayName} ({env.slug})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Input placeholder="Slug *" value={newSlug} onChange={(e) => setNewSlug(e.target.value)} />
|
||||||
|
<Input placeholder="Display name *" value={newDisplayName} onChange={(e) => setNewDisplayName(e.target.value)} />
|
||||||
|
<div className={styles.createActions}>
|
||||||
|
<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() || !newEnvId}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
onRowClick={(row) => {
|
||||||
|
window.location.href = `${window.location.pathname.replace(/\/apps.*/, '')}/apps/${row.id}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Detail View ---
|
||||||
|
|
||||||
|
function AppDetailView({
|
||||||
|
appId,
|
||||||
|
environments,
|
||||||
|
}: {
|
||||||
|
appId: string;
|
||||||
|
environments: Environment[];
|
||||||
|
}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { data: allApps = [] } = useAllApps();
|
||||||
|
const app = useMemo(() => allApps.find((a) => a.id === appId), [allApps, appId]);
|
||||||
|
const { data: versions = [] } = useAppVersions(appId);
|
||||||
|
const { data: deployments = [] } = useDeployments(appId);
|
||||||
|
const uploadJar = useUploadJar();
|
||||||
|
const createDeployment = useCreateDeployment();
|
||||||
|
const stopDeployment = useStopDeployment();
|
||||||
|
const deleteApp = useDeleteApp();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const envMap = useMemo(() => {
|
||||||
|
const m = new Map<string, Environment>();
|
||||||
|
for (const e of environments) m.set(e.id, e);
|
||||||
|
return m;
|
||||||
|
}, [environments]);
|
||||||
|
|
||||||
|
const sortedVersions = useMemo(
|
||||||
|
() => [...versions].sort((a, b) => b.version - a.version),
|
||||||
|
[versions],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!app) return <Spinner size="md" />;
|
||||||
|
|
||||||
|
const envName = envMap.get(app.environmentId)?.displayName ?? '?';
|
||||||
|
|
||||||
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const v = await uploadJar.mutateAsync({ appId, 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, environmentId: string) {
|
||||||
|
try {
|
||||||
|
await createDeployment.mutateAsync({ appId, appVersionId: versionId, 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, deploymentId });
|
||||||
|
toast({ title: 'Deployment stopped', variant: 'warning' });
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Stop failed', variant: 'error', duration: 86_400_000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
try {
|
||||||
|
await deleteApp.mutateAsync(appId);
|
||||||
|
toast({ title: 'App deleted', variant: 'warning' });
|
||||||
|
window.history.back();
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Delete failed', variant: 'error', duration: 86_400_000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.detailHeader}>
|
||||||
|
<div>
|
||||||
|
<h2 className={styles.detailTitle}>{app.displayName}</h2>
|
||||||
|
<div className={styles.detailMeta}>
|
||||||
|
{app.slug} · <Badge label={envName} color="auto" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="danger" onClick={handleDelete}>Delete</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.metaGrid}>
|
||||||
|
<span className={styles.metaLabel}>ID</span>
|
||||||
|
<MonoText size="xs">{app.id}</MonoText>
|
||||||
|
<span className={styles.metaLabel}>Environment</span>
|
||||||
|
<span>{envName}</span>
|
||||||
|
<span className={styles.metaLabel}>Created</span>
|
||||||
|
<span>{new Date(app.createdAt).toLocaleDateString()}</span>
|
||||||
|
<span className={styles.metaLabel}>Updated</span>
|
||||||
|
<span>{timeAgo(app.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deployments across all environments */}
|
||||||
|
<SectionHeader>Deployments ({deployments.length})</SectionHeader>
|
||||||
|
{deployments.length === 0 && <p className={styles.emptyNote}>No deployments yet.</p>}
|
||||||
|
{deployments.map((d) => (
|
||||||
|
<DeploymentRow
|
||||||
|
key={d.id}
|
||||||
|
deployment={d}
|
||||||
|
versions={versions}
|
||||||
|
envMap={envMap}
|
||||||
|
onStop={() => handleStop(d.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 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.emptyNote}>No versions uploaded yet.</p>}
|
||||||
|
{sortedVersions.map((v) => (
|
||||||
|
<VersionRow
|
||||||
|
key={v.id}
|
||||||
|
version={v}
|
||||||
|
environments={environments}
|
||||||
|
onDeploy={(envId) => handleDeploy(v.id, envId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Container Config */}
|
||||||
|
<SectionHeader>Container Configuration</SectionHeader>
|
||||||
|
<ContainerConfigForm app={app} environment={envMap.get(app.environmentId)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Deployment Row ---
|
||||||
|
|
||||||
|
function DeploymentRow({
|
||||||
|
deployment,
|
||||||
|
versions,
|
||||||
|
envMap,
|
||||||
|
onStop,
|
||||||
|
}: {
|
||||||
|
deployment: Deployment;
|
||||||
|
versions: AppVersion[];
|
||||||
|
envMap: Map<string, Environment>;
|
||||||
|
onStop: () => void;
|
||||||
|
}) {
|
||||||
|
const version = versions.find((v) => v.id === deployment.appVersionId);
|
||||||
|
const env = envMap.get(deployment.environmentId);
|
||||||
|
const canStop = deployment.status === 'RUNNING' || deployment.status === 'STARTING';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.row}>
|
||||||
|
<Badge label={deployment.status} color={STATUS_COLORS[deployment.status] ?? 'auto'} />
|
||||||
|
<Badge label={env?.displayName ?? '?'} color="auto" />
|
||||||
|
<span className={styles.rowText}>{version ? `v${version.version}` : '?'}</span>
|
||||||
|
{deployment.containerName && <MonoText size="xs">{deployment.containerName}</MonoText>}
|
||||||
|
{deployment.errorMessage && <span className={styles.errorText}>{deployment.errorMessage}</span>}
|
||||||
|
<span className={styles.rowMeta}>{deployment.deployedAt ? timeAgo(deployment.deployedAt) : ''}</span>
|
||||||
|
{canStop && <Button size="sm" variant="danger" onClick={onStop}>Stop</Button>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Version Row ---
|
||||||
|
|
||||||
|
function VersionRow({
|
||||||
|
version,
|
||||||
|
environments,
|
||||||
|
onDeploy,
|
||||||
|
}: {
|
||||||
|
version: AppVersion;
|
||||||
|
environments: Environment[];
|
||||||
|
onDeploy: (environmentId: string) => void;
|
||||||
|
}) {
|
||||||
|
const [deployEnv, setDeployEnv] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.row}>
|
||||||
|
<Badge label={`v${version.version}`} color="auto" />
|
||||||
|
<span className={styles.rowText}>
|
||||||
|
{version.jarFilename} ({formatBytes(version.jarSizeBytes)})
|
||||||
|
</span>
|
||||||
|
<span className={styles.rowMeta}>{timeAgo(version.uploadedAt)}</span>
|
||||||
|
<select
|
||||||
|
className={styles.envSelect}
|
||||||
|
value={deployEnv}
|
||||||
|
onChange={(e) => setDeployEnv(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Deploy to...</option>
|
||||||
|
{environments.filter((e) => e.enabled).map((e) => (
|
||||||
|
<option key={e.id} value={e.id}>{e.displayName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!deployEnv}
|
||||||
|
onClick={() => { onDeploy(deployEnv); setDeployEnv(''); }}
|
||||||
|
>
|
||||||
|
Deploy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Container Config Form ---
|
||||||
|
|
||||||
|
function ContainerConfigForm({ app, environment }: { app: App; environment?: Environment }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const updateConfig = useUpdateContainerConfig();
|
||||||
|
const isProd = environment?.production ?? false;
|
||||||
|
|
||||||
|
const defaults = environment?.defaultContainerConfig ?? {};
|
||||||
|
const merged = { ...defaults, ...app.containerConfig };
|
||||||
|
|
||||||
|
const [memoryLimitMb, setMemoryLimitMb] = useState<string>(String(merged.memoryLimitMb ?? '512'));
|
||||||
|
const [memoryReserveMb, setMemoryReserveMb] = useState<string>(String(merged.memoryReserveMb ?? ''));
|
||||||
|
const [cpuShares, setCpuShares] = useState<string>(String(merged.cpuShares ?? '512'));
|
||||||
|
const [cpuLimit, setCpuLimit] = useState<string>(String(merged.cpuLimit ?? ''));
|
||||||
|
const [exposedPorts, setExposedPorts] = useState<string>(
|
||||||
|
Array.isArray(merged.exposedPorts) ? (merged.exposedPorts as number[]).join(', ') : '',
|
||||||
|
);
|
||||||
|
const [customEnvVars, setCustomEnvVars] = useState<string>(
|
||||||
|
merged.customEnvVars ? JSON.stringify(merged.customEnvVars, null, 2) : '{}',
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
memoryLimitMb: memoryLimitMb ? parseInt(memoryLimitMb) : null,
|
||||||
|
memoryReserveMb: memoryReserveMb ? parseInt(memoryReserveMb) : null,
|
||||||
|
cpuShares: cpuShares ? parseInt(cpuShares) : null,
|
||||||
|
cpuLimit: cpuLimit ? parseFloat(cpuLimit) : null,
|
||||||
|
exposedPorts: exposedPorts ? exposedPorts.split(',').map((p) => parseInt(p.trim())).filter((p) => !isNaN(p)) : [],
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
config.customEnvVars = JSON.parse(customEnvVars);
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Invalid JSON in environment variables', variant: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateConfig.mutateAsync({ appId: app.id, config });
|
||||||
|
toast({ title: 'Container config saved', variant: 'success' });
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to save config', variant: 'error', duration: 86_400_000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.configForm}>
|
||||||
|
<div className={styles.configSection}>
|
||||||
|
<h4 className={styles.configTitle}>Resources</h4>
|
||||||
|
<div className={styles.configGrid}>
|
||||||
|
<label className={styles.configLabel}>Memory Limit (MB)</label>
|
||||||
|
<Input value={memoryLimitMb} onChange={(e) => setMemoryLimitMb(e.target.value)} placeholder="512" />
|
||||||
|
<label className={styles.configLabel}>Memory Reserve (MB)</label>
|
||||||
|
<div className={styles.configField}>
|
||||||
|
<Input
|
||||||
|
value={memoryReserveMb}
|
||||||
|
onChange={(e) => setMemoryReserveMb(e.target.value)}
|
||||||
|
placeholder="—"
|
||||||
|
disabled={!isProd}
|
||||||
|
/>
|
||||||
|
{!isProd && <span className={styles.configHint}>Available in production environments only</span>}
|
||||||
|
</div>
|
||||||
|
<label className={styles.configLabel}>CPU Shares</label>
|
||||||
|
<Input value={cpuShares} onChange={(e) => setCpuShares(e.target.value)} placeholder="512" />
|
||||||
|
<label className={styles.configLabel}>CPU Limit (cores)</label>
|
||||||
|
<Input value={cpuLimit} onChange={(e) => setCpuLimit(e.target.value)} placeholder="e.g. 1.0" />
|
||||||
|
<label className={styles.configLabel}>Exposed Ports</label>
|
||||||
|
<Input value={exposedPorts} onChange={(e) => setExposedPorts(e.target.value)} placeholder="e.g. 8080, 9090" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.configSection}>
|
||||||
|
<h4 className={styles.configTitle}>Custom Environment Variables</h4>
|
||||||
|
<textarea
|
||||||
|
className={styles.envVarEditor}
|
||||||
|
value={customEnvVars}
|
||||||
|
onChange={(e) => setCustomEnvVars(e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="primary" onClick={handleSave} loading={updateConfig.isPending}>
|
||||||
|
Save Configuration
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,8 +18,8 @@ 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 AppsTab = lazy(() => import('./pages/AppsTab/AppsTab'));
|
||||||
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'));
|
||||||
|
|
||||||
@@ -70,6 +70,10 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'config', element: <SuspenseWrapper><AppConfigPage /></SuspenseWrapper> },
|
{ path: 'config', element: <SuspenseWrapper><AppConfigPage /></SuspenseWrapper> },
|
||||||
{ path: 'config/:appId', element: <SuspenseWrapper><AppConfigPage /></SuspenseWrapper> },
|
{ path: 'config/:appId', element: <SuspenseWrapper><AppConfigPage /></SuspenseWrapper> },
|
||||||
|
|
||||||
|
// Apps tab (OPERATOR+ via UI guard, shows all or single app)
|
||||||
|
{ path: 'apps', element: <SuspenseWrapper><AppsTab /></SuspenseWrapper> },
|
||||||
|
{ path: 'apps/:appId', element: <SuspenseWrapper><AppsTab /></SuspenseWrapper> },
|
||||||
|
|
||||||
// Admin (ADMIN role required)
|
// Admin (ADMIN role required)
|
||||||
{
|
{
|
||||||
element: <RequireAdmin />,
|
element: <RequireAdmin />,
|
||||||
@@ -84,7 +88,6 @@ 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