mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 07:36:40 +00:00
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:
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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 • Cooldown {alert.cooldown_mins}m
|
||||
{renderTargetLabel(alert)}
|
||||
{' '}• Trigger after {alert.duration_mins}m • 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>
|
||||
|
||||
Reference in New Issue
Block a user