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:
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user