diff --git a/ui/src/api/queries/admin/environments.ts b/ui/src/api/queries/admin/environments.ts new file mode 100644 index 00000000..7e198aa1 --- /dev/null +++ b/ui/src/api/queries/admin/environments.ts @@ -0,0 +1,63 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { adminFetch } from './admin-api'; + +export interface Environment { + id: string; + slug: string; + displayName: string; + production: boolean; + enabled: boolean; + createdAt: string; +} + +export interface CreateEnvironmentRequest { + slug: string; + displayName: string; + production: boolean; +} + +export interface UpdateEnvironmentRequest { + displayName: string; + production: boolean; + enabled: boolean; +} + +export function useEnvironments() { + return useQuery({ + queryKey: ['admin', 'environments'], + queryFn: () => adminFetch('/environments'), + }); +} + +export function useCreateEnvironment() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (req: CreateEnvironmentRequest) => + adminFetch('/environments', { + method: 'POST', + body: JSON.stringify(req), + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'environments'] }), + }); +} + +export function useUpdateEnvironment() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...req }: UpdateEnvironmentRequest & { id: string }) => + adminFetch(`/environments/${id}`, { + method: 'PUT', + body: JSON.stringify(req), + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'environments'] }), + }); +} + +export function useDeleteEnvironment() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + adminFetch(`/environments/${id}`, { method: 'DELETE' }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'environments'] }), + }); +} diff --git a/ui/src/components/sidebar-utils.ts b/ui/src/components/sidebar-utils.ts index d490f6ec..e2dda212 100644 --- a/ui/src/components/sidebar-utils.ts +++ b/ui/src/components/sidebar-utils.ts @@ -98,6 +98,7 @@ export function buildAppTreeNodes( */ export function buildAdminTreeNodes(): SidebarTreeNode[] { return [ + { id: 'admin:environments', label: 'Environments', path: '/admin/environments' }, { id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' }, { id: 'admin:audit', label: 'Audit Log', path: '/admin/audit' }, { id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' }, diff --git a/ui/src/pages/Admin/EnvironmentsPage.tsx b/ui/src/pages/Admin/EnvironmentsPage.tsx new file mode 100644 index 00000000..58db7e9a --- /dev/null +++ b/ui/src/pages/Admin/EnvironmentsPage.tsx @@ -0,0 +1,299 @@ +import { useState, useMemo } from 'react'; +import { + Avatar, + Badge, + Button, + Input, + MonoText, + SectionHeader, + Tag, + Toggle, + InlineEdit, + ConfirmDialog, + SplitPane, + EntityList, + Spinner, + useToast, +} from '@cameleer/design-system'; +import { + useEnvironments, + useCreateEnvironment, + useUpdateEnvironment, + useDeleteEnvironment, +} from '../../api/queries/admin/environments'; +import type { Environment } from '../../api/queries/admin/environments'; +import styles from './UserManagement.module.css'; + +export default function EnvironmentsPage() { + const { toast } = useToast(); + const { data: environments = [], isLoading } = useEnvironments(); + + const [search, setSearch] = useState(''); + const [selectedId, setSelectedId] = useState(null); + const [creating, setCreating] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + // Create form state + const [newSlug, setNewSlug] = useState(''); + const [newDisplayName, setNewDisplayName] = useState(''); + const [newProduction, setNewProduction] = useState(false); + + // Mutations + const createEnv = useCreateEnvironment(); + const updateEnv = useUpdateEnvironment(); + const deleteEnv = useDeleteEnvironment(); + + const selected = useMemo( + () => environments.find((e) => e.id === selectedId) ?? null, + [environments, selectedId], + ); + + const filtered = useMemo(() => { + if (!search) return environments; + const q = search.toLowerCase(); + return environments.filter( + (e) => + e.slug.toLowerCase().includes(q) || + e.displayName.toLowerCase().includes(q), + ); + }, [environments, search]); + + const isDefault = selected?.slug === 'default'; + + const duplicateSlug = + newSlug.trim() !== '' && + environments.some((e) => e.slug.toLowerCase() === newSlug.trim().toLowerCase()); + + async function handleCreate() { + if (!newSlug.trim() || !newDisplayName.trim()) return; + try { + await createEnv.mutateAsync({ + slug: newSlug.trim(), + displayName: newDisplayName.trim(), + production: newProduction, + }); + toast({ title: 'Environment created', description: newSlug.trim(), variant: 'success' }); + setCreating(false); + setNewSlug(''); + setNewDisplayName(''); + setNewProduction(false); + } catch { + toast({ title: 'Failed to create environment', variant: 'error', duration: 86_400_000 }); + } + } + + async function handleDelete() { + if (!deleteTarget) return; + try { + await deleteEnv.mutateAsync(deleteTarget.id); + toast({ title: 'Environment deleted', description: deleteTarget.slug, variant: 'warning' }); + if (selectedId === deleteTarget.id) setSelectedId(null); + setDeleteTarget(null); + } catch { + toast({ title: 'Failed to delete environment', variant: 'error', duration: 86_400_000 }); + setDeleteTarget(null); + } + } + + async function handleRename(newName: string) { + if (!selected) return; + try { + await updateEnv.mutateAsync({ + id: selected.id, + displayName: newName, + production: selected.production, + enabled: selected.enabled, + }); + toast({ title: 'Environment renamed', variant: 'success' }); + } catch { + toast({ title: 'Failed to rename', variant: 'error', duration: 86_400_000 }); + } + } + + async function handleToggleProduction(value: boolean) { + if (!selected) return; + try { + await updateEnv.mutateAsync({ + id: selected.id, + displayName: selected.displayName, + production: value, + enabled: selected.enabled, + }); + toast({ title: value ? 'Marked as production' : 'Marked as non-production', variant: 'success' }); + } catch { + toast({ title: 'Failed to update', variant: 'error', duration: 86_400_000 }); + } + } + + async function handleToggleEnabled(value: boolean) { + if (!selected) return; + try { + await updateEnv.mutateAsync({ + id: selected.id, + displayName: selected.displayName, + production: selected.production, + enabled: value, + }); + toast({ title: value ? 'Environment enabled' : 'Environment disabled', variant: 'success' }); + } catch { + toast({ title: 'Failed to update', variant: 'error', duration: 86_400_000 }); + } + } + + if (isLoading) return ; + + return ( + <> + + {creating && ( +
+ setNewSlug(e.target.value)} + /> + {duplicateSlug && ( + Slug already exists + )} + setNewDisplayName(e.target.value)} + /> + +
+ + +
+
+ )} + + ( + <> + +
+
{env.displayName}
+
+ {env.slug} +
+
+ {env.production && } + {!env.production && } + {!env.enabled && } +
+
+ + )} + getItemId={(env) => env.id} + selectedId={selectedId ?? undefined} + onSelect={setSelectedId} + searchPlaceholder="Search environments..." + onSearch={setSearch} + addLabel="+ Add environment" + onAdd={() => setCreating(true)} + emptyMessage="No environments match your search" + /> + + } + detail={ + selected ? ( + <> +
+ +
+
+ {isDefault ? ( + selected.displayName + ) : ( + + )} +
+
+ {selected.slug} + {isDefault && ' (built-in)'} +
+
+ +
+ +
+ ID + {selected.id} + Slug + {selected.slug} + Created + + {new Date(selected.createdAt).toLocaleDateString()} + +
+ + Configuration +
+
+ handleToggleProduction(!selected.production)} /> + Production environment + {selected.production ? ( + + ) : ( + + )} +
+
+ + Status +
+
+ handleToggleEnabled(!selected.enabled)} /> + {selected.enabled ? 'Enabled' : 'Disabled'} + {!selected.enabled && ( + + )} +
+ {!selected.enabled && ( +

+ Disabled environments do not allow new deployments. Active + deployments can only be started, stopped, or deleted. +

+ )} +
+ + ) : null + } + emptyMessage="Select an environment to view details" + /> + + setDeleteTarget(null)} + onConfirm={handleDelete} + message={`Delete environment "${deleteTarget?.displayName}"? All apps and deployments in this environment will be removed. This cannot be undone.`} + confirmText={deleteTarget?.slug ?? ''} + loading={deleteEnv.isPending} + /> + + ); +} diff --git a/ui/src/router.tsx b/ui/src/router.tsx index fc09fb37..7cceaeb9 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -17,6 +17,7 @@ const AuditLogPage = lazy(() => import('./pages/Admin/AuditLogPage')); const OidcConfigPage = lazy(() => import('./pages/Admin/OidcConfigPage')); const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage')); const ClickHouseAdminPage = lazy(() => import('./pages/Admin/ClickHouseAdminPage')); +const EnvironmentsPage = lazy(() => import('./pages/Admin/EnvironmentsPage')); const AppConfigPage = lazy(() => import('./pages/Admin/AppConfigPage')); const LogsPage = lazy(() => import('./pages/LogsTab/LogsPage')); const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage')); @@ -81,6 +82,7 @@ export const router = createBrowserRouter([ { path: 'oidc', element: }, { path: 'database', element: }, { path: 'clickhouse', element: }, + { path: 'environments', element: }, ], }], },