feat: add environments list and environment detail pages

Implements EnvironmentsPage with DataTable, create modal, and row navigation,
and EnvironmentDetailPage with app list, inline rename, new app form with JAR
upload, and delete confirmation — all gated by RBAC permissions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-04 22:00:14 +02:00
parent 02019e9347
commit 5eac48ad72
2 changed files with 509 additions and 4 deletions

View File

@@ -1,3 +1,318 @@
export function EnvironmentDetailPage() {
return <div>Environment Detail</div>;
import React, { useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Badge,
Button,
Card,
ConfirmDialog,
DataTable,
EmptyState,
FormField,
InlineEdit,
Input,
Modal,
Spinner,
useToast,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useAuthStore } from '../auth/auth-store';
import {
useEnvironments,
useUpdateEnvironment,
useDeleteEnvironment,
useApps,
useCreateApp,
} from '../api/hooks';
import { RequirePermission } from '../components/RequirePermission';
import { DeploymentStatusBadge } from '../components/DeploymentStatusBadge';
import type { AppResponse } from '../types/api';
interface AppTableRow {
id: string;
displayName: string;
slug: string;
deploymentStatus: string;
updatedAt: string;
_raw: AppResponse;
}
const appColumns: Column<AppTableRow>[] = [
{
key: 'displayName',
header: 'Name',
render: (_val, row) => (
<span className="font-medium text-white">{row.displayName}</span>
),
},
{
key: 'slug',
header: 'Slug',
render: (_val, row) => (
<Badge label={row.slug} color="primary" variant="outlined" />
),
},
{
key: 'deploymentStatus',
header: 'Status',
render: (_val, row) =>
row._raw.currentDeploymentId ? (
<DeploymentStatusBadge status={row.deploymentStatus} />
) : (
<Badge label="Not deployed" color="auto" />
),
},
{
key: 'updatedAt',
header: 'Last Updated',
render: (_val, row) =>
new Date(row.updatedAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
}),
},
];
export function EnvironmentDetailPage() {
const navigate = useNavigate();
const { envId } = useParams<{ envId: string }>();
const { toast } = useToast();
const tenantId = useAuthStore((s) => s.tenantId);
const { data: environments, isLoading: envsLoading } = useEnvironments(tenantId ?? '');
const environment = environments?.find((e) => e.id === envId);
const { data: apps, isLoading: appsLoading } = useApps(envId ?? '');
const updateMutation = useUpdateEnvironment(tenantId ?? '', envId ?? '');
const deleteMutation = useDeleteEnvironment(tenantId ?? '', envId ?? '');
const createAppMutation = useCreateApp(envId ?? '');
// New app modal
const [newAppOpen, setNewAppOpen] = useState(false);
const [appSlug, setAppSlug] = useState('');
const [appDisplayName, setAppDisplayName] = useState('');
const [jarFile, setJarFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Delete confirm
const [deleteOpen, setDeleteOpen] = useState(false);
function openNewApp() {
setAppSlug('');
setAppDisplayName('');
setJarFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
setNewAppOpen(true);
}
function closeNewApp() {
setNewAppOpen(false);
}
async function handleCreateApp(e: React.FormEvent) {
e.preventDefault();
if (!appSlug.trim() || !appDisplayName.trim()) return;
const formData = new FormData();
formData.append('slug', appSlug.trim());
formData.append('displayName', appDisplayName.trim());
if (jarFile) {
formData.append('jar', jarFile);
}
try {
await createAppMutation.mutateAsync(formData);
toast({ title: 'App created', variant: 'success' });
closeNewApp();
} catch {
toast({ title: 'Failed to create app', variant: 'error' });
}
}
async function handleDeleteEnvironment() {
try {
await deleteMutation.mutateAsync();
toast({ title: 'Environment deleted', variant: 'success' });
navigate('/environments');
} catch {
toast({ title: 'Failed to delete environment', variant: 'error' });
}
}
async function handleRename(value: string) {
if (!value.trim() || value === environment?.displayName) return;
try {
await updateMutation.mutateAsync({ displayName: value.trim() });
toast({ title: 'Environment renamed', variant: 'success' });
} catch {
toast({ title: 'Failed to rename environment', variant: 'error' });
}
}
const isLoading = envsLoading || appsLoading;
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Spinner />
</div>
);
}
if (!environment) {
return (
<EmptyState
title="Environment not found"
description="The requested environment does not exist or you do not have access."
action={
<Button variant="secondary" onClick={() => navigate('/environments')}>
Back to Environments
</Button>
}
/>
);
}
const tableData: AppTableRow[] = (apps ?? []).map((app) => ({
id: app.id,
displayName: app.displayName,
slug: app.slug,
deploymentStatus: app.currentDeploymentId ? 'RUNNING' : 'STOPPED',
updatedAt: app.updatedAt,
_raw: app,
}));
const hasApps = (apps?.length ?? 0) > 0;
return (
<div className="space-y-6 p-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RequirePermission
permission="apps:manage"
fallback={
<h1 className="text-2xl font-semibold text-white">
{environment.displayName}
</h1>
}
>
<InlineEdit
value={environment.displayName}
onSave={handleRename}
placeholder="Environment name"
/>
</RequirePermission>
<Badge label={environment.slug} color="primary" variant="outlined" />
<Badge
label={environment.status}
color={environment.status === 'ACTIVE' ? 'success' : 'warning'}
/>
</div>
<div className="flex items-center gap-2">
<RequirePermission permission="apps:deploy">
<Button variant="primary" size="sm" onClick={openNewApp}>
New App
</Button>
</RequirePermission>
<RequirePermission permission="apps:manage">
<Button
variant="danger"
size="sm"
onClick={() => setDeleteOpen(true)}
disabled={hasApps}
title={hasApps ? 'Remove all apps before deleting this environment' : undefined}
>
Delete Environment
</Button>
</RequirePermission>
</div>
</div>
{/* Apps table */}
{tableData.length === 0 ? (
<EmptyState
title="No apps yet"
description="Deploy your first Camel application to this environment."
action={
<RequirePermission permission="apps:deploy">
<Button variant="primary" onClick={openNewApp}>
New App
</Button>
</RequirePermission>
}
/>
) : (
<Card title="Apps">
<DataTable<AppTableRow>
columns={appColumns}
data={tableData}
onRowClick={(row) => navigate(`/environments/${envId}/apps/${row.id}`)}
flush
/>
</Card>
)}
{/* New App Modal */}
<Modal open={newAppOpen} onClose={closeNewApp} title="New App" size="sm">
<form onSubmit={handleCreateApp} className="space-y-4">
<FormField label="Slug" htmlFor="app-slug" required>
<Input
id="app-slug"
value={appSlug}
onChange={(e) => setAppSlug(e.target.value)}
placeholder="e.g. order-router"
required
/>
</FormField>
<FormField label="Display Name" htmlFor="app-display-name" required>
<Input
id="app-display-name"
value={appDisplayName}
onChange={(e) => setAppDisplayName(e.target.value)}
placeholder="e.g. Order Router"
required
/>
</FormField>
<FormField label="JAR File" htmlFor="app-jar">
<input
ref={fileInputRef}
id="app-jar"
type="file"
accept=".jar"
className="block w-full text-sm text-white/70 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-white/10 file:text-white hover:file:bg-white/20 cursor-pointer"
onChange={(e) => setJarFile(e.target.files?.[0] ?? null)}
/>
</FormField>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="secondary" size="sm" onClick={closeNewApp}>
Cancel
</Button>
<Button
type="submit"
variant="primary"
size="sm"
loading={createAppMutation.isPending}
disabled={!appSlug.trim() || !appDisplayName.trim()}
>
Create App
</Button>
</div>
</form>
</Modal>
{/* Delete Confirmation */}
<ConfirmDialog
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={handleDeleteEnvironment}
title="Delete Environment"
message={`Are you sure you want to delete "${environment.displayName}"? This action cannot be undone.`}
confirmText="Delete"
confirmLabel="Delete"
cancelLabel="Cancel"
variant="danger"
loading={deleteMutation.isPending}
/>
</div>
);
}

View File

@@ -1,3 +1,193 @@
export function EnvironmentsPage() {
return <div>Environments</div>;
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Badge,
Button,
Card,
DataTable,
EmptyState,
FormField,
Input,
Modal,
Spinner,
useToast,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useAuthStore } from '../auth/auth-store';
import { useEnvironments, useCreateEnvironment } from '../api/hooks';
import { RequirePermission } from '../components/RequirePermission';
import type { EnvironmentResponse } from '../types/api';
interface TableRow {
id: string;
displayName: string;
slug: string;
status: string;
createdAt: string;
_raw: EnvironmentResponse;
}
const columns: Column<TableRow>[] = [
{
key: 'displayName',
header: 'Name',
render: (_val, row) => (
<span className="font-medium text-white">{row.displayName}</span>
),
},
{
key: 'slug',
header: 'Slug',
render: (_val, row) => (
<Badge label={row.slug} color="primary" variant="outlined" />
),
},
{
key: 'status',
header: 'Status',
render: (_val, row) => (
<Badge
label={row.status}
color={row.status === 'ACTIVE' ? 'success' : 'warning'}
/>
),
},
{
key: 'createdAt',
header: 'Created',
render: (_val, row) =>
new Date(row.createdAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
}),
},
];
export function EnvironmentsPage() {
const navigate = useNavigate();
const { toast } = useToast();
const tenantId = useAuthStore((s) => s.tenantId);
const { data: environments, isLoading } = useEnvironments(tenantId ?? '');
const createMutation = useCreateEnvironment(tenantId ?? '');
const [modalOpen, setModalOpen] = useState(false);
const [slug, setSlug] = useState('');
const [displayName, setDisplayName] = useState('');
const tableData: TableRow[] = (environments ?? []).map((env) => ({
id: env.id,
displayName: env.displayName,
slug: env.slug,
status: env.status,
createdAt: env.createdAt,
_raw: env,
}));
function openModal() {
setSlug('');
setDisplayName('');
setModalOpen(true);
}
function closeModal() {
setModalOpen(false);
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!slug.trim() || !displayName.trim()) return;
try {
await createMutation.mutateAsync({ slug: slug.trim(), displayName: displayName.trim() });
toast({ title: 'Environment created', variant: 'success' });
closeModal();
} catch {
toast({ title: 'Failed to create environment', variant: 'error' });
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Spinner />
</div>
);
}
return (
<div className="space-y-6 p-6">
{/* Page header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-white">Environments</h1>
<RequirePermission permission="apps:manage">
<Button variant="primary" size="sm" onClick={openModal}>
Create Environment
</Button>
</RequirePermission>
</div>
{/* Table / empty state */}
{tableData.length === 0 ? (
<EmptyState
title="No environments yet"
description="Create your first environment to start deploying Camel applications."
action={
<RequirePermission permission="apps:manage">
<Button variant="primary" onClick={openModal}>
Create Environment
</Button>
</RequirePermission>
}
/>
) : (
<Card>
<DataTable<TableRow>
columns={columns}
data={tableData}
onRowClick={(row) => navigate(`/environments/${row.id}`)}
flush
/>
</Card>
)}
{/* Create Environment Modal */}
<Modal open={modalOpen} onClose={closeModal} title="Create Environment" size="sm">
<form onSubmit={handleCreate} className="space-y-4">
<FormField label="Slug" htmlFor="env-slug" required>
<Input
id="env-slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="e.g. production"
required
/>
</FormField>
<FormField label="Display Name" htmlFor="env-display-name" required>
<Input
id="env-display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="e.g. Production"
required
/>
</FormField>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="secondary" size="sm" onClick={closeModal}>
Cancel
</Button>
<Button
type="submit"
variant="primary"
size="sm"
loading={createMutation.isPending}
disabled={!slug.trim() || !displayName.trim()}
>
Create
</Button>
</div>
</form>
</Modal>
</div>
);
}