feat: add service-scoped stack alert rules (#1681)

* feat: add service-scoped stack alert rules

Stack alerts can target one Compose service or all services. Breach timers
are per container and cooldowns are per service so a healthy sibling no
longer clears another container's timer or silences a different service.

* fix: gate remote scoped alert creates without losing the body

Remote hops skip JSON parsing so the proxy stream stays pipeable, which
left service_name invisible to the capability gate. Buffer POST /alerts
bodies for inspection, fail closed when the remote lacks the capability,
and rewrite the buffered bytes on forward. Restore alert-panel alt text
to match the unchanged screenshot.

* fix: bound remote alert body buffer and reject encoded JSON

Cap proxied POST /alerts buffering at the local 100KB JSON limit with
structured 413 cleanup, reject non-identity Content-Encoding with 415 so
compressed scoped bodies cannot bypass the mixed-version gate, and cover
oversized, chunked, and gzip regressions.

* fix: harden service-scoped alert delete, cooldown, and proxy gates

Reject non-digit alert ids, dual-write last_fired_at for rollback safety,
gate cooldown on persisted notification history, fail-fast oversized proxy
bodies with 413, and clarify Not in compose UI semantics.

* test: expect dispatchAlert persisted result in crash-safety cases

Update notification-routing assertions for the new { persisted } return
shape so CI matches the cooldown-gating contract.
This commit is contained in:
Anso
2026-07-23 17:57:04 -04:00
committed by GitHub
parent dd54a2e483
commit 85842cc547
32 changed files with 1736 additions and 135 deletions
@@ -0,0 +1,248 @@
/**
* StackAlertSheet Alerts tab: service targeting, services-state machine,
* capability gating, and active-node reset.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
const { nodeState } = vi.hoisted(() => ({
nodeState: {
activeNode: { id: 1, type: 'local', name: 'Local' } as { id: number; type: string; name: string } | null,
activeNodeMeta: {
version: '1.0.0',
capabilities: ['service-scoped-stack-alert'],
} as { version: string; capabilities: string[] } | null,
},
}));
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: () => ({
activeNode: nodeState.activeNode,
activeNodeMeta: nodeState.activeNodeMeta,
hasCapability: (cap: string) => nodeState.activeNodeMeta?.capabilities.includes(cap) === true,
}),
}));
vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({ isAdmin: true }),
}));
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { StackAlertSheet } from './StackAlertSheet';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
function jsonRes(body: unknown, ok = true, status = ok ? 200 : 500) {
return {
ok,
status,
json: async () => body,
text: async () => '',
} as unknown as Response;
}
beforeEach(() => {
nodeState.activeNode = { id: 1, type: 'local', name: 'Local' };
nodeState.activeNodeMeta = {
version: '1.0.0',
capabilities: ['service-scoped-stack-alert'],
};
mockedFetch.mockReset();
vi.mocked(toast.success).mockReset();
vi.mocked(toast.error).mockReset();
});
function mockHappyPath(services: string[] = ['api', 'database'], alerts: unknown[] = []) {
mockedFetch.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes('/agents')) {
return jsonRes([{ type: 'discord', enabled: true }]);
}
if (url.includes('/services')) {
return jsonRes(services);
}
if (url.startsWith('/alerts') && (!init || !init.method || init.method === 'GET')) {
return jsonRes(alerts);
}
if (url === '/alerts' && init?.method === 'POST') {
const body = JSON.parse(String(init.body));
return jsonRes({ id: 99, ...body }, true, 201);
}
return jsonRes(null, false);
});
}
describe('StackAlertSheet Alerts tab', () => {
it('POSTs selected service_name when capability is present', async () => {
mockHappyPath();
const user = userEvent.setup();
render(<StackAlertSheet open onOpenChange={() => {}} stackName="my-stack" />);
await waitFor(() => expect(screen.getByText('Add Rule')).toBeInTheDocument());
await waitFor(() => {
expect(mockedFetch.mock.calls.some(([url]) => String(url).includes('/services'))).toBe(true);
});
// Service combobox is the first one in the Add new rule form.
await waitFor(() => {
const serviceBox = screen.getAllByRole('combobox')[0];
expect(serviceBox).not.toBeDisabled();
});
await user.click(screen.getAllByRole('combobox')[0]);
await user.click(await screen.findByRole('button', { name: 'api' }));
fireEvent.change(screen.getByPlaceholderText('e.g. 90'), { target: { value: '80' } });
await user.click(screen.getByText('Add Rule'));
await waitFor(() => {
const post = mockedFetch.mock.calls.find(
([url, init]) => String(url) === '/alerts' && (init as RequestInit | undefined)?.method === 'POST',
);
expect(post).toBeDefined();
const body = JSON.parse(String((post![1] as RequestInit).body));
expect(body.service_name).toBe('api');
expect(body.stack_name).toBe('my-stack');
});
});
it('POSTs service_name null for All services', async () => {
mockHappyPath();
const user = userEvent.setup();
render(<StackAlertSheet open onOpenChange={() => {}} stackName="my-stack" />);
await waitFor(() => expect(screen.getByText('Add Rule')).toBeInTheDocument());
fireEvent.change(screen.getByPlaceholderText('e.g. 90'), { target: { value: '80' } });
await user.click(screen.getByText('Add Rule'));
await waitFor(() => {
const post = mockedFetch.mock.calls.find(
([url, init]) => String(url) === '/alerts' && (init as RequestInit | undefined)?.method === 'POST',
);
const body = JSON.parse(String((post![1] as RequestInit).body));
expect(body.service_name).toBeNull();
});
});
it('shows Not in compose only after a successful services list', async () => {
mockHappyPath(['database'], [{
id: 1,
stack_name: 'my-stack',
service_name: 'api',
metric: 'cpu_percent',
operator: '>',
threshold: 80,
duration_mins: 5,
cooldown_mins: 60,
}]);
render(<StackAlertSheet open onOpenChange={() => {}} stackName="my-stack" />);
await waitFor(() => {
expect(screen.getByText(/Not in compose/i)).toBeInTheDocument();
});
});
it('does not show Not in compose when services fetch fails', async () => {
mockedFetch.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/agents')) return jsonRes([{ type: 'discord', enabled: true }]);
if (url.includes('/services')) return jsonRes(null, false, 500);
if (url.includes('/alerts')) {
return jsonRes([{
id: 1,
stack_name: 'my-stack',
service_name: 'api',
metric: 'cpu_percent',
operator: '>',
threshold: 80,
duration_mins: 5,
cooldown_mins: 60,
}]);
}
return jsonRes(null, false);
});
render(<StackAlertSheet open onOpenChange={() => {}} stackName="my-stack" />);
await waitFor(() => expect(screen.getByText('api')).toBeInTheDocument());
expect(screen.queryByText(/Not in compose/i)).not.toBeInTheDocument();
});
it('hides service selector when capability is missing and posts null', async () => {
nodeState.activeNodeMeta = { version: '0.90.0', capabilities: [] };
mockHappyPath();
const user = userEvent.setup();
render(<StackAlertSheet open onOpenChange={() => {}} stackName="my-stack" />);
await waitFor(() => expect(screen.getByText('Add Rule')).toBeInTheDocument());
expect(screen.queryByText(/does not support service-scoped/i)).toBeInTheDocument();
expect(screen.queryByRole('combobox', { name: /all services/i })).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('e.g. 90'), { target: { value: '80' } });
await user.click(screen.getByText('Add Rule'));
await waitFor(() => {
const post = mockedFetch.mock.calls.find(
([url, init]) => String(url) === '/alerts' && (init as RequestInit | undefined)?.method === 'POST',
);
const body = JSON.parse(String((post![1] as RequestInit).body));
expect(body.service_name).toBeNull();
});
});
it('resets services state when active node changes', async () => {
let servicesCalls = 0;
mockedFetch.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/agents')) return jsonRes([{ type: 'discord', enabled: true }]);
if (url.includes('/services')) {
servicesCalls += 1;
return jsonRes(servicesCalls === 1 ? ['api'] : ['worker']);
}
if (url.includes('/alerts')) {
return jsonRes([{
id: 1,
stack_name: 'my-stack',
service_name: 'api',
metric: 'cpu_percent',
operator: '>',
threshold: 80,
duration_mins: 5,
cooldown_mins: 60,
}]);
}
return jsonRes(null, false);
});
const { rerender } = render(
<StackAlertSheet open onOpenChange={() => {}} stackName="my-stack" />,
);
await waitFor(() => expect(servicesCalls).toBe(1));
await waitFor(() => expect(screen.queryByText(/Not in compose/i)).not.toBeInTheDocument());
nodeState.activeNode = { id: 2, type: 'remote', name: 'Remote' };
nodeState.activeNodeMeta = {
version: '1.0.0',
capabilities: ['service-scoped-stack-alert'],
};
rerender(<StackAlertSheet open onOpenChange={() => {}} stackName="my-stack" />);
await waitFor(() => expect(servicesCalls).toBe(2));
// New node's list has only worker, so the api-targeted rule is missing.
await waitFor(() => expect(screen.getByText(/Not in compose/i)).toBeInTheDocument());
});
});
+94 -3
View File
@@ -20,12 +20,14 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { Trash2, HelpCircle, AlertTriangle, Info, CheckCircle2, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { SERVICE_SCOPED_STACK_ALERT_CAPABILITY } from '@/lib/capabilities';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
interface StackAlert {
id?: number;
stack_name: string;
service_name: string | null;
metric: string;
operator: string;
threshold: number;
@@ -33,6 +35,11 @@ interface StackAlert {
cooldown_mins: number;
}
type ServicesState =
| { status: 'idle' | 'loading' }
| { status: 'success'; options: string[] }
| { status: 'error' };
interface AutoHealPolicy {
id?: number;
node_id: number;
@@ -161,8 +168,9 @@ export function StackAlertSheet({ open, onOpenChange, stackName, initialTab = 'a
function AlertsTab({ stackName }: { stackName: string }) {
const { isAdmin } = useAuth();
const { activeNode } = useNodes();
const { activeNode, activeNodeMeta } = useNodes();
const isRemote = activeNode?.type === 'remote';
const canScopeService = activeNodeMeta?.capabilities.includes(SERVICE_SCOPED_STACK_ALERT_CAPABILITY) === true;
const [alerts, setAlerts] = useState<StackAlert[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -172,7 +180,9 @@ function AlertsTab({ stackName }: { stackName: string }) {
hasEnabled: false,
enabledTypes: [],
});
const [servicesState, setServicesState] = useState<ServicesState>({ status: 'idle' });
const [service, setService] = useState('');
const [metric, setMetric] = useState('cpu_percent');
const [operator, setOperator] = useState('>');
const [threshold, setThreshold] = useState('');
@@ -183,7 +193,33 @@ function AlertsTab({ stackName }: { stackName: string }) {
if (!stackName) return;
fetchAlerts();
fetchAgentStatus();
}, [stackName]); // eslint-disable-line react-hooks/exhaustive-deps
}, [stackName, activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!stackName || !canScopeService) {
setServicesState({ status: 'idle' });
setService('');
return;
}
let cancelled = false;
setServicesState({ status: 'loading' });
setService('');
void (async () => {
try {
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/services`);
if (!res.ok) throw new Error(`services ${res.status}`);
const names = await res.json() as string[];
if (!cancelled) setServicesState({ status: 'success', options: names });
} catch (e) {
console.error('[StackAlertSheet] Failed to fetch stack services', e);
if (!cancelled) setServicesState({ status: 'error' });
}
})();
return () => { cancelled = true; };
}, [stackName, activeNode?.id, canScopeService]);
const fetchAlerts = async () => {
try {
@@ -224,8 +260,10 @@ function AlertsTab({ stackName }: { stackName: string }) {
return;
}
setIsLoading(true);
const scopedServiceName = canScopeService && service !== '' ? service : null;
const newAlert = {
stack_name: stackName,
service_name: scopedServiceName,
metric,
operator,
threshold: parseFloat(threshold),
@@ -240,6 +278,7 @@ function AlertsTab({ stackName }: { stackName: string }) {
if (res.ok) {
toast.success('Alert rule added.');
setThreshold('');
setService('');
fetchAlerts();
} else {
const err = await res.json().catch(() => ({}));
@@ -272,6 +311,32 @@ function AlertsTab({ stackName }: { stackName: string }) {
}
};
const serviceComboOptions = [
{ value: '', label: 'All services' },
...(servicesState.status === 'success'
? servicesState.options.map(n => ({ value: n, label: n }))
: []),
];
const renderTargetLabel = (alert: StackAlert) => {
if (!alert.service_name) {
return <span className="text-muted-foreground font-sans">All services</span>;
}
const missing = servicesState.status === 'success'
&& !servicesState.options.includes(alert.service_name);
if (missing) {
return (
<span
className="text-warning font-sans"
title="This name is not in the compose file. The rule still evaluates running containers with this Compose service label."
>
{alert.service_name} <span className="font-medium">(Not in compose)</span>
</span>
);
}
return <span className="font-sans">{alert.service_name}</span>;
};
const renderAgentStatusBanner = () => {
if (agentStatus.loading) {
return (
@@ -355,7 +420,8 @@ function AlertsTab({ stackName }: { stackName: string }) {
{metricLabels[alert.metric] || alert.metric} {alert.operator} {alert.threshold}
</span>
<div className="text-muted-foreground text-xs mt-0.5">
Trigger after {alert.duration_mins}m &bull; Cooldown {alert.cooldown_mins}m
{renderTargetLabel(alert)}
{' '}&bull; Trigger after {alert.duration_mins}m &bull; Cooldown {alert.cooldown_mins}m
</div>
</div>
{isAdmin && (
@@ -378,6 +444,31 @@ function AlertsTab({ stackName }: { stackName: string }) {
{isAdmin && (
<SheetSection title="Add new rule">
<div className="space-y-3">
{canScopeService && (
<div className="space-y-2">
<Label>Service</Label>
<Combobox
options={serviceComboOptions}
value={service}
onValueChange={setService}
placeholder="All services"
searchPlaceholder="Search services..."
emptyText={servicesState.status === 'error' ? 'Could not load services.' : 'No services found.'}
disabled={servicesState.status === 'loading' || servicesState.status === 'error'}
/>
{servicesState.status === 'error' && (
<p className="text-xs text-muted-foreground">
Could not load Compose services. The rule will target all services.
</p>
)}
</div>
)}
{!canScopeService && activeNodeMeta && (
<p className="text-xs text-muted-foreground">
This node does not support service-scoped alert rules yet. Update the node to target a specific service.
</p>
)}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label>Metric</Label>