feat: add Environment admin UI page
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:
63
ui/src/api/queries/admin/environments.ts
Normal file
63
ui/src/api/queries/admin/environments.ts
Normal 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'] }),
|
||||
});
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
299
ui/src/pages/Admin/EnvironmentsPage.tsx
Normal file
299
ui/src/pages/Admin/EnvironmentsPage.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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> },
|
||||
],
|
||||
}],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user