diff --git a/ui/src/pages/EnvironmentDetailPage.tsx b/ui/src/pages/EnvironmentDetailPage.tsx
index ac61aff..49cffdc 100644
--- a/ui/src/pages/EnvironmentDetailPage.tsx
+++ b/ui/src/pages/EnvironmentDetailPage.tsx
@@ -1,3 +1,318 @@
-export function EnvironmentDetailPage() {
- return
Environment Detail
;
+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[] = [
+ {
+ key: 'displayName',
+ header: 'Name',
+ render: (_val, row) => (
+ {row.displayName}
+ ),
+ },
+ {
+ key: 'slug',
+ header: 'Slug',
+ render: (_val, row) => (
+
+ ),
+ },
+ {
+ key: 'deploymentStatus',
+ header: 'Status',
+ render: (_val, row) =>
+ row._raw.currentDeploymentId ? (
+
+ ) : (
+
+ ),
+ },
+ {
+ 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(null);
+ const fileInputRef = useRef(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 (
+
+
+
+ );
+ }
+
+ if (!environment) {
+ return (
+ navigate('/environments')}>
+ Back to Environments
+
+ }
+ />
+ );
+ }
+
+ 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 (
+
+ {/* Header */}
+
+
+
+ {environment.displayName}
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Apps table */}
+ {tableData.length === 0 ? (
+
+
+
+ }
+ />
+ ) : (
+
+
+ columns={appColumns}
+ data={tableData}
+ onRowClick={(row) => navigate(`/environments/${envId}/apps/${row.id}`)}
+ flush
+ />
+
+ )}
+
+ {/* New App Modal */}
+
+
+
+
+ {/* Delete Confirmation */}
+ 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}
+ />
+
+ );
}
diff --git a/ui/src/pages/EnvironmentsPage.tsx b/ui/src/pages/EnvironmentsPage.tsx
index 8e19427..183b7fa 100644
--- a/ui/src/pages/EnvironmentsPage.tsx
+++ b/ui/src/pages/EnvironmentsPage.tsx
@@ -1,3 +1,193 @@
-export function EnvironmentsPage() {
- return Environments
;
+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[] = [
+ {
+ key: 'displayName',
+ header: 'Name',
+ render: (_val, row) => (
+ {row.displayName}
+ ),
+ },
+ {
+ key: 'slug',
+ header: 'Slug',
+ render: (_val, row) => (
+
+ ),
+ },
+ {
+ key: 'status',
+ header: 'Status',
+ render: (_val, row) => (
+
+ ),
+ },
+ {
+ 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 (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Page header */}
+
+
Environments
+
+
+
+
+
+ {/* Table / empty state */}
+ {tableData.length === 0 ? (
+
+
+
+ }
+ />
+ ) : (
+
+
+ columns={columns}
+ data={tableData}
+ onRowClick={(row) => navigate(`/environments/${row.id}`)}
+ flush
+ />
+
+ )}
+
+ {/* Create Environment Modal */}
+
+
+
+
+ );
}