mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +00:00
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:
@@ -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 → 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/{key} 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('');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user