feat: add Apprise as a fourth notification channel (#1644)

* feat: add Apprise as a fourth notification channel

Support keyed and stateless Apprise endpoints with secret-safe public DTOs, fail-closed malformed config, and mode-specific Settings UI. Docs and screenshots updated for four-channel Channels and routing.

* fix: harden Apprise secrets at rest and preserve-on-write saves

Encrypt Apprise endpoint and config with CryptoService so a downgrade cannot leak via SELECT *. Align channel and routing saves so blank destination fields omit config on same-mode URL edits, enforce keyed notify IDs, and keep secrets_redacted truthful.

* fix: harden Apprise route type changes and mixed-version config UI

Require a raw channel_url when switching notification-route types so ciphertext cannot strand under Discord/Slack/webhook. Default missing remote apprise status, replace Channels state on node switch, and exercise the production config-column migrator.

* fix: tolerate stub fleet configuration payloads without agents

Normalize remote Apprise agent status only when notifications.agents is present so successful Pilot/stub fetches stay online instead of throwing into the offline catch path.

* fix: correct TypeScript in configuration normalize tests

* fix: ignore stale Channels agent bodies after node switch

Compare the active node after response JSON parsing so a slow body
cannot overwrite the newly selected node's channel state.

* fix: isolate corrupt Apprise crypto and keep keyed Tags visible

Decrypt failures on one Apprise row no longer 500 agent/route lists or
suppress sibling channel dispatch. Treat public /notify/<redacted> as keyed
so Tags remain editable after reload.
This commit is contained in:
Anso
2026-07-18 16:32:58 -04:00
committed by GitHub
parent 674220b9de
commit 83b3d932e5
42 changed files with 2916 additions and 136 deletions
+2 -1
View File
@@ -102,6 +102,7 @@ const agentTypeLabels: Record<string, string> = {
discord: 'Discord',
slack: 'Slack',
webhook: 'Webhook',
apprise: 'Apprise',
};
const clampNonNegative = (setter: (v: string) => void) => (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -314,7 +315,7 @@ function AlertsTab({ stackName }: { stackName: string }) {
<div>
<p className="font-medium text-warning">No notification channels configured</p>
<p className="text-muted-foreground mt-0.5">
Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, or a webhook in{' '}
Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, Apprise, or a webhook in{' '}
<span className="font-medium">Settings &rarr; Notifications</span>.
</p>
</div>
@@ -1,6 +1,7 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Bell, Zap, Shield, HardDrive, ChevronRight } from 'lucide-react';
import { formatCount } from '@/lib/utils';
import { normalizeConfigurationAgents } from '@/lib/configurationStatus';
import { useConfigurationStatus } from './useConfigurationStatus';
import type { SectionId } from '@/components/settings/types';
import { SENCHO_NAVIGATE_EVENT, type SenchoNavigateDetail } from '@/components/NodeManager';
@@ -115,11 +116,12 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
const { notifications, automation, security, thresholds, backup } = status;
const agentSummary = (() => {
const { discord, slack, webhook } = notifications.agents;
const { discord, slack, webhook, apprise } = normalizeConfigurationAgents(notifications.agents);
const active = [
discord.enabled ? 'Discord' : null,
slack.enabled ? 'Slack' : null,
webhook.enabled ? 'Webhook' : null,
apprise.enabled ? 'Apprise' : null,
].filter(Boolean);
return active.length === 0 ? 'None' : active.join(', ');
})();
@@ -17,6 +17,7 @@ function makePayload(overrides: Partial<ConfigurationStatusPayload> = {}): Confi
discord: { configured: false, enabled: false },
slack: { configured: false, enabled: false },
webhook: { configured: false, enabled: false },
apprise: { configured: false, enabled: false },
},
alertRules: 0,
routingRules: { count: 0, enabledCount: 0, locked: true },
@@ -88,6 +89,7 @@ describe('ConfigurationStatus row visibility', () => {
discord: { configured: false, enabled: false },
slack: { configured: false, enabled: false },
webhook: { configured: false, enabled: false },
apprise: { configured: false, enabled: false },
},
alertRules: 2,
routingRules: { count: 1, enabledCount: 1, locked: false },
@@ -167,3 +169,33 @@ describe('ConfigurationStatus click targets', () => {
expect(onOpenSection).toHaveBeenCalledWith('container-alerts');
});
});
describe('ConfigurationStatus legacy remote agents', () => {
it('renders a three-channel payload that omits apprise without throwing', () => {
const legacy = makePayload();
// Simulate older remote contract: no apprise key on the wire.
delete (legacy.notifications.agents as { apprise?: unknown }).apprise;
useConfigurationStatusMock.mockReturnValue({ status: legacy, loading: false });
expect(() => render(<ConfigurationStatus />)).not.toThrow();
const channelsRow = screen.getByText('Channels').closest('button');
expect(channelsRow?.textContent).toContain('None');
});
it('summarizes enabled legacy channels without requiring apprise', () => {
const legacy = makePayload({
notifications: {
agents: {
discord: { configured: true, enabled: true },
slack: { configured: false, enabled: false },
webhook: { configured: false, enabled: false },
} as ConfigurationStatusPayload['notifications']['agents'],
alertRules: 0,
routingRules: { count: 0, enabledCount: 0, locked: true },
suppressionRules: { total: 0, enabledCount: 0 },
},
});
useConfigurationStatusMock.mockReturnValue({ status: legacy, loading: false });
render(<ConfigurationStatus />);
expect(screen.getByText('Discord')).toBeDefined();
});
});
@@ -2,20 +2,16 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { useNodes } from '@/context/NodeContext';
import { apiFetch } from '@/lib/api';
import { visibilityInterval } from '@/lib/utils';
import { normalizeConfigurationAgents, type AgentStatus } from '@/lib/configurationStatus';
// Trailing-edge debounce window for filtered settings-event refetches,
// matching the precedent in useNextAutoUpdateRun.
const INVALIDATE_DEBOUNCE_MS = 250;
interface AgentStatus {
configured: boolean;
enabled: boolean;
}
export interface ConfigurationStatus {
tier: 'community' | 'paid';
notifications: {
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus };
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus; apprise: AgentStatus };
alertRules: number;
routingRules: { count: number; enabledCount: number; locked: boolean };
suppressionRules: { total: number; enabledCount: number };
@@ -48,6 +44,28 @@ export interface ConfigurationStatus {
};
}
/** Wire payload may omit `apprise` from older remotes. */
type WireConfigurationStatus = Omit<ConfigurationStatus, 'notifications'> & {
notifications: Omit<ConfigurationStatus['notifications'], 'agents'> & {
agents: {
discord: AgentStatus;
slack: AgentStatus;
webhook: AgentStatus;
apprise?: AgentStatus;
};
};
};
function normalizeConfigurationStatus(raw: WireConfigurationStatus): ConfigurationStatus {
return {
...raw,
notifications: {
...raw.notifications,
agents: normalizeConfigurationAgents(raw.notifications.agents),
},
};
}
export function useConfigurationStatus() {
const { activeNode } = useNodes();
const nodeId = activeNode?.id;
@@ -61,8 +79,8 @@ export function useConfigurationStatus() {
try {
const res = await apiFetch('/dashboard/configuration');
if (!res.ok) return;
const data = await res.json() as ConfigurationStatus;
setStatus(data);
const data = await res.json() as WireConfigurationStatus;
setStatus(normalizeConfigurationStatus(data));
} catch {
// Silent; stale data stays visible
} finally {
@@ -10,6 +10,7 @@ import {
import { useFleetSyncStatus } from '@/hooks/useFleetSyncStatus';
import { STICKY_CONTROL_IDENTITY_MISMATCH, type FleetSyncStatus } from '@/lib/fleetSyncApi';
import type { ConfigurationStatusPayload } from '@/components/dashboard';
import { normalizeConfigurationAgents } from '@/lib/configurationStatus';
interface FleetNodeConfiguration {
id: number;
@@ -115,11 +116,13 @@ function NodeCard({ node, policySyncState }: {
}
const { notifications, automation, security, backup, thresholds } = node.configuration;
const agents = normalizeConfigurationAgents(notifications.agents);
const agentCount = [
notifications.agents.discord.enabled,
notifications.agents.slack.enabled,
notifications.agents.webhook.enabled,
agents.discord.enabled,
agents.slack.enabled,
agents.webhook.enabled,
agents.apprise.enabled,
].filter(Boolean).length;
return (
@@ -23,6 +23,7 @@ import { Plus, Trash2, Pencil, RefreshCw, Zap, X, Route } from 'lucide-react';
import { SettingsCallout } from './SettingsCallout';
import { SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { classifyAppriseEndpoint, isStatelessAppriseEndpoint } from '@/lib/appriseEndpoint';
interface NotificationRoute {
id: number;
@@ -31,8 +32,9 @@ interface NotificationRoute {
stack_patterns: string[];
label_ids: number[] | null;
categories: NotificationCategory[] | null;
channel_type: 'discord' | 'slack' | 'webhook';
channel_type: 'discord' | 'slack' | 'webhook' | 'apprise';
channel_url: string;
config: { mode: 'keyed' | 'stateless'; tags?: string; has_urls: boolean; providers?: string[]; url_count?: number } | null;
priority: number;
enabled: boolean;
created_at: number;
@@ -43,12 +45,14 @@ const CHANNEL_LABELS: Record<string, string> = {
discord: 'Discord',
slack: 'Slack',
webhook: 'Webhook',
apprise: 'Apprise',
};
const CHANNEL_PLACEHOLDERS: Record<string, string> = {
discord: 'https://discord.com/api/webhooks/...',
slack: 'https://hooks.slack.com/services/...',
webhook: 'https://example.com/webhook',
apprise: 'http://apprise.local/notify',
};
export function NotificationRoutingSection() {
@@ -70,8 +74,15 @@ export function NotificationRoutingSection() {
const [formStacks, setFormStacks] = useState<string[]>([]);
const [formLabelIds, setFormLabelIds] = useState<number[]>([]);
const [formCategories, setFormCategories] = useState<NotificationCategory[]>([]);
const [formChannelType, setFormChannelType] = useState<'discord' | 'slack' | 'webhook'>('discord');
const [formChannelType, setFormChannelType] = useState<'discord' | 'slack' | 'webhook' | 'apprise'>('discord');
const [formChannelUrl, setFormChannelUrl] = useState('');
const [formAppriseUrls, setFormAppriseUrls] = useState('');
const [formAppriseTags, setFormAppriseTags] = useState('');
const [appriseEndpointDirty, setAppriseEndpointDirty] = useState(false);
const [appriseConfigDirty, setAppriseConfigDirty] = useState(false);
/** Original Apprise mode when editing; drives preserve hints and forces a config write on mode switch. */
const [editAppriseOriginalMode, setEditAppriseOriginalMode] = useState<'keyed' | 'stateless' | null>(null);
const [editOriginalChannelType, setEditOriginalChannelType] = useState<'discord' | 'slack' | 'webhook' | 'apprise' | null>(null);
const [formPriority, setFormPriority] = useState(0);
const [formEnabled, setFormEnabled] = useState(true);
@@ -123,6 +134,12 @@ export function NotificationRoutingSection() {
setFormCategories([]);
setFormChannelType('discord');
setFormChannelUrl('');
setFormAppriseUrls('');
setFormAppriseTags('');
setAppriseEndpointDirty(false);
setAppriseConfigDirty(false);
setEditAppriseOriginalMode(null);
setEditOriginalChannelType(null);
setFormPriority(0);
setFormEnabled(true);
setEditingId(null);
@@ -138,6 +155,14 @@ export function NotificationRoutingSection() {
setFormCategories(route.categories ? [...route.categories] : []);
setFormChannelType(route.channel_type);
setFormChannelUrl(route.channel_url);
setFormAppriseTags(route.config?.tags ?? '');
setFormAppriseUrls('');
setAppriseEndpointDirty(false);
setAppriseConfigDirty(false);
setEditAppriseOriginalMode(
route.channel_type === 'apprise' ? (route.config?.mode ?? null) : null,
);
setEditOriginalChannelType(route.channel_type);
setFormPriority(route.priority);
setFormEnabled(route.enabled);
setShowForm(true);
@@ -145,8 +170,32 @@ export function NotificationRoutingSection() {
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.');
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;
}
const channelTypeChanged = Boolean(
editingId
&& editOriginalChannelType
&& formChannelType !== editOriginalChannelType,
);
const appriseMode = formChannelType === 'apprise' ? classifyAppriseEndpoint(formChannelUrl) : null;
const appriseModeChanged = Boolean(
editingId
&& editAppriseOriginalMode
&& appriseMode
&& appriseMode !== editAppriseOriginalMode,
);
// Mode switch, type switch, or dirty tags/URLs need config; endpoint-only edits omit it so blank fields preserve destinations.
const needsAppriseConfig = formChannelType === 'apprise'
&& (!editingId || appriseConfigDirty || appriseModeChanged || channelTypeChanged);
if (
appriseMode === 'stateless'
&& !formAppriseUrls.trim()
&& (!editingId || appriseModeChanged || appriseConfigDirty || channelTypeChanged)
) {
toast.error('Destination URLs are required for a stateless Apprise endpoint.');
return;
}
@@ -159,7 +208,16 @@ export function NotificationRoutingSection() {
label_ids: formLabelIds.length > 0 ? formLabelIds : null,
categories: formCategories.length > 0 ? formCategories : null,
channel_type: formChannelType,
channel_url: formChannelUrl.trim(),
...(formChannelType !== 'apprise' || !editingId || appriseEndpointDirty || channelTypeChanged
? { channel_url: formChannelUrl.trim() }
: {}),
...(needsAppriseConfig
? {
config: appriseMode === 'stateless'
? { urls: formAppriseUrls }
: { tags: formAppriseTags },
}
: {}),
priority: formPriority,
enabled: formEnabled,
};
@@ -287,6 +345,7 @@ export function NotificationRoutingSection() {
],
);
const isAppriseStateless = isStatelessAppriseEndpoint(formChannelUrl);
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 })),
@@ -431,8 +490,22 @@ export function NotificationRoutingSection() {
<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">
<Tabs
value={formChannelType}
onValueChange={(v) => {
const next = v as 'discord' | 'slack' | 'webhook' | 'apprise';
if (next !== formChannelType) {
// Type change replaces credentials; never carry a redacted prior URL across types.
setFormChannelUrl('');
setFormAppriseTags('');
setFormAppriseUrls('');
setAppriseEndpointDirty(true);
setAppriseConfigDirty(true);
}
setFormChannelType(next);
}}
>
<TabsList className="w-full grid grid-cols-4">
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
<TabsHighlightItem value="discord">
<TabsTrigger value="discord">Discord</TabsTrigger>
@@ -443,14 +516,36 @@ export function NotificationRoutingSection() {
<TabsHighlightItem value="webhook">
<TabsTrigger value="webhook">Webhook</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="apprise">
<TabsTrigger value="apprise">Apprise</TabsTrigger>
</TabsHighlightItem>
</TabsHighlight>
</TabsList>
</Tabs>
<Input
placeholder={CHANNEL_PLACEHOLDERS[formChannelType]}
value={formChannelUrl}
onChange={e => setFormChannelUrl(e.target.value)}
onChange={e => {
setFormChannelUrl(e.target.value);
if (formChannelType === 'apprise') setAppriseEndpointDirty(true);
}}
/>
{formChannelType === 'apprise' && (
<>
<Input
placeholder={isAppriseStateless ? 'Destination URLs, required for stateless mode' : 'Optional tags for keyed mode'}
value={isAppriseStateless ? formAppriseUrls : formAppriseTags}
onChange={e => {
if (isAppriseStateless) setFormAppriseUrls(e.target.value);
else setFormAppriseTags(e.target.value);
setAppriseConfigDirty(true);
}}
/>
{editingId !== null && isAppriseStateless && editAppriseOriginalMode === 'stateless' && !formAppriseUrls && (
<p className="text-xs text-stat-subtitle">Leave destination URLs blank to preserve the configured destinations.</p>
)}
</>
)}
</div>
<div className="grid grid-cols-2 gap-4">
@@ -594,7 +689,7 @@ export function NotificationRoutingSection() {
<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}>
<span className="font-mono truncate max-w-[200px]" title={route.channel_type === 'apprise' ? undefined : route.channel_url}>
{route.channel_url}
</span>
{route.priority !== 0 && (
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
import { springs } from '@/lib/motion';
import { Button } from '@/components/ui/button';
@@ -13,42 +13,78 @@ import { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { classifyAppriseEndpoint, isKeyedAppriseEndpoint, isStatelessAppriseEndpoint } from '@/lib/appriseEndpoint';
export function NotificationsSection() {
const { activeNode } = useNodes();
type ChannelType = 'discord' | 'slack' | 'webhook' | 'apprise';
const [notifTab, setNotifTab] = useState<'discord' | 'slack' | 'webhook'>('discord');
const [agents, setAgents] = useState<Record<string, Agent>>({
function emptyAgents(): Record<ChannelType, Agent> {
return {
discord: { type: 'discord', url: '', enabled: false },
slack: { type: 'slack', url: '', enabled: false },
webhook: { type: 'webhook', url: '', enabled: false },
});
apprise: { type: 'apprise', url: '', enabled: false, config: null },
};
}
function appriseWriteConfig(agent: Agent): { urls: string } | { tags: string } {
if (isStatelessAppriseEndpoint(agent.url)) {
return { urls: agent.config?.urls ?? '' };
}
return { tags: agent.config?.tags ?? '' };
}
function hasStoredAppriseAgent(agent: Agent): boolean {
// Stateless public URLs are not redacted; treat a public mode summary (or keyed redaction) as stored.
return Boolean(agent.config?.mode) || agent.url.includes('<redacted>');
}
export function NotificationsSection() {
const { activeNode } = useNodes();
const activeNodeIdRef = useRef(activeNode?.id);
useEffect(() => { activeNodeIdRef.current = activeNode?.id; }, [activeNode?.id]);
const [notifTab, setNotifTab] = useState<ChannelType>('discord');
const [agents, setAgents] = useState<Record<string, Agent>>(emptyAgents);
const [isSavingAgent, setIsSavingAgent] = useState<Record<string, boolean>>({});
const [isTestingAgent, setIsTestingAgent] = useState<Record<string, boolean>>({});
const [appriseUrlDirty, setAppriseUrlDirty] = useState(false);
const [appriseConfigDirty, setAppriseConfigDirty] = useState(false);
const fetchAgents = async () => {
const requestNodeId = activeNode?.id;
try {
const res = await apiFetch('/agents');
if (res.ok) {
const data: Agent[] = await res.json();
setAgents(prev => {
const next = { ...prev };
data.forEach(a => { next[a.type] = a; });
return next;
});
}
const res = await apiFetch('/agents', {
// Bind the request to the node captured when this fetch started.
nodeId: typeof requestNodeId === 'number' ? requestNodeId : undefined,
});
if (!res.ok) return;
const data: Agent[] = await res.json();
// Compare after body parse so a slow json() cannot commit after a node switch.
if (activeNodeIdRef.current !== requestNodeId) return;
const next = emptyAgents();
data.forEach(a => {
if (a.type in next) next[a.type as ChannelType] = a;
});
setAgents(next);
setAppriseUrlDirty(false);
setAppriseConfigDirty(false);
} catch (e) {
console.error('Failed to fetch agents', e);
}
};
useEffect(() => { fetchAgents(); }, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
setAgents(emptyAgents());
setAppriseUrlDirty(false);
setAppriseConfigDirty(false);
void fetchAgents();
}, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const enabledCount = Object.values(agents).filter(a => a.enabled).length;
useMastheadStats([
{
label: 'CHANNELS',
value: `${enabledCount}/3`,
value: `${enabledCount}/4`,
tone: enabledCount > 0 ? 'value' : 'subtitle',
},
]);
@@ -60,15 +96,47 @@ export function NotificationsSection() {
}));
};
const handleAppriseConfigPatch = (patch: { urls?: string; tags?: string }) => {
setAppriseConfigDirty(true);
setAgents(prev => ({
...prev,
apprise: { ...prev.apprise, config: { ...prev.apprise.config, ...patch } },
}));
};
const saveAgent = async (type: string) => {
setIsSavingAgent(prev => ({ ...prev, [type]: true }));
try {
const agent = agents[type];
const body: Record<string, unknown> = {
type: agent.type,
enabled: agent.enabled,
};
if (type !== 'apprise') {
body.url = agent.url;
} else {
const stored = hasStoredAppriseAgent(agent);
// Omit url/config on clean saves (including enable toggles) so preserve-on-write keeps destinations.
// URL-only edits omit config so blank destination fields do not wipe stored URLs.
if (appriseUrlDirty || !stored) {
body.url = agent.url.trim();
}
const storedMode = agent.config?.mode ?? null;
const nextMode = classifyAppriseEndpoint(agent.url);
const modeChanged = Boolean(stored && storedMode && nextMode && storedMode !== nextMode);
if (appriseConfigDirty || !stored || modeChanged) {
body.config = appriseWriteConfig(agent);
}
}
const res = await apiFetch('/agents', {
method: 'POST',
body: JSON.stringify(agents[type]),
body: JSON.stringify(body),
});
if (res.ok) {
toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} settings saved.`);
await fetchAgents();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Something went wrong.');
@@ -80,16 +148,33 @@ export function NotificationsSection() {
}
};
const appriseCanTest = (() => {
const agent = agents.apprise;
if (!agent.url.trim() || agent.url.includes('<redacted>')) return false;
if (isStatelessAppriseEndpoint(agent.url)) {
return Boolean(agent.config?.urls?.trim());
}
return true;
})();
const testAgent = async (type: string) => {
if (type === 'apprise' && !appriseCanTest) {
toast.error('Enter a raw Apprise endpoint (and destination URLs for /notify) before testing.');
return;
}
if (!agents[type].url) {
toast.error('Please enter a webhook URL first.');
return;
}
setIsTestingAgent(prev => ({ ...prev, [type]: true }));
try {
const body: Record<string, unknown> = { type, url: agents[type].url };
if (type === 'apprise') {
body.config = appriseWriteConfig(agents.apprise);
}
const res = await apiFetch('/notifications/test', {
method: 'POST',
body: JSON.stringify({ type, url: agents[type].url }),
body: JSON.stringify(body),
});
if (res.ok) {
toast.success('Test notification sent!');
@@ -104,7 +189,7 @@ export function NotificationsSection() {
}
};
const renderAgentTab = (type: 'discord' | 'slack' | 'webhook', title: string) => (
const renderAgentTab = (type: ChannelType, title: string) => (
<SettingsSection title={title} kicker={agents[type].enabled ? 'enabled' : 'off'}>
<SettingsField
label="Enabled"
@@ -117,19 +202,57 @@ export function NotificationsSection() {
/>
</SettingsField>
<SettingsField
label="Webhook URL"
helper="Sencho posts JSON payloads here. Use a private channel."
label={type === 'apprise' ? 'Apprise endpoint' : 'Webhook URL'}
helper={type === 'apprise' ? 'Use /notify/{key} for keyed delivery or /notify with destination URLs below.' : 'Sencho posts JSON payloads here. Use a private channel.'}
htmlFor={`${type}-url`}
>
<Input
id={`${type}-url`}
placeholder="https://..."
placeholder={type === 'apprise' ? 'http://apprise.local/notify' : 'https://...'}
value={agents[type].url}
onChange={(e) => handleAgentChange(type, 'url', e.target.value)}
onChange={(e) => {
if (type === 'apprise') setAppriseUrlDirty(true);
handleAgentChange(type, 'url', e.target.value);
}}
/>
</SettingsField>
{type === 'apprise' && isStatelessAppriseEndpoint(agents.apprise.url) && (
<SettingsField
label="Destination URLs"
helper="Required for a /notify endpoint. Separate Apprise URLs with commas or whitespace. Leave blank when editing to keep stored destinations."
htmlFor="apprise-urls"
>
<Input
id="apprise-urls"
placeholder={agents.apprise.config?.has_urls ? 'Configured destinations are preserved until replaced.' : 'discord://...'}
value={agents.apprise.config?.urls ?? ''}
onChange={(e) => handleAppriseConfigPatch({ urls: e.target.value })}
/>
</SettingsField>
)}
{type === 'apprise' && isKeyedAppriseEndpoint(agents.apprise.url) && (
<SettingsField label="Tags" helper="Optional tags for a keyed /notify/{key} endpoint." htmlFor="apprise-tags">
<Input
id="apprise-tags"
value={agents.apprise.config?.tags ?? ''}
onChange={(e) => handleAppriseConfigPatch({ tags: e.target.value })}
/>
</SettingsField>
)}
{type === 'apprise' && agents.apprise.url.trim() && !isKeyedAppriseEndpoint(agents.apprise.url) && !isStatelessAppriseEndpoint(agents.apprise.url) && (
<p className="text-xs text-stat-subtitle">
Enter an endpoint ending in /notify or /notify/&#123;key&#125; to configure Apprise.
</p>
)}
<SettingsActions>
<Button variant="outline" onClick={() => testAgent(type)} disabled={isTestingAgent[type]}>
<Button
variant="outline"
onClick={() => testAgent(type)}
disabled={isTestingAgent[type] || (type === 'apprise' && !appriseCanTest)}
title={type === 'apprise' && !appriseCanTest
? 'Enter a raw Apprise endpoint (and destination URLs for /notify) before testing.'
: undefined}
>
{isTestingAgent[type] ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
@@ -150,13 +273,18 @@ export function NotificationsSection() {
)}
</SettingsPrimaryButton>
</SettingsActions>
{type === 'apprise' && !appriseCanTest && agents.apprise.url.includes('<redacted>') && (
<p className="text-xs text-stat-subtitle">
Replace the redacted endpoint with the real URL to send a test. Unchanged secrets are preserved on Save.
</p>
)}
</SettingsSection>
);
return (
<div className="flex flex-col gap-6">
<Tabs value={notifTab} onValueChange={(v) => setNotifTab(v as 'discord' | 'slack' | 'webhook')} className="w-full">
<TabsList className="w-full mb-4 grid grid-cols-3">
<Tabs value={notifTab} onValueChange={(v) => setNotifTab(v as ChannelType)} className="w-full">
<TabsList className="w-full mb-4 grid grid-cols-4">
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
<TabsHighlightItem value="discord">
<TabsTrigger value="discord">Discord</TabsTrigger>
@@ -167,11 +295,15 @@ export function NotificationsSection() {
<TabsHighlightItem value="webhook">
<TabsTrigger value="webhook">Webhook</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="apprise">
<TabsTrigger value="apprise">Apprise</TabsTrigger>
</TabsHighlightItem>
</TabsHighlight>
</TabsList>
<TabsContent value="discord">{renderAgentTab('discord', 'Discord')}</TabsContent>
<TabsContent value="slack">{renderAgentTab('slack', 'Slack')}</TabsContent>
<TabsContent value="webhook">{renderAgentTab('webhook', 'Custom Webhook')}</TabsContent>
<TabsContent value="apprise">{renderAgentTab('apprise', 'Apprise')}</TabsContent>
</Tabs>
</div>
);
@@ -0,0 +1,229 @@
/**
* NotificationRoutingSection Apprise channel: tab render and secret-preserving
* edit save (omit redacted channel_url/config when not dirty).
*/
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: () => {},
}));
import { apiFetch } from '@/lib/api';
import { NotificationRoutingSection } from '../NotificationRoutingSection';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const APPRISE_ROUTE = {
id: 42,
name: 'Ops Apprise',
node_id: null,
stack_patterns: ['app'],
label_ids: null,
categories: null,
channel_type: 'apprise',
channel_url: 'http://apprise.local/notify/<redacted>',
config: {
mode: 'keyed',
tags: 'ops',
has_urls: false,
providers: [],
},
priority: 0,
enabled: true,
created_at: 1,
updated_at: 1,
};
function findRoutePut() {
return mockedFetch.mock.calls.find(
([url, opts]) =>
url === '/notification-routes/42'
&& (opts as { method?: string } | undefined)?.method === 'PUT',
);
}
describe('NotificationRoutingSection', () => {
beforeEach(() => {
mockedFetch.mockReset();
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/notification-routes' && !opts?.method) {
return { ok: true, json: async () => [APPRISE_ROUTE] };
}
if (url === '/stacks') return { ok: true, json: async () => ['app'] };
if (url === '/labels') return { ok: true, json: async () => [] };
if (url === '/notification-routes/42' && opts?.method === 'PUT') {
return { ok: true, json: async () => APPRISE_ROUTE };
}
return { ok: true, json: async () => ([]) };
});
});
it('shows Apprise in the channel type tabs when creating a route', async () => {
render(<NotificationRoutingSection />);
await waitFor(() => expect(screen.getByText('Ops Apprise')).toBeInTheDocument());
await userEvent.click(screen.getByRole('button', { name: /Add route/i }));
expect(await screen.findByRole('tab', { name: 'Apprise' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('tab', { name: 'Apprise' }));
expect(screen.getByPlaceholderText('http://apprise.local/notify')).toBeInTheDocument();
});
it('omits redacted Apprise channel_url and config on edit save when not dirty', async () => {
render(<NotificationRoutingSection />);
await waitFor(() => expect(screen.getByText('Ops Apprise')).toBeInTheDocument());
const card = screen.getByText('Ops Apprise').closest('.rounded-lg');
expect(card).toBeTruthy();
const editBtn = card!.querySelector('svg.lucide-pencil')?.closest('button');
expect(editBtn).toBeTruthy();
await userEvent.click(editBtn!);
expect(await screen.findByText('Edit routing rule')).toBeInTheDocument();
const nameInput = screen.getByPlaceholderText('e.g. Production alerts');
expect(nameInput).toHaveValue('Ops Apprise');
expect(screen.getByDisplayValue('http://apprise.local/notify/<redacted>')).toBeInTheDocument();
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'Ops Apprise renamed');
await userEvent.click(screen.getByRole('button', { name: 'Update' }));
await waitFor(() => expect(findRoutePut()).toBeTruthy());
const body = JSON.parse((findRoutePut()![1] as { body: string }).body);
expect(body.name).toBe('Ops Apprise renamed');
expect(body.channel_type).toBe('apprise');
expect(body).not.toHaveProperty('channel_url');
expect(body).not.toHaveProperty('config');
expect(JSON.stringify(body)).not.toContain('<redacted>');
expect(JSON.stringify(body)).not.toContain('has_urls');
expect(JSON.stringify(body)).not.toContain('providers');
});
it('sends empty keyed config when switching a stateless route to keyed', async () => {
const statelessRoute = {
...APPRISE_ROUTE,
channel_url: 'http://apprise.local/notify',
config: { mode: 'stateless' as const, has_urls: true, providers: ['discord'], url_count: 1 },
};
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/notification-routes' && !opts?.method) {
return { ok: true, json: async () => [statelessRoute] };
}
if (url === '/stacks') return { ok: true, json: async () => ['app'] };
if (url === '/labels') return { ok: true, json: async () => [] };
if (url === '/notification-routes/42' && opts?.method === 'PUT') {
return { ok: true, json: async () => statelessRoute };
}
return { ok: true, json: async () => ([]) };
});
render(<NotificationRoutingSection />);
await waitFor(() => expect(screen.getByText('Ops Apprise')).toBeInTheDocument());
const card = screen.getByText('Ops Apprise').closest('.rounded-lg');
await userEvent.click(card!.querySelector('svg.lucide-pencil')!.closest('button')!);
const urlInput = await screen.findByDisplayValue('http://apprise.local/notify');
await userEvent.clear(urlInput);
await userEvent.type(urlInput, 'http://apprise.local/notify/new-key');
await userEvent.click(screen.getByRole('button', { name: 'Update' }));
await waitFor(() => expect(findRoutePut()).toBeTruthy());
const body = JSON.parse((findRoutePut()![1] as { body: string }).body);
expect(body.channel_url).toBe('http://apprise.local/notify/new-key');
expect(body.config).toEqual({ tags: '' });
});
it('omits config on same-mode endpoint-only edit so destinations stay preserved', async () => {
const statelessRoute = {
...APPRISE_ROUTE,
channel_url: 'http://apprise.local/notify',
config: { mode: 'stateless' as const, has_urls: true, providers: ['discord'], url_count: 1 },
};
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/notification-routes' && !opts?.method) {
return { ok: true, json: async () => [statelessRoute] };
}
if (url === '/stacks') return { ok: true, json: async () => ['app'] };
if (url === '/labels') return { ok: true, json: async () => [] };
if (url === '/notification-routes/42' && opts?.method === 'PUT') {
return { ok: true, json: async () => statelessRoute };
}
return { ok: true, json: async () => ([]) };
});
render(<NotificationRoutingSection />);
await waitFor(() => expect(screen.getByText('Ops Apprise')).toBeInTheDocument());
const card = screen.getByText('Ops Apprise').closest('.rounded-lg');
await userEvent.click(card!.querySelector('svg.lucide-pencil')!.closest('button')!);
const urlInput = await screen.findByDisplayValue('http://apprise.local/notify');
await userEvent.clear(urlInput);
await userEvent.type(urlInput, 'http://apprise.local:8080/notify');
await userEvent.click(screen.getByRole('button', { name: 'Update' }));
await waitFor(() => expect(findRoutePut()).toBeTruthy());
const body = JSON.parse((findRoutePut()![1] as { body: string }).body);
expect(body.channel_url).toBe('http://apprise.local:8080/notify');
expect(body).not.toHaveProperty('config');
});
it('classifies query-bearing and trailing-slash /notify URLs as stateless', async () => {
render(<NotificationRoutingSection />);
await waitFor(() => expect(screen.getByText('Ops Apprise')).toBeInTheDocument());
await userEvent.click(screen.getByRole('button', { name: /Add route/i }));
await userEvent.click(await screen.findByRole('tab', { name: 'Apprise' }));
const urlInput = screen.getByPlaceholderText('http://apprise.local/notify');
await userEvent.clear(urlInput);
await userEvent.type(urlInput, 'http://apprise.local/notify?x=1');
expect(screen.getByPlaceholderText(/Destination URLs/i)).toBeInTheDocument();
await userEvent.clear(urlInput);
await userEvent.type(urlInput, 'http://apprise.local/notify/');
expect(screen.getByPlaceholderText(/Destination URLs/i)).toBeInTheDocument();
});
it('clears the endpoint and requires a raw URL when switching channel type on edit', async () => {
render(<NotificationRoutingSection />);
await waitFor(() => expect(screen.getByText('Ops Apprise')).toBeInTheDocument());
const card = screen.getByText('Ops Apprise').closest('.rounded-lg');
await userEvent.click(card!.querySelector('svg.lucide-pencil')!.closest('button')!);
await screen.findByDisplayValue('http://apprise.local/notify/<redacted>');
await userEvent.click(await screen.findByRole('tab', { name: 'Discord' }));
expect(screen.getByPlaceholderText(/discord/i)).toHaveValue('');
await userEvent.type(screen.getByPlaceholderText(/discord/i), 'https://discord.com/api/webhooks/9/new-token');
await userEvent.click(screen.getByRole('button', { name: 'Update' }));
await waitFor(() => expect(findRoutePut()).toBeTruthy());
const body = JSON.parse((findRoutePut()![1] as { body: string }).body);
expect(body.channel_type).toBe('discord');
expect(body.channel_url).toBe('https://discord.com/api/webhooks/9/new-token');
expect(body).not.toHaveProperty('config');
});
});
@@ -0,0 +1,379 @@
/**
* NotificationsSection Apprise channel: four-tab masthead, secret-preserving
* save (omit redacted url/config when not dirty), and Test gating.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { MastheadMetadataItem } from '@/components/ui/PageMasthead';
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(),
},
}));
const { masthead, nodeState } = vi.hoisted(() => ({
masthead: { last: null as MastheadMetadataItem[] | null },
nodeState: { activeNode: { id: 1 } as { id: number } },
}));
vi.mock('@/context/NodeContext', () => ({
useNodes: () => ({ activeNode: nodeState.activeNode }),
}));
vi.mock('../MastheadStatsContext', () => ({
useMastheadStats: (stats: MastheadMetadataItem[] | null) => {
masthead.last = stats;
},
}));
import { apiFetch } from '@/lib/api';
import { NotificationsSection } from '../NotificationsSection';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const REDACTED_APPRISE = {
type: 'apprise',
url: 'http://apprise.local/notify/<redacted>',
enabled: true,
secrets_redacted: true,
config: {
mode: 'keyed' as const,
tags: 'ops',
has_urls: false,
providers: [] as string[],
},
};
function agentsResponse(agents: unknown[] = [REDACTED_APPRISE]) {
return { ok: true, json: async () => agents };
}
function findAgentsPost() {
return mockedFetch.mock.calls.find(
([url, opts]) => url === '/agents' && (opts as { method?: string } | undefined)?.method === 'POST',
);
}
describe('NotificationsSection', () => {
beforeEach(() => {
mockedFetch.mockReset();
masthead.last = null;
nodeState.activeNode = { id: 1 };
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/agents' && !opts?.method) return agentsResponse();
if (url === '/agents' && opts?.method === 'POST') {
return { ok: true, json: async () => ({}) };
}
return { ok: true, json: async () => ([]) };
});
});
it('renders four channel tabs including Apprise', async () => {
render(<NotificationsSection />);
await waitFor(() => expect(screen.getByRole('tab', { name: 'Apprise' })).toBeInTheDocument());
expect(screen.getByRole('tab', { name: 'Discord' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Slack' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Webhook' })).toBeInTheDocument();
});
it('reports CHANNELS as n/4 in the masthead', async () => {
render(<NotificationsSection />);
await waitFor(() => expect(masthead.last?.[0]?.value).toBe('1/4'));
expect(masthead.last?.[0]?.label).toBe('CHANNELS');
});
it('omits redacted Apprise url and config on save when not dirty', async () => {
render(<NotificationsSection />);
await userEvent.click(await screen.findByRole('tab', { name: 'Apprise' }));
await waitFor(() => expect(screen.getByLabelText(/Apprise endpoint/i)).toHaveValue(
'http://apprise.local/notify/<redacted>',
));
// Public DTO masks the notify key; Tags must stay editable without re-entering the raw key.
expect(screen.getByLabelText(/^Tags$/i)).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(findAgentsPost()).toBeTruthy());
const body = JSON.parse((findAgentsPost()![1] as { body: string }).body);
expect(body).toEqual({ type: 'apprise', enabled: true });
expect(body).not.toHaveProperty('url');
expect(body).not.toHaveProperty('config');
expect(JSON.stringify(body)).not.toContain('<redacted>');
expect(JSON.stringify(body)).not.toContain('has_urls');
expect(JSON.stringify(body)).not.toContain('providers');
});
it('sends only raw url/config fields when Apprise fields are edited', async () => {
render(<NotificationsSection />);
await userEvent.click(await screen.findByRole('tab', { name: 'Apprise' }));
const endpoint = await screen.findByLabelText(/Apprise endpoint/i);
await userEvent.clear(endpoint);
await userEvent.type(endpoint, 'http://apprise.local/notify/new-key');
const tags = screen.getByLabelText(/^Tags$/i);
await userEvent.clear(tags);
await userEvent.type(tags, 'night');
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(findAgentsPost()).toBeTruthy());
const body = JSON.parse((findAgentsPost()![1] as { body: string }).body);
expect(body).toEqual({
type: 'apprise',
enabled: true,
url: 'http://apprise.local/notify/new-key',
config: { tags: 'night' },
});
expect(body.config).not.toHaveProperty('has_urls');
expect(body.config).not.toHaveProperty('providers');
expect(body.config).not.toHaveProperty('mode');
});
it('sends keyed config when creating with endpoint only (no tags)', async () => {
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/agents' && !opts?.method) return agentsResponse([]);
if (url === '/agents' && opts?.method === 'POST') {
return { ok: true, json: async () => ({}) };
}
return { ok: true, json: async () => ([]) };
});
render(<NotificationsSection />);
await userEvent.click(await screen.findByRole('tab', { name: 'Apprise' }));
const endpoint = await screen.findByLabelText(/Apprise endpoint/i);
await userEvent.clear(endpoint);
await userEvent.type(endpoint, 'http://apprise.local/notify/create-key');
expect(screen.getByLabelText(/^Tags$/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/Destination URLs/i)).toBeNull();
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(findAgentsPost()).toBeTruthy());
const body = JSON.parse((findAgentsPost()![1] as { body: string }).body);
expect(body).toEqual({
type: 'apprise',
enabled: false,
url: 'http://apprise.local/notify/create-key',
config: { tags: '' },
});
});
it('shows Destination URLs for stateless endpoints and hides Tags', async () => {
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/agents' && !opts?.method) {
return agentsResponse([{
...REDACTED_APPRISE,
url: 'http://apprise.local/notify',
config: {
mode: 'stateless',
has_urls: true,
providers: ['discord'],
url_count: 1,
urls: '',
},
}]);
}
return { ok: true, json: async () => ({}) };
});
render(<NotificationsSection />);
await userEvent.click(await screen.findByRole('tab', { name: 'Apprise' }));
expect(await screen.findByLabelText(/Destination URLs/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/^Tags$/i)).toBeNull();
});
it('omits url and config on stateless enable-only save so destinations are preserved', async () => {
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/agents' && !opts?.method) {
return agentsResponse([{
type: 'apprise',
url: 'http://apprise.local/notify',
enabled: false,
secrets_redacted: true,
config: {
mode: 'stateless',
has_urls: true,
providers: ['discord'],
url_count: 1,
},
}]);
}
if (url === '/agents' && opts?.method === 'POST') {
return { ok: true, json: async () => ({}) };
}
return { ok: true, json: async () => ([]) };
});
render(<NotificationsSection />);
await userEvent.click(await screen.findByRole('tab', { name: 'Apprise' }));
await waitFor(() => expect(screen.getByLabelText(/Apprise endpoint/i)).toHaveValue('http://apprise.local/notify'));
await userEvent.click(screen.getByRole('switch'));
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(findAgentsPost()).toBeTruthy());
const body = JSON.parse((findAgentsPost()![1] as { body: string }).body);
expect(body).toEqual({ type: 'apprise', enabled: true });
expect(body).not.toHaveProperty('url');
expect(body).not.toHaveProperty('config');
});
it('sends destination URLs when stateless destinations are edited', async () => {
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/agents' && !opts?.method) {
return agentsResponse([{
type: 'apprise',
url: 'http://apprise.local/notify',
enabled: true,
secrets_redacted: true,
config: { mode: 'stateless', has_urls: true, url_count: 1, providers: ['discord'] },
}]);
}
if (url === '/agents' && opts?.method === 'POST') {
return { ok: true, json: async () => ({}) };
}
return { ok: true, json: async () => ([]) };
});
render(<NotificationsSection />);
await userEvent.click(await screen.findByRole('tab', { name: 'Apprise' }));
const dest = await screen.findByLabelText(/Destination URLs/i);
await userEvent.clear(dest);
await userEvent.type(dest, 'discord://hook');
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(findAgentsPost()).toBeTruthy());
const body = JSON.parse((findAgentsPost()![1] as { body: string }).body);
expect(body).toEqual({
type: 'apprise',
enabled: true,
config: { urls: 'discord://hook' },
});
expect(body).not.toHaveProperty('url');
});
it('omits config on same-mode endpoint-only edit so destinations stay preserved', async () => {
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/agents' && !opts?.method) {
return agentsResponse([{
type: 'apprise',
url: 'http://apprise.local/notify',
enabled: true,
secrets_redacted: true,
config: { mode: 'stateless', has_urls: true, url_count: 1, providers: ['discord'] },
}]);
}
if (url === '/agents' && opts?.method === 'POST') {
return { ok: true, json: async () => ({}) };
}
return { ok: true, json: async () => ([]) };
});
render(<NotificationsSection />);
await userEvent.click(await screen.findByRole('tab', { name: 'Apprise' }));
const endpoint = await screen.findByLabelText(/Apprise endpoint/i);
await userEvent.clear(endpoint);
await userEvent.type(endpoint, 'http://apprise.local:8080/notify');
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(findAgentsPost()).toBeTruthy());
const body = JSON.parse((findAgentsPost()![1] as { body: string }).body);
expect(body).toEqual({
type: 'apprise',
enabled: true,
url: 'http://apprise.local:8080/notify',
});
expect(body).not.toHaveProperty('config');
});
it('shows Destination URLs for a query-bearing stateless endpoint', async () => {
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/agents' && !opts?.method) return agentsResponse([]);
return { ok: true, json: async () => ({}) };
});
render(<NotificationsSection />);
await userEvent.click(await screen.findByRole('tab', { name: 'Apprise' }));
const endpoint = await screen.findByLabelText(/Apprise endpoint/i);
await userEvent.clear(endpoint);
await userEvent.type(endpoint, 'http://apprise.local/notify?token=x');
expect(await screen.findByLabelText(/Destination URLs/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/^Tags$/i)).toBeNull();
});
it('disables Test when the Apprise endpoint is still redacted', async () => {
render(<NotificationsSection />);
await userEvent.click(await screen.findByRole('tab', { name: 'Apprise' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Test' })).toBeDisabled());
expect(screen.getByText(/Replace the redacted endpoint/i)).toBeInTheDocument();
});
it('replaces agent state when switching to a node with no agents', async () => {
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/agents' && !opts?.method) {
if (nodeState.activeNode.id === 1) return agentsResponse([REDACTED_APPRISE]);
return agentsResponse([]);
}
return { ok: true, json: async () => ({}) };
});
const { rerender } = render(<NotificationsSection />);
await userEvent.click(await screen.findByRole('tab', { name: 'Apprise' }));
await waitFor(() => expect(screen.getByLabelText(/Apprise endpoint/i)).toHaveValue('http://apprise.local/notify/<redacted>'));
expect(masthead.last?.[0]?.value).toBe('1/4');
nodeState.activeNode = { id: 2 };
rerender(<NotificationsSection />);
await waitFor(() => expect(masthead.last?.[0]?.value).toBe('0/4'));
await userEvent.click(await screen.findByRole('tab', { name: 'Apprise' }));
await waitFor(() => expect(screen.getByLabelText(/Apprise endpoint/i)).toHaveValue(''));
});
it('ignores a stale agents body when json() resolves after a node switch', async () => {
let releaseNode1Body: (() => void) | undefined;
const node1BodyGate = new Promise<void>((resolve) => { releaseNode1Body = resolve; });
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string; nodeId?: number | null }) => {
if (url === '/agents' && !opts?.method) {
const targetId = opts?.nodeId ?? nodeState.activeNode.id;
if (targetId === 1) {
// Response headers arrive immediately; body stays pending across the switch.
return {
ok: true,
json: async () => {
await node1BodyGate;
return [REDACTED_APPRISE];
},
};
}
return agentsResponse([]);
}
return { ok: true, json: async () => ({}) };
});
const { rerender } = render(<NotificationsSection />);
await waitFor(() => expect(mockedFetch).toHaveBeenCalled());
expect(mockedFetch.mock.calls[0]?.[1]).toMatchObject({ nodeId: 1 });
nodeState.activeNode = { id: 2 };
rerender(<NotificationsSection />);
await waitFor(() => expect(masthead.last?.[0]?.value).toBe('0/4'));
await waitFor(() =>
expect(mockedFetch.mock.calls.some((call) => (call[1] as { nodeId?: number } | undefined)?.nodeId === 2)).toBe(true),
);
releaseNode1Body?.();
await new Promise((r) => setTimeout(r, 40));
expect(masthead.last?.[0]?.value).toBe('0/4');
await userEvent.click(await screen.findByRole('tab', { name: 'Apprise' }));
expect(screen.getByLabelText(/Apprise endpoint/i)).toHaveValue('');
});
});
+2 -2
View File
@@ -204,8 +204,8 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
id: 'notifications',
group: 'notifications',
label: 'Channels',
description: 'Discord, Slack, and custom webhook destinations for Sencho alerts.',
keywords: ['discord', 'slack', 'webhook', 'channels', 'destinations', 'alerts'],
description: 'Discord, Slack, Apprise, and custom webhook destinations for Sencho alerts.',
keywords: ['discord', 'slack', 'apprise', 'webhook', 'channels', 'destinations', 'alerts'],
tier: null,
scope: 'node',
},
+2 -1
View File
@@ -79,7 +79,8 @@ export type SectionId =
| 'about';
export interface Agent {
type: 'discord' | 'slack' | 'webhook';
type: 'discord' | 'slack' | 'webhook' | 'apprise';
url: string;
enabled: boolean;
config?: { mode?: 'keyed' | 'stateless'; tags?: string; urls?: string; has_urls?: boolean; providers?: string[]; url_count?: number } | null;
}