mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 09:24:09 +00:00
0bf061a745
* feat(settings): group sections, add ⌘K search, scope breadcrumb Restructures the Settings Hub sidebar into four labelled groups (Identity, System, Alerts, Advanced), adds a ⌘K command palette for section search, and surfaces the active scope (global vs node-scoped) in the content breadcrumb. - New `settings/registry.ts` centralises group/item metadata, tier gates, glyph assignments, visibility rules, and keyword hints consumed by both the sidebar and the command palette - Cyan 2px left rail + gradient on the active sidebar item; mono-uppercase group headers; tier chips inline for locked items - Scoped ⌘K handler via onKeyDownCapture on DialogContent so the hub no longer hijacks the global sidebar shortcut while open - ScrollArea gains an opt-in `block` prop so the Nodes management table can overflow horizontally without Radix's default `display: table` wrapper clipping action buttons - Docs reference updated with the grouped sidebar, scope breadcrumb, and ⌘K walkthrough plus refreshed screenshots * refactor(settings): drop duplicate section headers, redesign system limits, always-visible tier chips - Remove redundant section titles in every settings page; the dialog header now owns the title and description - Rework System Limits into a compact row panel with inline-edit chips (warn state, focus ring) and an ON/OFF toggle pill - Show tier chips on sidebar and command palette whether locked or unlocked, so Skipper/Admiral scope is always legible - Keep right-aligned action buttons on pages that had a title+button header (Users, Labels, Nodes, API Tokens, Registries) * fix(settings): seed NumberChip draft on edit instead of via effect ESLint rule react-hooks/set-state-in-effect flagged the sync effect that mirrored the external value into local draft state. Replace it with a startEdit handler that seeds draft from value at click time, so the button path always reads value directly and no cascading render is triggered on prop change. * fix(settings): restore heading role and clean sidebar accessible names - Wrap the settings dialog title in an h2 so screen readers and E2E locators see a heading again after the in-section headers were removed - Mark the sidebar glyph aria-hidden so the button's accessible name is just the item label (fixes anchored name matchers) - Align the MFA E2E helper with the renamed Account section heading
452 lines
21 KiB
TypeScript
452 lines
21 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Combobox } from '@/components/ui/combobox';
|
|
import type { ComboboxOption } from '@/components/ui/combobox';
|
|
import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
|
|
import { springs } from '@/lib/motion';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from '@/components/ui/alert-dialog';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
import { apiFetch } from '@/lib/api';
|
|
import { AdmiralGate } from '@/components/AdmiralGate';
|
|
import { CapabilityGate } from '@/components/CapabilityGate';
|
|
import { Plus, Trash2, Pencil, RefreshCw, Zap, X, Route } from 'lucide-react';
|
|
|
|
interface NotificationRoute {
|
|
id: number;
|
|
name: string;
|
|
stack_patterns: string[];
|
|
channel_type: 'discord' | 'slack' | 'webhook';
|
|
channel_url: string;
|
|
priority: number;
|
|
enabled: boolean;
|
|
created_at: number;
|
|
updated_at: number;
|
|
}
|
|
|
|
const CHANNEL_LABELS: Record<string, string> = {
|
|
discord: 'Discord',
|
|
slack: 'Slack',
|
|
webhook: 'Webhook',
|
|
};
|
|
|
|
const CHANNEL_PLACEHOLDERS: Record<string, string> = {
|
|
discord: 'https://discord.com/api/webhooks/...',
|
|
slack: 'https://hooks.slack.com/services/...',
|
|
webhook: 'https://example.com/webhook',
|
|
};
|
|
|
|
export function NotificationRoutingSection() {
|
|
const [routes, setRoutes] = useState<NotificationRoute[]>([]);
|
|
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 [stackOptions, setStackOptions] = useState<ComboboxOption[]>([]);
|
|
|
|
// Form state
|
|
const [formName, setFormName] = useState('');
|
|
const [formStacks, setFormStacks] = useState<string[]>([]);
|
|
const [formChannelType, setFormChannelType] = useState<'discord' | 'slack' | 'webhook'>('discord');
|
|
const [formChannelUrl, setFormChannelUrl] = useState('');
|
|
const [formPriority, setFormPriority] = useState(0);
|
|
const [formEnabled, setFormEnabled] = useState(true);
|
|
|
|
const fetchRoutes = useCallback(async () => {
|
|
try {
|
|
const res = await apiFetch('/notification-routes');
|
|
if (res.ok) {
|
|
setRoutes(await res.json());
|
|
}
|
|
} catch {
|
|
toast.error('Failed to load notification routes.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const fetchStacks = useCallback(async () => {
|
|
try {
|
|
const res = await apiFetch('/stacks');
|
|
if (res.ok) {
|
|
const data: string[] = await res.json();
|
|
setStackOptions(data.map((s) => ({ value: s, label: s })));
|
|
}
|
|
} catch {
|
|
// Stacks may fail on remote nodes, non-critical
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchRoutes();
|
|
fetchStacks();
|
|
}, [fetchRoutes, fetchStacks]);
|
|
|
|
const resetForm = () => {
|
|
setFormName('');
|
|
setFormStacks([]);
|
|
setFormChannelType('discord');
|
|
setFormChannelUrl('');
|
|
setFormPriority(0);
|
|
setFormEnabled(true);
|
|
setEditingId(null);
|
|
setShowForm(false);
|
|
};
|
|
|
|
const startEdit = (route: NotificationRoute) => {
|
|
setEditingId(route.id);
|
|
setFormName(route.name);
|
|
setFormStacks([...route.stack_patterns]);
|
|
setFormChannelType(route.channel_type);
|
|
setFormChannelUrl(route.channel_url);
|
|
setFormPriority(route.priority);
|
|
setFormEnabled(route.enabled);
|
|
setShowForm(true);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!formName.trim()) { toast.error('Name is required.'); return; }
|
|
if (formStacks.length === 0) { toast.error('At least one stack must be selected.'); return; }
|
|
if (!formChannelUrl.trim() || !formChannelUrl.startsWith('https://')) {
|
|
toast.error('Channel URL must be a valid HTTPS URL.');
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
try {
|
|
const body = {
|
|
name: formName.trim(),
|
|
stack_patterns: formStacks,
|
|
channel_type: formChannelType,
|
|
channel_url: formChannelUrl.trim(),
|
|
priority: formPriority,
|
|
enabled: formEnabled,
|
|
};
|
|
|
|
const url = editingId ? `/notification-routes/${editingId}` : '/notification-routes';
|
|
const method = editingId ? 'PUT' : 'POST';
|
|
|
|
const res = await apiFetch(url, {
|
|
method,
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (res.ok) {
|
|
toast.success(editingId ? 'Route updated.' : 'Route created.');
|
|
resetForm();
|
|
fetchRoutes();
|
|
} else {
|
|
const err = await res.json().catch(() => ({}));
|
|
toast.error(err?.error || err?.message || err?.data?.error || 'Something went wrong.');
|
|
}
|
|
} catch (e: unknown) {
|
|
toast.error((e as Error)?.message || 'Network error.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: number) => {
|
|
try {
|
|
const res = await apiFetch(`/notification-routes/${id}`, { method: 'DELETE' });
|
|
if (res.ok) {
|
|
toast.success('Route deleted.');
|
|
fetchRoutes();
|
|
} else {
|
|
const err = await res.json().catch(() => ({}));
|
|
toast.error(err?.error || err?.message || err?.data?.error || 'Something went wrong.');
|
|
}
|
|
} catch {
|
|
toast.error('Network error.');
|
|
}
|
|
};
|
|
|
|
const handleTest = async (id: number) => {
|
|
setTestingId(id);
|
|
try {
|
|
const res = await apiFetch(`/notification-routes/${id}/test`, { method: 'POST' });
|
|
if (res.ok) {
|
|
toast.success('Test notification sent!');
|
|
} else {
|
|
const err = await res.json().catch(() => ({}));
|
|
toast.error(err?.details || err?.error || 'Test failed.');
|
|
}
|
|
} catch {
|
|
toast.error('Network error.');
|
|
} finally {
|
|
setTestingId(null);
|
|
}
|
|
};
|
|
|
|
const handleToggleEnabled = async (route: NotificationRoute) => {
|
|
try {
|
|
const res = await apiFetch(`/notification-routes/${route.id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ enabled: !route.enabled }),
|
|
});
|
|
if (res.ok) {
|
|
fetchRoutes();
|
|
} else {
|
|
const err = await res.json().catch(() => ({}));
|
|
toast.error(err?.error || err?.message || err?.data?.error || 'Something went wrong.');
|
|
}
|
|
} catch {
|
|
toast.error('Network error.');
|
|
}
|
|
};
|
|
|
|
const addStack = (stackName: string) => {
|
|
if (stackName && !formStacks.includes(stackName)) {
|
|
setFormStacks(prev => [...prev, stackName]);
|
|
}
|
|
};
|
|
|
|
const removeStack = (stackName: string) => {
|
|
setFormStacks(prev => prev.filter(s => s !== stackName));
|
|
};
|
|
|
|
const availableStackOptions = stackOptions.filter(o => !formStacks.includes(o.value));
|
|
|
|
return (
|
|
<AdmiralGate featureName="Notification Routing">
|
|
<CapabilityGate capability="notification-routing" featureName="Notification Routing">
|
|
<div className="space-y-6">
|
|
<div className="flex justify-end">
|
|
<Button size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
|
|
<Plus className="w-4 h-4 mr-1.5" /> Add Route
|
|
</Button>
|
|
</div>
|
|
|
|
<Dialog open={showForm} onOpenChange={(open) => { if (!open) resetForm(); }}>
|
|
<DialogContent className="sm:max-w-[500px]">
|
|
<DialogTitle>{editingId ? 'Edit Route' : 'New Routing Rule'}</DialogTitle>
|
|
<DialogDescription className="sr-only">
|
|
{editingId ? 'Edit a notification routing rule' : 'Create a notification routing rule'}
|
|
</DialogDescription>
|
|
|
|
<div className="space-y-4 pt-2">
|
|
<div className="space-y-2">
|
|
<Label>Name</Label>
|
|
<Input
|
|
placeholder="e.g. Production alerts"
|
|
value={formName}
|
|
onChange={e => setFormName(e.target.value)}
|
|
maxLength={100}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Stacks</Label>
|
|
<Combobox
|
|
options={availableStackOptions}
|
|
value=""
|
|
onValueChange={addStack}
|
|
placeholder="Add a stack..."
|
|
searchPlaceholder="Search stacks..."
|
|
emptyText="No stacks found."
|
|
/>
|
|
{formStacks.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
|
{formStacks.map(s => (
|
|
<Badge key={s} variant="secondary" className="font-mono text-xs gap-1 pr-1">
|
|
{s}
|
|
<button
|
|
type="button"
|
|
onClick={() => removeStack(s)}
|
|
className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Channel</Label>
|
|
<Tabs value={formChannelType} onValueChange={(v) => setFormChannelType(v as 'discord' | 'slack' | 'webhook')}>
|
|
<TabsList className="w-full grid grid-cols-3">
|
|
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
|
|
<TabsHighlightItem value="discord">
|
|
<TabsTrigger value="discord">Discord</TabsTrigger>
|
|
</TabsHighlightItem>
|
|
<TabsHighlightItem value="slack">
|
|
<TabsTrigger value="slack">Slack</TabsTrigger>
|
|
</TabsHighlightItem>
|
|
<TabsHighlightItem value="webhook">
|
|
<TabsTrigger value="webhook">Webhook</TabsTrigger>
|
|
</TabsHighlightItem>
|
|
</TabsHighlight>
|
|
</TabsList>
|
|
</Tabs>
|
|
<Input
|
|
placeholder={CHANNEL_PLACEHOLDERS[formChannelType]}
|
|
value={formChannelUrl}
|
|
onChange={e => setFormChannelUrl(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Priority</Label>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
value={formPriority}
|
|
onChange={e => setFormPriority(parseInt(e.target.value, 10) || 0)}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">Lower values are evaluated first.</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Enabled</Label>
|
|
<div className="pt-2">
|
|
<Switch checked={formEnabled} onCheckedChange={setFormEnabled} />
|
|
</div>
|
|
</div>
|
|
</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' : 'Create'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{loading && (
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-20 w-full rounded-xl" />
|
|
<Skeleton className="h-20 w-full rounded-xl" />
|
|
</div>
|
|
)}
|
|
|
|
{!loading && routes.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<Route className="w-10 h-10 text-muted-foreground/50 mb-3" strokeWidth={1.5} />
|
|
<p className="text-sm text-muted-foreground">No routing rules configured.</p>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Alerts will use your global notification channels. Add a route to direct specific stack alerts to dedicated channels.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{!loading && routes.map(route => (
|
|
<div
|
|
key={route.id}
|
|
className="rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors hover:border-t-card-border-hover p-4 space-y-3"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<Route className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
|
|
<span className="font-medium text-sm truncate">{route.name}</span>
|
|
<Badge variant="outline" className="text-[10px] shrink-0">
|
|
{CHANNEL_LABELS[route.channel_type]}
|
|
</Badge>
|
|
{!route.enabled && (
|
|
<Badge variant="secondary" className="text-[10px] shrink-0 text-muted-foreground">
|
|
Disabled
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<Switch
|
|
checked={route.enabled}
|
|
onCheckedChange={() => handleToggleEnabled(route)}
|
|
className="scale-75"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleTest(route.id)}
|
|
disabled={testingId === route.id}
|
|
title="Send test notification"
|
|
>
|
|
{testingId === route.id ? (
|
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Zap className="w-4 h-4" strokeWidth={1.5} />
|
|
)}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => startEdit(route)} title="Edit">
|
|
<Pencil className="w-4 h-4" strokeWidth={1.5} />
|
|
</Button>
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
|
title="Delete"
|
|
>
|
|
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete routing rule?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Deleting <strong>{route.name}</strong> will remove this routing rule. Alerts for the associated stacks will fall back to your global notification channels.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() => handleDelete(route.id)}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
Delete
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
<div className="flex flex-wrap gap-1">
|
|
{route.stack_patterns.map(s => (
|
|
<Badge key={s} variant="secondary" className="font-mono text-[10px]">{s}</Badge>
|
|
))}
|
|
</div>
|
|
<span className="text-muted-foreground/50">|</span>
|
|
<span className="font-mono truncate max-w-[200px]" title={route.channel_url}>
|
|
{route.channel_url}
|
|
</span>
|
|
{route.priority !== 0 && (
|
|
<>
|
|
<span className="text-muted-foreground/50">|</span>
|
|
<span className="tabular-nums">Priority: {route.priority}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CapabilityGate>
|
|
</AdmiralGate>
|
|
);
|
|
}
|