Files
sencho/frontend/src/components/RegistriesSection.tsx
T
Anso 7637091e84 feat(ui): glassmorphism redesign with settings decomposition (#274)
* feat(ui): add glassmorphism design tokens and utility classes

Introduce glass design system foundation: translucent oklch color
variables for both light and dark themes, glass/glass-border/glass-highlight
tokens, semantic status colors (success/warning/info), .glass and
.glass-strong utility classes with backdrop-filter, reduced shadow
values, and standardized spring animation presets in lib/motion.ts.

* feat(ui): apply glass treatment to core components

Update card, dialog, input, button, popover, sheet, tooltip,
dropdown-menu, context-menu, select, alert-dialog, and tabs
components with glassmorphism styling: translucent backgrounds via
new CSS variables, backdrop-blur layers, glass-border luminous
edges, and glass-highlight hover states.

* refactor(settings): decompose Settings Modal into section components

Extract 10 inline sections from the 1,987-line SettingsModal into
dedicated files under components/settings/. Introduce section
registry pattern replacing 14 conditional blocks. Add shared types,
sidebar navigation grouping with separators, glass treatment on
sidebar and nav buttons, and responsive modal height. SettingsModal
shell shrinks to ~380 lines.

* refactor(ui): unify all tabs to animate-ui TabsHighlight with glass styling

Migrate 4 tab instances (EditorLayout, FleetView, ResourcesView,
NotificationsSection) from inconsistent patterns (manual layoutId,
underline border-b-2, default fade) to the shared TabsHighlight
primitive with glass-highlight indicator and springs.snappy
transition. Standardize EditorLayout nav highlight spring config,
apply glass-highlight to sidebar stack list hover/active states,
and update mobile nav styling.

* refactor(ui): migrate hardcoded colors to semantic CSS variables

Replace hardcoded Tailwind color classes across ~19 component files
with semantic CSS variable classes: emerald/green to success,
orange/amber to warning, blue to info. Preserves brand/decorative
colors (Crown amber, Admiral blue). Enables consistent theming of
status indicators across the entire application.

* refactor(ui): Linear dark precision aesthetic — solid surfaces, depth cues, text hierarchy

Replace glassmorphism with Linear.app-inspired design: solid surface tokens
(card #111111, sidebar #0d0d0d, root #0a0a0a), backdrop-blur restricted to
floating overlays only (blur(10px) saturate(1.15)), desaturated teal accent,
font-weight 500 everywhere, monochrome chart palette, and three depth cues:
root ambient glow, luminous card top-edge, steep text brightness ramp.

* refactor(ui): precision polish — fix muddy dark, snowblind light, add design anchors

- Replace 34 hardcoded rgba values with theme-aware stat-* CSS tokens
- Fix light theme: solid white cards, off-white background, readable text
- Add card-border tokens with sharper directional lighting (top edge 2x)
- Add chart-grid/chart-tick tokens for theme-aware axis rendering
- Upgrade body glow: teal-tinted (dark), warm amber (light)
- Terminal-inspired sidebar: monospaced UP/DN status codes, Geist Mono
- Add tabular-nums to stat values to prevent layout jitter
- Light mode cards get shadow-sm for depth against off-white background

* refactor(ui): Linear materiality pass — ghosted nav, translucent sidebar, font unity

- De-escalate Delete button from solid destructive to ghost with hover fill
- Make sidebar translucent (80% opacity + backdrop-blur) so body glow bleeds through
- Bump dark nav accent to 0.07 for ghosted backlit selection
- Unify all terminal/editor fonts to Geist Mono (was JetBrains/Consolas mix)
- Add Monaco editor fontFamily for YAML/env editing consistency
- Add threshold-based color to Host RAM and Host Disk stat values (warn/crit)

* refactor(ui): material simulation — inherent depth, layer separation, recessed terminal

- Bump dark background 0.065→0.08, card surfaces 0.10→0.12 for 4% layer separation
- Add card-bevel token (inset top shimmer) for permanent structural depth
- Add button-inner-glow token for physical key feel on outline buttons
- Recess terminal with inset shadow and dimmed label
- Reduce action icon strokeWidth to 1.5 for refined industrial feel
- Add teal LED backlight bar on active nav item via blur pseudo-element

* fix(ui): parse usagePercent string to number for getValueColor

usagePercent is typed as string in SystemStats but getValueColor
expects number, causing TS2345 in CI builds.
2026-03-30 12:48:42 -04:00

385 lines
18 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api';
import { AdmiralGate } from './AdmiralGate';
import { TierBadge } from './TierBadge';
import { Database, Plus, Trash2, Pencil, RefreshCw, CheckCircle, XCircle, Clock } from 'lucide-react';
type RegistryType = 'dockerhub' | 'ghcr' | 'ecr' | 'custom';
interface RegistryItem {
id: number;
name: string;
url: string;
type: RegistryType;
username: string;
has_secret: boolean;
aws_region: string | null;
created_at: number;
updated_at: number;
}
const TYPE_LABELS: Record<RegistryType, string> = {
dockerhub: 'Docker Hub',
ghcr: 'GitHub (GHCR)',
ecr: 'AWS ECR',
custom: 'Custom',
};
const TYPE_BADGE_VARIANT: Record<RegistryType, 'default' | 'secondary' | 'outline'> = {
dockerhub: 'default',
ghcr: 'secondary',
ecr: 'secondary',
custom: 'outline',
};
const TYPE_URL_DEFAULTS: Record<RegistryType, string> = {
dockerhub: 'https://index.docker.io/v1/',
ghcr: 'ghcr.io',
ecr: '',
custom: '',
};
const TYPE_USERNAME_HINT: Record<RegistryType, string> = {
dockerhub: 'Docker Hub username',
ghcr: 'GitHub username',
ecr: 'AWS Access Key ID',
custom: 'Username',
};
const TYPE_SECRET_HINT: Record<RegistryType, string> = {
dockerhub: 'Access token or password',
ghcr: 'Personal access token (PAT)',
ecr: 'AWS Secret Access Key',
custom: 'Password or token',
};
function formatDate(ts: number): string {
return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
export function RegistriesSection() {
const [registries, setRegistries] = useState<RegistryItem[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [testingId, setTestingId] = useState<number | null>(null);
const [formName, setFormName] = useState('');
const [formUrl, setFormUrl] = useState('');
const [formType, setFormType] = useState<RegistryType>('dockerhub');
const [formUsername, setFormUsername] = useState('');
const [formSecret, setFormSecret] = useState('');
const [formAwsRegion, setFormAwsRegion] = useState('');
const fetchRegistries = async () => {
try {
const res = await apiFetch('/registries', { localOnly: true });
if (res.ok) {
setRegistries(await res.json());
}
} catch {
toast.error('Failed to load registries.');
} finally {
setLoading(false);
}
};
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { fetchRegistries(); }, []);
const resetForm = () => {
setFormName('');
setFormUrl('');
setFormType('dockerhub');
setFormUsername('');
setFormSecret('');
setFormAwsRegion('');
setEditingId(null);
setShowForm(false);
};
const handleTypeChange = (type: RegistryType) => {
setFormType(type);
if (!editingId) {
setFormUrl(TYPE_URL_DEFAULTS[type]);
}
};
const startEdit = (reg: RegistryItem) => {
setEditingId(reg.id);
setFormName(reg.name);
setFormUrl(reg.url);
setFormType(reg.type);
setFormUsername(reg.username);
setFormSecret('');
setFormAwsRegion(reg.aws_region ?? '');
setShowForm(true);
};
const handleSave = async () => {
if (!formName.trim()) { toast.error('Name is required.'); return; }
if (!formUrl.trim()) { toast.error('URL is required.'); return; }
if (!formUsername.trim()) { toast.error('Username is required.'); return; }
if (!editingId && !formSecret.trim()) { toast.error('Secret/token is required.'); return; }
if (formType === 'ecr' && !formAwsRegion.trim()) { toast.error('AWS region is required for ECR.'); return; }
setSaving(true);
try {
const body: Record<string, unknown> = {
name: formName.trim(),
url: formUrl.trim(),
type: formType,
username: formUsername.trim(),
aws_region: formType === 'ecr' ? formAwsRegion.trim() : null,
};
if (formSecret.trim()) body.secret = formSecret.trim();
const url = editingId ? `/registries/${editingId}` : '/registries';
const method = editingId ? 'PUT' : 'POST';
const res = await apiFetch(url, {
method,
localOnly: true,
body: JSON.stringify(body),
});
if (res.ok) {
toast.success(editingId ? 'Registry updated.' : 'Registry added.');
resetForm();
fetchRegistries();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save registry.');
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Network error.');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: number) => {
try {
const res = await apiFetch(`/registries/${id}`, { method: 'DELETE', localOnly: true });
if (res.ok) {
toast.success('Registry deleted.');
fetchRegistries();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to delete registry.');
}
} catch {
toast.error('Network error.');
}
};
const handleTest = async (id: number) => {
setTestingId(id);
try {
const res = await apiFetch(`/registries/${id}/test`, { method: 'POST', localOnly: true });
if (res.ok) {
const data = await res.json();
if (data.success) {
toast.success('Connection successful!');
} else {
toast.error(data.error || 'Connection failed.');
}
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Test failed.');
}
} catch {
toast.error('Network error.');
} finally {
setTestingId(null);
}
};
return (
<AdmiralGate featureName="Private Registry Management">
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">
Private Registries <TierBadge />
</h3>
<p className="text-sm text-muted-foreground">
Store credentials for private Docker registries. Sencho injects them automatically during deploy and pull operations.
</p>
</div>
<Button size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="w-4 h-4 mr-1.5" /> Add Registry
</Button>
</div>
{/* Create / Edit form */}
{showForm && (
<div className="space-y-4 bg-muted/10 p-4 border border-border rounded-xl">
<div className="space-y-2">
<Label>Registry Type</Label>
<Select value={formType} onValueChange={(v) => handleTypeChange(v as RegistryType)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="dockerhub">Docker Hub</SelectItem>
<SelectItem value="ghcr">GitHub Container Registry (GHCR)</SelectItem>
<SelectItem value="ecr">AWS Elastic Container Registry (ECR)</SelectItem>
<SelectItem value="custom">Custom / Self-hosted</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Name</Label>
<Input
placeholder="My private registry"
value={formName}
onChange={e => setFormName(e.target.value)}
maxLength={100}
/>
</div>
<div className="space-y-2">
<Label>Registry URL</Label>
<Input
placeholder={formType === 'ecr' ? '123456789.dkr.ecr.us-east-1.amazonaws.com' : 'registry.example.com'}
value={formUrl}
onChange={e => setFormUrl(e.target.value)}
maxLength={500}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{formType === 'ecr' ? 'AWS Access Key ID' : 'Username'}</Label>
<Input
placeholder={TYPE_USERNAME_HINT[formType]}
value={formUsername}
onChange={e => setFormUsername(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>{formType === 'ecr' ? 'AWS Secret Access Key' : 'Secret / Token'}</Label>
<Input
type="password"
placeholder={editingId ? '(leave blank to keep current)' : TYPE_SECRET_HINT[formType]}
value={formSecret}
onChange={e => setFormSecret(e.target.value)}
/>
</div>
</div>
{formType === 'ecr' && (
<div className="space-y-2">
<Label>AWS Region</Label>
<Input
placeholder="us-east-1"
value={formAwsRegion}
onChange={e => setFormAwsRegion(e.target.value)}
/>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? <><RefreshCw className="w-4 h-4 mr-1.5 animate-spin" />Saving...</> : editingId ? 'Update' : 'Add'}
</Button>
</div>
</div>
)}
{/* Loading state */}
{loading && (
<div className="space-y-3">
<Skeleton className="h-20 w-full rounded-xl" />
<Skeleton className="h-20 w-full rounded-xl" />
</div>
)}
{/* Empty state */}
{!loading && registries.length === 0 && !showForm && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Database className="w-10 h-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">No private registries configured.</p>
<p className="text-xs text-muted-foreground mt-1">Add one to pull images from Docker Hub orgs, GHCR, ECR, or self-hosted registries.</p>
</div>
)}
{/* Registry list */}
{!loading && registries.map(reg => (
<div key={reg.id} className="border border-border rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<Database className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm truncate">{reg.name}</span>
<Badge variant={TYPE_BADGE_VARIANT[reg.type]} className="text-[10px] shrink-0">
{TYPE_LABELS[reg.type]}
</Badge>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => handleTest(reg.id)}
disabled={testingId === reg.id}
title="Test connection"
>
{testingId === reg.id ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<CheckCircle className="w-4 h-4" />
)}
</Button>
<Button variant="ghost" size="sm" onClick={() => startEdit(reg)} title="Edit">
<Pencil className="w-4 h-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" title="Delete">
<Trash2 className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete registry?</AlertDialogTitle>
<AlertDialogDescription>
Deleting <strong>{reg.name}</strong> will remove its stored credentials. Stacks using images from this registry will fail to pull until credentials are re-added.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDelete(reg.id)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="font-mono truncate max-w-[200px]" title={reg.url}>{reg.url}</span>
<span>{reg.username}</span>
<span className="flex items-center gap-1">
{reg.has_secret ? (
<><CheckCircle className="w-3 h-3 text-success" /> Secret stored</>
) : (
<><XCircle className="w-3 h-3 text-destructive" /> No secret</>
)}
</span>
{reg.aws_region && <span>Region: {reg.aws_region}</span>}
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatDate(reg.created_at)}
</span>
</div>
</div>
))}
</div>
</AdmiralGate>
);
}