mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +00:00
feat: stack glob patterns and route severity levels (#1651)
* feat: add stack glob patterns and route severity levels Operators can filter notification routes and mute rules with anchored * globs, and routes can target info, warning, or error. Matching is fail-closed for unsafe stored patterns; write paths keep partial-PUT semantics and ReDoS caps. * test: split mute and routing chip tests to avoid dialog race * fix: move stack pattern client validator out of PatternChips * fix: bound stack glob matching and atomic pattern chip saves
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -24,6 +24,9 @@ import { SettingsCallout } from './SettingsCallout';
|
||||
import { SettingsPrimaryButton } from './SettingsActions';
|
||||
import { useMastheadStats } from './MastheadStatsContext';
|
||||
import { classifyAppriseEndpoint, isStatelessAppriseEndpoint } from '@/lib/appriseEndpoint';
|
||||
import { PatternChips, type PatternChipsHandle } from './PatternChips';
|
||||
|
||||
type NotificationLevel = 'info' | 'warning' | 'error';
|
||||
|
||||
interface NotificationRoute {
|
||||
id: number;
|
||||
@@ -32,6 +35,7 @@ interface NotificationRoute {
|
||||
stack_patterns: string[];
|
||||
label_ids: number[] | null;
|
||||
categories: NotificationCategory[] | null;
|
||||
levels: NotificationLevel[] | null;
|
||||
channel_type: 'discord' | 'slack' | 'webhook' | 'apprise';
|
||||
channel_url: string;
|
||||
config: { mode: 'keyed' | 'stateless'; tags?: string; has_urls: boolean; providers?: string[]; url_count?: number } | null;
|
||||
@@ -41,6 +45,12 @@ interface NotificationRoute {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
const LEVEL_LABELS: Record<NotificationLevel, string> = {
|
||||
info: 'Info',
|
||||
warning: 'Warning',
|
||||
error: 'Error',
|
||||
};
|
||||
|
||||
const CHANNEL_LABELS: Record<string, string> = {
|
||||
discord: 'Discord',
|
||||
slack: 'Slack',
|
||||
@@ -74,7 +84,9 @@ export function NotificationRoutingSection() {
|
||||
const [formStacks, setFormStacks] = useState<string[]>([]);
|
||||
const [formLabelIds, setFormLabelIds] = useState<number[]>([]);
|
||||
const [formCategories, setFormCategories] = useState<NotificationCategory[]>([]);
|
||||
const [formLevels, setFormLevels] = useState<NotificationLevel[]>([]);
|
||||
const [formChannelType, setFormChannelType] = useState<'discord' | 'slack' | 'webhook' | 'apprise'>('discord');
|
||||
const patternChipsRef = useRef<PatternChipsHandle>(null);
|
||||
const [formChannelUrl, setFormChannelUrl] = useState('');
|
||||
const [formAppriseUrls, setFormAppriseUrls] = useState('');
|
||||
const [formAppriseTags, setFormAppriseTags] = useState('');
|
||||
@@ -132,6 +144,7 @@ export function NotificationRoutingSection() {
|
||||
setFormStacks([]);
|
||||
setFormLabelIds([]);
|
||||
setFormCategories([]);
|
||||
setFormLevels([]);
|
||||
setFormChannelType('discord');
|
||||
setFormChannelUrl('');
|
||||
setFormAppriseUrls('');
|
||||
@@ -153,6 +166,7 @@ export function NotificationRoutingSection() {
|
||||
setFormStacks([...route.stack_patterns]);
|
||||
setFormLabelIds(route.label_ids ? [...route.label_ids] : []);
|
||||
setFormCategories(route.categories ? [...route.categories] : []);
|
||||
setFormLevels(route.levels ? [...route.levels] : []);
|
||||
setFormChannelType(route.channel_type);
|
||||
setFormChannelUrl(route.channel_url);
|
||||
setFormAppriseTags(route.config?.tags ?? '');
|
||||
@@ -170,6 +184,11 @@ export function NotificationRoutingSection() {
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formName.trim()) { toast.error('Name is required.'); return; }
|
||||
const preparedPatterns = patternChipsRef.current?.prepareSave();
|
||||
if (!preparedPatterns?.ok) {
|
||||
toast.error('Fix invalid stack patterns before saving.');
|
||||
return;
|
||||
}
|
||||
if (!formChannelUrl.trim() || (formChannelType !== 'apprise' && !formChannelUrl.startsWith('https://'))) {
|
||||
toast.error(formChannelType === 'apprise' ? 'Enter a valid Apprise endpoint.' : 'Channel URL must be a valid HTTPS URL.');
|
||||
return;
|
||||
@@ -204,9 +223,10 @@ export function NotificationRoutingSection() {
|
||||
const body = {
|
||||
name: formName.trim(),
|
||||
node_id: formNodeId,
|
||||
stack_patterns: formStacks,
|
||||
stack_patterns: preparedPatterns.patterns,
|
||||
label_ids: formLabelIds.length > 0 ? formLabelIds : null,
|
||||
categories: formCategories.length > 0 ? formCategories : null,
|
||||
levels: formLevels.length > 0 ? formLevels : null,
|
||||
channel_type: formChannelType,
|
||||
...(formChannelType !== 'apprise' || !editingId || appriseEndpointDirty || channelTypeChanged
|
||||
? { channel_url: formChannelUrl.trim() }
|
||||
@@ -305,10 +325,6 @@ export function NotificationRoutingSection() {
|
||||
}
|
||||
};
|
||||
|
||||
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)) {
|
||||
@@ -331,6 +347,17 @@ export function NotificationRoutingSection() {
|
||||
setFormCategories(prev => prev.filter(c => c !== cat));
|
||||
};
|
||||
|
||||
const addLevel = (level: string) => {
|
||||
const l = level as NotificationLevel;
|
||||
if ((l === 'info' || l === 'warning' || l === 'error') && !formLevels.includes(l)) {
|
||||
setFormLevels(prev => [...prev, l]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeLevel = (level: NotificationLevel) => {
|
||||
setFormLevels(prev => prev.filter(l => l !== level));
|
||||
};
|
||||
|
||||
const enabledRoutesCount = routes.filter(r => r.enabled).length;
|
||||
useMastheadStats(
|
||||
loading
|
||||
@@ -355,6 +382,10 @@ export function NotificationRoutingSection() {
|
||||
() => (Object.keys(CATEGORY_LABELS) as NotificationCategory[]).filter(c => !formCategories.includes(c)).map(c => ({ value: c, label: CATEGORY_LABELS[c] })),
|
||||
[formCategories],
|
||||
);
|
||||
const availableLevelOptions = useMemo<ComboboxOption[]>(
|
||||
() => (Object.keys(LEVEL_LABELS) as NotificationLevel[]).filter(l => !formLevels.includes(l)).map(l => ({ value: l, label: LEVEL_LABELS[l] })),
|
||||
[formLevels],
|
||||
);
|
||||
|
||||
return (
|
||||
<CapabilityGate capability="notification-routing" featureName="Routing">
|
||||
@@ -402,30 +433,21 @@ export function NotificationRoutingSection() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Stacks <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
|
||||
<PatternChips
|
||||
ref={patternChipsRef}
|
||||
patterns={formStacks}
|
||||
onChange={setFormStacks}
|
||||
placeholder="Type a pattern (for example prod-*)"
|
||||
data-testid="route-pattern-chips"
|
||||
/>
|
||||
<Combobox
|
||||
options={availableStackOptions}
|
||||
value=""
|
||||
onValueChange={addStack}
|
||||
placeholder="Add a stack..."
|
||||
placeholder="Insert known stack name..."
|
||||
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">
|
||||
@@ -488,6 +510,34 @@ export function NotificationRoutingSection() {
|
||||
<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>Severity <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
|
||||
<Combobox
|
||||
options={availableLevelOptions}
|
||||
value=""
|
||||
onValueChange={addLevel}
|
||||
placeholder="Add a severity..."
|
||||
searchPlaceholder="Search..."
|
||||
emptyText="No levels left."
|
||||
/>
|
||||
{formLevels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{formLevels.map((l) => (
|
||||
<Badge key={l} variant="outline" className="text-xs gap-1 pr-1">
|
||||
{LEVEL_LABELS[l]}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeLevel(l)}
|
||||
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
|
||||
@@ -685,9 +735,18 @@ export function NotificationRoutingSection() {
|
||||
{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>
|
||||
)}
|
||||
{route.levels && route.levels.length > 0 && route.levels.map((l) => (
|
||||
<Badge key={l} variant="outline" className="text-[10px]">{LEVEL_LABELS[l]}</Badge>
|
||||
))}
|
||||
{route.stack_patterns.length === 0
|
||||
&& (!route.label_ids || route.label_ids.length === 0)
|
||||
&& (!route.categories || route.categories.length === 0)
|
||||
&& (!route.levels || route.levels.length === 0)
|
||||
&& (
|
||||
route.node_id === null
|
||||
? <span className="text-muted-foreground/50 text-[10px]">Matches all alerts</span>
|
||||
: <span className="text-muted-foreground/50 text-[10px]">Matches all alerts on this node</span>
|
||||
)}
|
||||
<span className="text-muted-foreground/50">|</span>
|
||||
<span className="font-mono truncate max-w-[200px]" title={route.channel_type === 'apprise' ? undefined : route.channel_url}>
|
||||
{route.channel_url}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -23,6 +23,7 @@ import { Plus, Trash2, Pencil, RefreshCw, X, BellOff } from 'lucide-react';
|
||||
import { SettingsCallout } from './SettingsCallout';
|
||||
import { SettingsPrimaryButton } from './SettingsActions';
|
||||
import { useMastheadStats } from './MastheadStatsContext';
|
||||
import { PatternChips, type PatternChipsHandle } from './PatternChips';
|
||||
|
||||
type NotificationLevel = 'info' | 'warning' | 'error';
|
||||
type AppliesTo = 'bell' | 'external' | 'both';
|
||||
@@ -118,6 +119,7 @@ export function NotificationSuppressionSection({
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formNodeId, setFormNodeId] = useState<number | null>(null);
|
||||
const [formStacks, setFormStacks] = useState<string[]>([]);
|
||||
const patternChipsRef = useRef<PatternChipsHandle>(null);
|
||||
const [formLabelIds, setFormLabelIds] = useState<number[]>([]);
|
||||
const [formCategories, setFormCategories] = useState<NotificationCategory[]>([]);
|
||||
const [formLevels, setFormLevels] = useState<NotificationLevel[]>([]);
|
||||
@@ -212,6 +214,11 @@ export function NotificationSuppressionSection({
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formName.trim()) { toast.error('Name is required.'); return; }
|
||||
const preparedPatterns = patternChipsRef.current?.prepareSave();
|
||||
if (!preparedPatterns?.ok) {
|
||||
toast.error('Fix invalid stack patterns before saving.');
|
||||
return;
|
||||
}
|
||||
const customMs = formCustomExpiry ? new Date(formCustomExpiry).getTime() : null;
|
||||
if (formExpirationPreset === 'custom' && (customMs == null || Number.isNaN(customMs))) {
|
||||
toast.error('Choose a valid custom expiration date.');
|
||||
@@ -223,7 +230,7 @@ export function NotificationSuppressionSection({
|
||||
const body = {
|
||||
name: formName.trim(),
|
||||
node_id: formNodeId,
|
||||
stack_patterns: formStacks,
|
||||
stack_patterns: preparedPatterns.patterns,
|
||||
label_ids: formLabelIds.length > 0 ? formLabelIds : null,
|
||||
categories: formCategories.length > 0 ? formCategories : null,
|
||||
levels: formLevels.length > 0 ? formLevels : null,
|
||||
@@ -297,7 +304,6 @@ export function NotificationSuppressionSection({
|
||||
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 (!Number.isNaN(id) && id > 0 && !formLabelIds.includes(id)) setFormLabelIds((prev) => [...prev, id]);
|
||||
@@ -387,17 +393,14 @@ export function NotificationSuppressionSection({
|
||||
|
||||
<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>
|
||||
)}
|
||||
<PatternChips
|
||||
ref={patternChipsRef}
|
||||
patterns={formStacks}
|
||||
onChange={setFormStacks}
|
||||
placeholder="Type a pattern (for example prod-*)"
|
||||
data-testid="mute-pattern-chips"
|
||||
/>
|
||||
<Combobox options={availableStackOptions} value="" onValueChange={addStack} placeholder="Insert known stack name..." searchPlaceholder="Search stacks..." emptyText="No stacks found." />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { forwardRef, useImperativeHandle, useState, type KeyboardEvent } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { X } from 'lucide-react';
|
||||
import { validateStackPatternClient } from './stackPatternClient';
|
||||
|
||||
export type PrepareSaveResult =
|
||||
| { ok: true; patterns: string[] }
|
||||
| { ok: false };
|
||||
|
||||
export interface PatternChipsHandle {
|
||||
/**
|
||||
* Commit pending text if any and return the validated pattern list to serialize.
|
||||
* Callers must use the returned `patterns` instead of a stale parent render.
|
||||
*/
|
||||
prepareSave: () => PrepareSaveResult;
|
||||
}
|
||||
|
||||
interface PatternChipsProps {
|
||||
patterns: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
placeholder?: string;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
function appendPattern(base: string[], raw: string): { ok: true; patterns: string[] } | { ok: false; error: string } {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { ok: true, patterns: base };
|
||||
const err = validateStackPatternClient(trimmed);
|
||||
if (err) return { ok: false, error: err };
|
||||
if (base.includes(trimmed)) return { ok: true, patterns: base };
|
||||
return { ok: true, patterns: [...base, trimmed] };
|
||||
}
|
||||
|
||||
export const PatternChips = forwardRef<PatternChipsHandle, PatternChipsProps>(function PatternChips(
|
||||
{ patterns, onChange, placeholder = 'Type a pattern and press Enter', 'data-testid': testId },
|
||||
ref,
|
||||
) {
|
||||
const [pending, setPending] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const commitAgainst = (base: string[], raw: string): string[] | null => {
|
||||
const result = appendPattern(base, raw);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return null;
|
||||
}
|
||||
setError(null);
|
||||
return result.patterns;
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
prepareSave: () => {
|
||||
let next = patterns;
|
||||
if (pending.trim()) {
|
||||
const committed = commitAgainst(patterns, pending);
|
||||
if (!committed) return { ok: false };
|
||||
next = committed;
|
||||
}
|
||||
for (const p of next) {
|
||||
const err = validateStackPatternClient(p);
|
||||
if (err) {
|
||||
setError(err);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
if (next !== patterns) onChange(next);
|
||||
setPending('');
|
||||
setError(null);
|
||||
return { ok: true, patterns: next };
|
||||
},
|
||||
}));
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
const next = commitAgainst(patterns, pending);
|
||||
if (!next) return;
|
||||
if (next !== patterns) onChange(next);
|
||||
setPending('');
|
||||
}
|
||||
};
|
||||
|
||||
const onChangePending = (value: string) => {
|
||||
if (value.includes(',')) {
|
||||
const parts = value.split(',');
|
||||
const last = parts.pop() ?? '';
|
||||
let next = patterns;
|
||||
for (const part of parts) {
|
||||
const committed = commitAgainst(next, part);
|
||||
if (!committed) return;
|
||||
next = committed;
|
||||
}
|
||||
if (next !== patterns) onChange(next);
|
||||
setPending(last);
|
||||
return;
|
||||
}
|
||||
setPending(value);
|
||||
if (error) setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5" data-testid={testId}>
|
||||
<Input
|
||||
value={pending}
|
||||
onChange={(e) => onChangePending(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
aria-invalid={error != null}
|
||||
/>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
{patterns.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{patterns.map((s) => (
|
||||
<Badge key={s} variant="secondary" className="font-mono text-xs gap-1 pr-1">
|
||||
{s}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(patterns.filter((p) => p !== s))}
|
||||
className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"
|
||||
aria-label={`Remove ${s}`}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use * as a wildcard (for example prod-*). Press Enter or comma to add.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -44,6 +44,7 @@ const APPRISE_ROUTE = {
|
||||
stack_patterns: ['app'],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
channel_type: 'apprise',
|
||||
channel_url: 'http://apprise.local/notify/<redacted>',
|
||||
config: {
|
||||
@@ -226,4 +227,159 @@ describe('NotificationRoutingSection', () => {
|
||||
expect(body).not.toHaveProperty('config');
|
||||
});
|
||||
|
||||
it('shows Error badge and not Matches all alerts for a severity-only route', async () => {
|
||||
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
|
||||
if (url === '/notification-routes' && !opts?.method) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [{
|
||||
...APPRISE_ROUTE,
|
||||
id: 7,
|
||||
name: 'Errors only',
|
||||
stack_patterns: [],
|
||||
levels: ['error'],
|
||||
channel_type: 'discord',
|
||||
channel_url: 'https://discord.com/api/webhooks/1/x',
|
||||
config: null,
|
||||
}],
|
||||
};
|
||||
}
|
||||
if (url === '/stacks') return { ok: true, json: async () => ['app'] };
|
||||
if (url === '/labels') return { ok: true, json: async () => [] };
|
||||
return { ok: true, json: async () => ([]) };
|
||||
});
|
||||
render(<NotificationRoutingSection />);
|
||||
await waitFor(() => expect(screen.getByText('Errors only')).toBeInTheDocument());
|
||||
expect(screen.getByText('Error')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Matches all alerts')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Matches all alerts on this node')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows node-scoped match-all for a node-only route', async () => {
|
||||
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
|
||||
if (url === '/notification-routes' && !opts?.method) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [{
|
||||
...APPRISE_ROUTE,
|
||||
id: 8,
|
||||
name: 'Local only',
|
||||
node_id: 1,
|
||||
stack_patterns: [],
|
||||
levels: null,
|
||||
channel_type: 'discord',
|
||||
channel_url: 'https://discord.com/api/webhooks/1/x',
|
||||
config: null,
|
||||
}],
|
||||
};
|
||||
}
|
||||
if (url === '/stacks') return { ok: true, json: async () => [] };
|
||||
if (url === '/labels') return { ok: true, json: async () => [] };
|
||||
return { ok: true, json: async () => ([]) };
|
||||
});
|
||||
render(<NotificationRoutingSection />);
|
||||
await waitFor(() => expect(screen.getByText('Local only')).toBeInTheDocument());
|
||||
expect(screen.getByText('Local')).toBeInTheDocument();
|
||||
expect(screen.getByText('Matches all alerts on this node')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Matches all alerts$/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('commits pattern chips and null severity into create JSON', async () => {
|
||||
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
|
||||
if (url === '/notification-routes' && !opts?.method) {
|
||||
return { ok: true, json: async () => [] };
|
||||
}
|
||||
if (url === '/stacks') return { ok: true, json: async () => ['known-stack'] };
|
||||
if (url === '/labels') return { ok: true, json: async () => [] };
|
||||
if (url === '/notification-routes' && opts?.method === 'POST') {
|
||||
return { ok: true, json: async () => ({ id: 99 }) };
|
||||
}
|
||||
return { ok: true, json: async () => ([]) };
|
||||
});
|
||||
render(<NotificationRoutingSection />);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /Add route/i })).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add route/i }));
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'prod-*{Enter}');
|
||||
const nameInput = screen.getByPlaceholderText(/Production alerts/i);
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, 'Chip route');
|
||||
await userEvent.type(screen.getByPlaceholderText(/discord/i), 'https://discord.com/api/webhooks/1/token');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
await waitFor(() => {
|
||||
const post = mockedFetch.mock.calls.find(
|
||||
([url, opts]) => url === '/notification-routes' && (opts as { method?: string })?.method === 'POST',
|
||||
);
|
||||
expect(post).toBeTruthy();
|
||||
const body = JSON.parse((post![1] as { body: string }).body);
|
||||
expect(body.stack_patterns).toEqual(['prod-*']);
|
||||
expect(body.levels).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('includes a pending pattern that was never committed with Enter', async () => {
|
||||
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
|
||||
if (url === '/notification-routes' && !opts?.method) {
|
||||
return { ok: true, json: async () => [] };
|
||||
}
|
||||
if (url === '/stacks') return { ok: true, json: async () => ['known-stack'] };
|
||||
if (url === '/labels') return { ok: true, json: async () => [] };
|
||||
if (url === '/notification-routes' && opts?.method === 'POST') {
|
||||
return { ok: true, json: async () => ({ id: 99 }) };
|
||||
}
|
||||
return { ok: true, json: async () => ([]) };
|
||||
});
|
||||
render(<NotificationRoutingSection />);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /Add route/i })).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add route/i }));
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'prod-*');
|
||||
const nameInput = screen.getByPlaceholderText(/Production alerts/i);
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, 'Pending route');
|
||||
await userEvent.type(screen.getByPlaceholderText(/discord/i), 'https://discord.com/api/webhooks/1/token');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
await waitFor(() => {
|
||||
const post = mockedFetch.mock.calls.find(
|
||||
([url, opts]) => url === '/notification-routes' && (opts as { method?: string })?.method === 'POST',
|
||||
);
|
||||
expect(post).toBeTruthy();
|
||||
const body = JSON.parse((post![1] as { body: string }).body);
|
||||
expect(body.stack_patterns).toEqual(['prod-*']);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks create when a stack pattern is invalid', async () => {
|
||||
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
|
||||
if (url === '/notification-routes' && !opts?.method) {
|
||||
return { ok: true, json: async () => [] };
|
||||
}
|
||||
if (url === '/stacks') return { ok: true, json: async () => ['known-stack'] };
|
||||
if (url === '/labels') return { ok: true, json: async () => [] };
|
||||
if (url === '/notification-routes' && opts?.method === 'POST') {
|
||||
return { ok: true, json: async () => ({ id: 99 }) };
|
||||
}
|
||||
return { ok: true, json: async () => ([]) };
|
||||
});
|
||||
render(<NotificationRoutingSection />);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /Add route/i })).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add route/i }));
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), '****');
|
||||
await userEvent.type(screen.getByPlaceholderText(/Production alerts/i), 'Bad');
|
||||
await userEvent.type(screen.getByPlaceholderText(/discord/i), 'https://discord.com/api/webhooks/1/token');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
await waitFor(() => {
|
||||
const posts = mockedFetch.mock.calls.filter(
|
||||
([url, opts]) => url === '/notification-routes' && (opts as { method?: string })?.method === 'POST',
|
||||
);
|
||||
expect(posts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* NotificationSuppressionSection stack pattern chips.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => ({
|
||||
nodes: [{ id: 1, type: 'local', name: 'Local' }],
|
||||
hasCapability: () => true,
|
||||
activeNode: { id: 1, type: 'local', name: 'Local' },
|
||||
activeNodeMeta: { version: '1.0.0' },
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/components/CapabilityGate', () => ({
|
||||
CapabilityGate: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock('../MastheadStatsContext', () => ({
|
||||
useMastheadStats: () => {},
|
||||
}));
|
||||
vi.mock('@/hooks/useMuteRulesRefresh', () => ({
|
||||
useMuteRulesRefresh: () => {},
|
||||
}));
|
||||
vi.mock('@/lib/muteRules', () => ({
|
||||
emitMuteRulesChanged: vi.fn(),
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { NotificationSuppressionSection } from '../NotificationSuppressionSection';
|
||||
|
||||
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
async function openMuteForm() {
|
||||
render(<NotificationSuppressionSection />);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /Add mute rule|Add rule/i })).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add mute rule|Add rule/i }));
|
||||
await waitFor(() => expect(screen.getByRole('dialog', { name: /New mute rule/i })).toBeInTheDocument());
|
||||
}
|
||||
|
||||
describe('NotificationSuppressionSection', () => {
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
|
||||
if (url === '/notification-suppression-rules' && !opts?.method) {
|
||||
return { ok: true, json: async () => [] };
|
||||
}
|
||||
if (url === '/stacks') return { ok: true, json: async () => ['staging'] };
|
||||
if (url === '/labels') return { ok: true, json: async () => [] };
|
||||
if (url === '/notification-suppression-rules' && opts?.method === 'POST') {
|
||||
return { ok: true, json: async () => ({ id: 1 }) };
|
||||
}
|
||||
return { ok: true, json: async () => ([]) };
|
||||
});
|
||||
});
|
||||
|
||||
it('posts normalized stack patterns and null levels', async () => {
|
||||
await openMuteForm();
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/Mute staging/i);
|
||||
await userEvent.type(nameInput, 'Mute prod');
|
||||
await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'prod-*,prod-*{Enter}');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Create|Update/i }));
|
||||
await waitFor(() => {
|
||||
const post = mockedFetch.mock.calls.find(
|
||||
([url, opts]) => url === '/notification-suppression-rules' && (opts as { method?: string })?.method === 'POST',
|
||||
);
|
||||
expect(post).toBeTruthy();
|
||||
const body = JSON.parse((post![1] as { body: string }).body);
|
||||
expect(body.stack_patterns).toEqual(['prod-*']);
|
||||
expect(body.levels).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('includes a pending pattern that was never committed with Enter', async () => {
|
||||
await openMuteForm();
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText(/Mute staging/i), 'Pending mute');
|
||||
await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'prod-*');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Create|Update/i }));
|
||||
await waitFor(() => {
|
||||
const post = mockedFetch.mock.calls.find(
|
||||
([url, opts]) => url === '/notification-suppression-rules' && (opts as { method?: string })?.method === 'POST',
|
||||
);
|
||||
expect(post).toBeTruthy();
|
||||
const body = JSON.parse((post![1] as { body: string }).body);
|
||||
expect(body.stack_patterns).toEqual(['prod-*']);
|
||||
});
|
||||
});
|
||||
|
||||
it('accumulates multi-pattern comma input into the create body', async () => {
|
||||
await openMuteForm();
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText(/Mute staging/i), 'Paste mute');
|
||||
await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'alpha-*,beta-*');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Create|Update/i }));
|
||||
await waitFor(() => {
|
||||
const post = mockedFetch.mock.calls.find(
|
||||
([url, opts]) => url === '/notification-suppression-rules' && (opts as { method?: string })?.method === 'POST',
|
||||
);
|
||||
expect(post).toBeTruthy();
|
||||
const body = JSON.parse((post![1] as { body: string }).body);
|
||||
expect(body.stack_patterns).toEqual(['alpha-*', 'beta-*']);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks create when a stack pattern is invalid', async () => {
|
||||
await openMuteForm();
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText(/Mute staging/i), 'Bad mute');
|
||||
await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), '****');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Create|Update/i }));
|
||||
await waitFor(() => {
|
||||
const posts = mockedFetch.mock.calls.filter(
|
||||
([url, opts]) => url === '/notification-suppression-rules' && (opts as { method?: string })?.method === 'POST',
|
||||
);
|
||||
expect(posts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Client mirror of backend validateStackPatternForRedos. */
|
||||
export function validateStackPatternClient(pattern: string): string | null {
|
||||
if (pattern.length > 200) return 'Pattern is too long (max 200 characters)';
|
||||
const stars = (pattern.match(/\*/g) ?? []).length;
|
||||
if (stars > 8) return 'Pattern has too many wildcards (max 8)';
|
||||
if (/\*{4,}/.test(pattern)) return 'Pattern must not contain 4+ consecutive wildcards';
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user