feat: add Environment admin UI page
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m19s
CI / docker (push) Successful in 1m6s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s

SplitPane with create/edit/delete, production flag toggle,
enabled/disabled toggle. Follows existing admin page patterns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-08 11:19:05 +02:00
parent 2e006051bc
commit 9af0043915
4 changed files with 365 additions and 0 deletions

View File

@@ -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<Environment[]>('/environments'),
});
}
export function useCreateEnvironment() {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: CreateEnvironmentRequest) =>
adminFetch<Environment>('/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<Environment>(`/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<void>(`/environments/${id}`, { method: 'DELETE' }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'environments'] }),
});
}

View File

@@ -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' },

View File

@@ -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<string | null>(null);
const [creating, setCreating] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Environment | null>(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 <Spinner size="md" />;
return (
<>
<SplitPane
list={
<>
{creating && (
<div className={styles.createForm}>
<Input
placeholder="Slug (e.g. staging) *"
value={newSlug}
onChange={(e) => setNewSlug(e.target.value)}
/>
{duplicateSlug && (
<span className={styles.errorText}>Slug already exists</span>
)}
<Input
placeholder="Display name *"
value={newDisplayName}
onChange={(e) => setNewDisplayName(e.target.value)}
/>
<label className={styles.securityRow}>
<Toggle checked={newProduction} onChange={() => setNewProduction(!newProduction)} />
Production environment
</label>
<div className={styles.createFormActions}>
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>
Cancel
</Button>
<Button
size="sm"
variant="primary"
onClick={handleCreate}
loading={createEnv.isPending}
disabled={!newSlug.trim() || !newDisplayName.trim() || duplicateSlug}
>
Create
</Button>
</div>
</div>
)}
<EntityList
items={filtered}
renderItem={(env) => (
<>
<Avatar name={env.displayName} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>{env.displayName}</div>
<div className={styles.entityMeta}>
{env.slug}
</div>
<div className={styles.entityTags}>
{env.production && <Badge label="PROD" color="error" />}
{!env.production && <Badge label="NON-PROD" color="auto" />}
{!env.enabled && <Badge label="DISABLED" color="warning" />}
</div>
</div>
</>
)}
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 ? (
<>
<div className={styles.detailHeader}>
<Avatar name={selected.displayName} size="lg" />
<div className={styles.detailHeaderInfo}>
<div className={styles.detailName}>
{isDefault ? (
selected.displayName
) : (
<InlineEdit value={selected.displayName} onSave={handleRename} />
)}
</div>
<div className={styles.detailEmail}>
{selected.slug}
{isDefault && ' (built-in)'}
</div>
</div>
<Button
size="sm"
variant="danger"
onClick={() => setDeleteTarget(selected)}
disabled={isDefault}
>
Delete
</Button>
</div>
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>ID</span>
<MonoText size="xs">{selected.id}</MonoText>
<span className={styles.metaLabel}>Slug</span>
<span className={styles.metaValue}>{selected.slug}</span>
<span className={styles.metaLabel}>Created</span>
<span className={styles.metaValue}>
{new Date(selected.createdAt).toLocaleDateString()}
</span>
</div>
<SectionHeader>Configuration</SectionHeader>
<div className={styles.securitySection}>
<div className={styles.securityRow}>
<Toggle checked={selected.production} onChange={() => handleToggleProduction(!selected.production)} />
<span>Production environment</span>
{selected.production ? (
<Tag label="Dedicated resources" color="error" />
) : (
<Tag label="Shared resources" color="auto" />
)}
</div>
</div>
<SectionHeader>Status</SectionHeader>
<div className={styles.securitySection}>
<div className={styles.securityRow}>
<Toggle checked={selected.enabled} onChange={() => handleToggleEnabled(!selected.enabled)} />
<span>{selected.enabled ? 'Enabled' : 'Disabled'}</span>
{!selected.enabled && (
<Tag label="No new deployments" color="warning" />
)}
</div>
{!selected.enabled && (
<p className={styles.inheritedNote}>
Disabled environments do not allow new deployments. Active
deployments can only be started, stopped, or deleted.
</p>
)}
</div>
</>
) : null
}
emptyMessage="Select an environment to view details"
/>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => 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}
/>
</>
);
}

View File

@@ -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: <SuspenseWrapper><OidcConfigPage /></SuspenseWrapper> },
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
{ path: 'clickhouse', element: <SuspenseWrapper><ClickHouseAdminPage /></SuspenseWrapper> },
{ path: 'environments', element: <SuspenseWrapper><EnvironmentsPage /></SuspenseWrapper> },
],
}],
},