mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
607 lines
29 KiB
TypeScript
607 lines
29 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { TogglePill } from '@/components/ui/toggle-pill';
|
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { springs } from '@/lib/motion';
|
|
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
import { apiFetch } from '@/lib/api';
|
|
import { useNodes } from '@/context/NodeContext';
|
|
import { CapabilityGate } from '@/components/CapabilityGate';
|
|
import type { NotificationCategory } from '@/components/dashboard/types';
|
|
import type { Label as StackLabel } from '@/components/label-types';
|
|
import { CATEGORY_LABELS } from '@/lib/notificationCategories';
|
|
import { Plus, Trash2, Pencil, RefreshCw, Zap, X, Route } from 'lucide-react';
|
|
import { SettingsCallout } from './SettingsCallout';
|
|
import { SettingsPrimaryButton } from './SettingsActions';
|
|
import { useMastheadStats } from './MastheadStatsContext';
|
|
|
|
interface NotificationRoute {
|
|
id: number;
|
|
name: string;
|
|
node_id: number | null;
|
|
stack_patterns: string[];
|
|
label_ids: number[] | null;
|
|
categories: NotificationCategory[] | null;
|
|
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 { nodes } = useNodes();
|
|
const localNode = useMemo(() => nodes.find(n => n.type === 'local') ?? null, [nodes]);
|
|
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 [deleteRouteId, setDeleteRouteId] = useState<number | null>(null);
|
|
const [stackOptions, setStackOptions] = useState<ComboboxOption[]>([]);
|
|
const [labelOptions, setLabelOptions] = useState<StackLabel[]>([]);
|
|
|
|
// Form state
|
|
const [formName, setFormName] = useState('');
|
|
const [formNodeId, setFormNodeId] = useState<number | null>(null);
|
|
const [formStacks, setFormStacks] = useState<string[]>([]);
|
|
const [formLabelIds, setFormLabelIds] = useState<number[]>([]);
|
|
const [formCategories, setFormCategories] = useState<NotificationCategory[]>([]);
|
|
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
|
|
}
|
|
}, []);
|
|
|
|
const fetchLabels = useCallback(async () => {
|
|
try {
|
|
const res = await apiFetch('/labels');
|
|
if (res.ok) {
|
|
setLabelOptions(await res.json());
|
|
}
|
|
} catch {
|
|
// Labels non-critical
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void Promise.all([fetchRoutes(), fetchStacks(), fetchLabels()]);
|
|
}, [fetchRoutes, fetchStacks, fetchLabels]);
|
|
|
|
const resetForm = () => {
|
|
setFormName('');
|
|
setFormNodeId(null);
|
|
setFormStacks([]);
|
|
setFormLabelIds([]);
|
|
setFormCategories([]);
|
|
setFormChannelType('discord');
|
|
setFormChannelUrl('');
|
|
setFormPriority(0);
|
|
setFormEnabled(true);
|
|
setEditingId(null);
|
|
setShowForm(false);
|
|
};
|
|
|
|
const startEdit = (route: NotificationRoute) => {
|
|
setEditingId(route.id);
|
|
setFormName(route.name);
|
|
setFormNodeId(route.node_id);
|
|
setFormStacks([...route.stack_patterns]);
|
|
setFormLabelIds(route.label_ids ? [...route.label_ids] : []);
|
|
setFormCategories(route.categories ? [...route.categories] : []);
|
|
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 (!formChannelUrl.trim() || !formChannelUrl.startsWith('https://')) {
|
|
toast.error('Channel URL must be a valid HTTPS URL.');
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
try {
|
|
const body = {
|
|
name: formName.trim(),
|
|
node_id: formNodeId,
|
|
stack_patterns: formStacks,
|
|
label_ids: formLabelIds.length > 0 ? formLabelIds : null,
|
|
categories: formCategories.length > 0 ? formCategories : null,
|
|
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 () => {
|
|
if (deleteRouteId == null) return;
|
|
try {
|
|
const res = await apiFetch(`/notification-routes/${deleteRouteId}`, { 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.');
|
|
} finally {
|
|
setDeleteRouteId(null);
|
|
}
|
|
};
|
|
|
|
const deleteTargetRoute = deleteRouteId != null ? routes.find(r => r.id === deleteRouteId) : null;
|
|
|
|
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 addLabel = (idStr: string) => {
|
|
const id = Number(idStr);
|
|
if (!isNaN(id) && id > 0 && !formLabelIds.includes(id)) {
|
|
setFormLabelIds(prev => [...prev, id]);
|
|
}
|
|
};
|
|
|
|
const removeLabel = (id: number) => {
|
|
setFormLabelIds(prev => prev.filter(l => l !== id));
|
|
};
|
|
|
|
const addCategory = (cat: string) => {
|
|
const c = cat as NotificationCategory;
|
|
if (c && !formCategories.includes(c)) {
|
|
setFormCategories(prev => [...prev, c]);
|
|
}
|
|
};
|
|
|
|
const removeCategory = (cat: NotificationCategory) => {
|
|
setFormCategories(prev => prev.filter(c => c !== cat));
|
|
};
|
|
|
|
const enabledRoutesCount = routes.filter(r => r.enabled).length;
|
|
useMastheadStats(
|
|
loading
|
|
? null
|
|
: [
|
|
{ label: 'ROUTES', value: `${routes.length}` },
|
|
{
|
|
label: 'ENABLED',
|
|
value: `${enabledRoutesCount}`,
|
|
tone: enabledRoutesCount > 0 ? 'value' : 'subtitle',
|
|
},
|
|
],
|
|
);
|
|
|
|
const availableStackOptions = stackOptions.filter(o => !formStacks.includes(o.value));
|
|
const availableLabelOptions = useMemo<ComboboxOption[]>(
|
|
() => labelOptions.filter(l => !formLabelIds.includes(l.id)).map(l => ({ value: String(l.id), label: l.name })),
|
|
[labelOptions, formLabelIds],
|
|
);
|
|
const availableCategoryOptions = useMemo<ComboboxOption[]>(
|
|
() => (Object.keys(CATEGORY_LABELS) as NotificationCategory[]).filter(c => !formCategories.includes(c)).map(c => ({ value: c, label: CATEGORY_LABELS[c] })),
|
|
[formCategories],
|
|
);
|
|
|
|
return (
|
|
<CapabilityGate capability="notification-routing" featureName="Routing">
|
|
<div className="space-y-6">
|
|
<div className="flex justify-end">
|
|
<SettingsPrimaryButton size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
|
|
<Plus className="w-4 h-4" /> Add route
|
|
</SettingsPrimaryButton>
|
|
</div>
|
|
|
|
<Modal open={showForm} onOpenChange={(open) => { if (!open) resetForm(); }} size="lg">
|
|
<ModalHeader
|
|
kicker={editingId ? 'ROUTING · EDIT RULE' : 'ROUTING · NEW RULE'}
|
|
title={editingId ? 'Edit routing rule' : 'New routing rule'}
|
|
description={editingId ? 'Edit a notification routing rule' : 'Create a notification routing rule'}
|
|
/>
|
|
<ModalBody>
|
|
<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>Node scope</Label>
|
|
<Select
|
|
value={formNodeId === null ? 'any' : String(formNodeId)}
|
|
onValueChange={(v) => setFormNodeId(v === 'any' ? null : Number(v))}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="any">Any node</SelectItem>
|
|
{localNode !== null && (
|
|
<SelectItem value={String(localNode.id)}>{localNode.name}</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Stacks <span className="text-muted-foreground font-normal text-xs">(optional)</span></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>Labels <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
|
|
<Combobox
|
|
options={availableLabelOptions}
|
|
value=""
|
|
onValueChange={addLabel}
|
|
placeholder="Add a label..."
|
|
searchPlaceholder="Search labels..."
|
|
emptyText="No labels found."
|
|
/>
|
|
{formLabelIds.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
|
{formLabelIds.map(id => {
|
|
const lbl = labelOptions.find(l => l.id === id);
|
|
return (
|
|
<Badge key={id} variant="secondary" className="text-xs gap-1 pr-1">
|
|
{lbl?.name ?? `Label ${id}`}
|
|
<button
|
|
type="button"
|
|
onClick={() => removeLabel(id)}
|
|
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>Categories <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
|
|
<Combobox
|
|
options={availableCategoryOptions}
|
|
value=""
|
|
onValueChange={addCategory}
|
|
placeholder="Add a category..."
|
|
searchPlaceholder="Search categories..."
|
|
emptyText="No categories found."
|
|
/>
|
|
{formCategories.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
|
{formCategories.map(c => (
|
|
<Badge key={c} variant="secondary" className="text-xs gap-1 pr-1">
|
|
{CATEGORY_LABELS[c]}
|
|
<button
|
|
type="button"
|
|
onClick={() => removeCategory(c)}
|
|
className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
<p className="text-xs text-muted-foreground">Leave blank to match all categories. All non-empty filters must match (AND).</p>
|
|
</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">
|
|
<TogglePill checked={formEnabled} onChange={setFormEnabled} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</ModalBody>
|
|
<ModalFooter
|
|
secondary={
|
|
<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>
|
|
}
|
|
primary={
|
|
<SettingsPrimaryButton size="sm" onClick={handleSave} disabled={saving}>
|
|
{saving ? <><RefreshCw className="w-4 h-4 animate-spin" />Saving</> : editingId ? 'Update' : 'Create'}
|
|
</SettingsPrimaryButton>
|
|
}
|
|
/>
|
|
</Modal>
|
|
|
|
{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 && (
|
|
<SettingsCallout
|
|
icon={<Route className="h-4 w-4" strokeWidth={1.5} />}
|
|
title="No routing rules configured"
|
|
subtitle="Alerts use your global notification channels. Add a route to direct specific stack alerts to dedicated channels."
|
|
/>
|
|
)}
|
|
|
|
{!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.node_id !== null && (
|
|
<Badge variant="secondary" className="text-[10px] shrink-0 font-mono">
|
|
{route.node_id === localNode?.id ? localNode?.name : `node:${route.node_id}`}
|
|
</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">
|
|
<TogglePill
|
|
checked={route.enabled}
|
|
onChange={() => 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>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
|
title="Delete"
|
|
onClick={() => setDeleteRouteId(route.id)}
|
|
>
|
|
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
|
{route.stack_patterns.length > 0 && route.stack_patterns.map(s => (
|
|
<Badge key={s} variant="secondary" className="font-mono text-[10px]">{s}</Badge>
|
|
))}
|
|
{route.label_ids && route.label_ids.length > 0 && route.label_ids.map(id => {
|
|
const lbl = labelOptions.find(l => l.id === id);
|
|
return (
|
|
<Badge key={id} variant="outline" className="text-[10px]">
|
|
{lbl?.name ?? `label:${id}`}
|
|
</Badge>
|
|
);
|
|
})}
|
|
{route.categories && route.categories.length > 0 && route.categories.map(c => (
|
|
<Badge key={c} variant="outline" className="text-[10px] font-mono">{CATEGORY_LABELS[c] ?? c}</Badge>
|
|
))}
|
|
{route.stack_patterns.length === 0 && (!route.label_ids || route.label_ids.length === 0) && (!route.categories || route.categories.length === 0) && (
|
|
<span className="text-muted-foreground/50 text-[10px]">Matches all alerts</span>
|
|
)}
|
|
<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>
|
|
))}
|
|
|
|
<ConfirmModal
|
|
open={deleteRouteId != null}
|
|
onOpenChange={(open) => { if (!open) setDeleteRouteId(null); }}
|
|
variant="destructive"
|
|
kicker="ROUTING · DELETE · IRREVERSIBLE"
|
|
title="Delete routing rule"
|
|
confirmLabel="Delete"
|
|
onConfirm={handleDelete}
|
|
>
|
|
<p className="text-sm text-stat-subtitle">
|
|
Deletes <span className="font-medium text-stat-value">{deleteTargetRoute?.name ?? 'this rule'}</span>. Alerts for the associated stacks will fall back to your global notification channels.
|
|
</p>
|
|
</ConfirmModal>
|
|
</div>
|
|
</CapabilityGate>
|
|
);
|
|
}
|