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