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 */} + +
+ + setAppSlug(e.target.value)} + placeholder="e.g. order-router" + required + /> + + + setAppDisplayName(e.target.value)} + placeholder="e.g. Order Router" + required + /> + + + setJarFile(e.target.files?.[0] ?? null)} + /> + +
+ + +
+
+
+ + {/* 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 */} + +
+ + setSlug(e.target.value)} + placeholder="e.g. production" + required + /> + + + setDisplayName(e.target.value)} + placeholder="e.g. Production" + required + /> + +
+ + +
+
+
+
+ ); }