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
@@ -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();
});
});