mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 03:59:41 +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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user