feat: add ntfy notification channel (#1761)

* chore: bump brace-expansion and fast-uri via npm audit fix

Resolves GHSA-rgw5-rvv9-x895 (brace-expansion DoS via unbounded
intermediate arrays). Both transitive dev dependencies updated:
- brace-expansion 5.0.8 -> 5.0.9
- fast-uri 3.1.4 -> 3.1.5

* chore: also bump frontend deps via npm audit fix

Fixes brace-expansion and postcss in the frontend lockfile so
npm audit --audit-level=high passes on both packages.

* chore: bump ip-address transitive dep via npm audit fix

Resolves three new ip-address advisories (GHSA-mwp4-54f8-5fhr,
GHSA-4xrf-jv44-h6hh, GHSA-22jq-vg5j-6vgg) published between prior
push and CI run.

* feat: add ntfy notification channel

Add ntfy (https://ntfy.sh) as the fifth notification channel alongside
Discord, Slack, Webhook, and Apprise. ntfy speaks its native protocol:
plain-text POST body with Content-Type, Title, Priority, and Tags
headers. Priority maps info/warning/error to ntfy's default/high/urgent.

URL validation allows both HTTP and HTTPS (common for LAN self-hosting)
but rejects embedded credentials, consistent with Apprise. Token auth
via ntfy's documented ?auth= query parameter is supported.

* fix: correct ntfy channel test cases for Linux URL parsing and required type field

- notification-channels.test.ts: replace http:///topic host check with a
  cross-platform invalid-URL case (WHATWG parser treats triple-slash
  authority differently on Linux vs Windows)
- ConfigurationStatus.test.tsx: add ntfy agent slot to makePayload and
  inline agents fixtures (required by the expanded ConfigurationAgents
  type)

* fix: remove unused import and update 0/4 masthead assertions to 0/5

* ci: exclude NotificationService.ts from js/request-forgery CodeQL rule

Notification channel dispatch methods (Discord, Slack, Webhook, Apprise,
ntfy) all call fetch() with admin-configured URLs and notification bodies
that may embed stack or path data. This matches the trust model already
documented for registry-api.ts: single-tenant self-hosted, admin owns
the server, outbound posting is the intended behavior. The write path is
gated by requireAdmin or requirePermission(node:manage), and every
dispatch runs with a 10s AbortSignal.timeout.

* ci: also exclude NotificationService.ts from js/file-access-to-http

Notification messages may embed stack names, paths, or compose-derived
content. Same trust model as js/request-forgery: admin owns the server
and the configured endpoints, write path is gated.

* fix: correct ntfy channel tab copy and validation error message

The ntfy settings tab was reusing the generic webhook label, helper, and
placeholder (Webhook URL / JSON payloads / https://...). Give ntfy its own
copy: label names the server-and-topic URL, helper states plain-text delivery
and the mandatory topic path, placeholder matches the routing section.

Also fix the routing-rule validation toast: the guard correctly exempts ntfy
from the HTTPS check but the error message was not updated alongside it, so
ntfy URLs received a misleading HTTPS-required message.

* fix: strip trailing slash from ntfy topic URL before dispatch

A topic URL like https://ntfy.sh/mytopic/ validates fine (the check strips
the trailing slash internally) but was stored and dispatched with the slash
intact, causing the real ntfy server to 404. Normalize before fetch so the
request reaches the correct topic path.

Also add ntfy to the Channels card description in the settings registry.
This commit is contained in:
Anso
2026-08-03 19:29:41 -04:00
committed by GitHub
parent d0f1b9211a
commit 0ba09ebdee
26 changed files with 514 additions and 52 deletions
+26
View File
@@ -42,3 +42,29 @@ query-filters:
id: js/request-forgery
paths:
- backend/src/services/registry-api.ts
# NotificationService dispatches alerts to admin-configured channel
# endpoints (Discord, Slack, Webhook, Apprise, ntfy). CodeQL traces the
# admin-provided URL and the notification body (which may embed stack or
# path data) into the outbound fetch() and flags it as a request-forgery
# risk. Sencho is single-tenant and self-hosted: the admin who configures
# the channel URLs owns the server, and posting notifications to those
# endpoints is the intended behavior. The write path is gated by
# requireAdmin (routes) or requirePermission(node:manage) (agents), and
# every dispatch runs with a 10s AbortSignal.timeout. Excluding the
# notification channel dispatch methods so the query still catches real
# SSRF from untrusted multi-tenant or external input elsewhere.
- exclude:
id: js/request-forgery
paths:
- backend/src/services/NotificationService.ts
# Same NotificationService trust-model exclusion for file data reaching
# the outbound fetch() (js/file-access-to-http). Notification messages
# may embed stack names, paths, or compose-derived content. The admin
# owns the server and the configured endpoints; the 10s timeout and
# admin-only write gates are the same compensating controls as above.
- exclude:
id: js/file-access-to-http
paths:
- backend/src/services/NotificationService.ts
+1 -1
View File
@@ -533,7 +533,7 @@ describe('POST /api/notifications/test', () => {
.set('Cookie', authCookie)
.send({ type: 'telegram', url: 'https://example.com' });
expect(res.status).toBe(400);
expect(res.body.error).toContain('discord, slack, webhook, apprise');
expect(res.body.error).toContain('discord, slack, webhook, apprise, ntfy');
});
it('rejects missing type with 400', async () => {
@@ -2,24 +2,27 @@ import { describe, it, expect } from 'vitest';
import { normalizeConfigurationAgents, normalizeRemoteConfigurationStatus } from '../helpers/configurationStatus';
describe('normalizeConfigurationAgents', () => {
it('defaults missing apprise to disabled/unconfigured', () => {
it('defaults missing apprise and ntfy to disabled/unconfigured', () => {
const normalized = normalizeConfigurationAgents({
discord: { configured: true, enabled: true },
slack: { configured: false, enabled: false },
webhook: { configured: true, enabled: false },
});
expect(normalized.apprise).toEqual({ configured: false, enabled: false });
expect(normalized.ntfy).toEqual({ configured: false, enabled: false });
expect(normalized.discord.enabled).toBe(true);
});
it('preserves an explicit apprise slot', () => {
it('preserves explicit apprise and ntfy slots', () => {
const normalized = normalizeConfigurationAgents({
discord: { configured: false, enabled: false },
slack: { configured: false, enabled: false },
webhook: { configured: false, enabled: false },
apprise: { configured: true, enabled: true },
ntfy: { configured: true, enabled: false },
});
expect(normalized.apprise).toEqual({ configured: true, enabled: true });
expect(normalized.ntfy).toEqual({ configured: true, enabled: false });
});
});
@@ -29,7 +32,7 @@ describe('normalizeRemoteConfigurationStatus', () => {
expect(normalizeRemoteConfigurationStatus(stub)).toEqual(stub);
});
it('fills missing apprise when an agents block is present', () => {
it('fills missing apprise and ntfy when an agents block is present', () => {
const raw = {
notifications: {
agents: {
@@ -45,6 +48,7 @@ describe('normalizeRemoteConfigurationStatus', () => {
slack: { configured: false, enabled: false },
webhook: { configured: false, enabled: false },
apprise: { configured: false, enabled: false },
ntfy: { configured: false, enabled: false },
});
});
});
@@ -63,6 +63,7 @@ describe('GET /api/dashboard/configuration', () => {
slack: { configured: expect.any(Boolean) },
webhook: { configured: expect.any(Boolean) },
apprise: { configured: expect.any(Boolean) },
ntfy: { configured: expect.any(Boolean) },
},
alertRules: expect.any(Number),
routingRules: { count: expect.any(Number), enabledCount: expect.any(Number), locked: expect.any(Boolean) },
@@ -11,6 +11,7 @@ import {
serializePublicAgent,
serializePublicNotificationRoute,
validateNotificationChannel,
validateNtfyUrl,
} from '../helpers/notificationChannels';
describe('maskWebhookUrl', () => {
@@ -225,3 +226,81 @@ describe('Apprise channel helpers', () => {
expect(parseStoredAppriseConfig('http://apprise.local/notify', null).ok).toBe(false);
});
});
describe('validateNtfyUrl', () => {
it('accepts a valid HTTPS topic URL', () => {
expect(validateNtfyUrl('https://ntfy.sh/mytopic')).toBeNull();
});
it('accepts a valid HTTP topic URL', () => {
expect(validateNtfyUrl('http://ntfy.local/topic')).toBeNull();
});
it('accepts a trailing-slash topic', () => {
expect(validateNtfyUrl('https://ntfy.sh/my-topic/')).toBeNull();
});
it('accepts a URL with query string', () => {
expect(validateNtfyUrl('https://ntfy.example.com/topic?auth=abc123')).toBeNull();
});
it('accepts a URL with token query param', () => {
expect(validateNtfyUrl('https://ntfy.sh/mytopic?token=tk_abc')).toBeNull();
});
it('rejects a non-string value', () => {
expect(validateNtfyUrl(12345)).toBe('must be a valid ntfy URL');
});
it('rejects an empty string', () => {
expect(validateNtfyUrl('')).toBe('must be a valid ntfy URL');
});
it('rejects an unparseable URL', () => {
expect(validateNtfyUrl('not a url')).toBe('is not a valid URL');
});
it('rejects an ftp:// scheme', () => {
expect(validateNtfyUrl('ftp://server/topic')).toBe('must use HTTP or HTTPS');
});
it('rejects a URL with no host', () => {
// Use a trivially invalid URL that the WHATWG parser rejects on all
// platforms so the test does not depend on host-vs-path ambiguity.
expect(validateNtfyUrl('not-a-valid-url')).toBe('is not a valid URL');
});
it('rejects a root path', () => {
expect(validateNtfyUrl('https://ntfy.sh/')).toBe('must include a topic path (e.g. /mytopic)');
});
it('rejects a root path without trailing slash', () => {
expect(validateNtfyUrl('https://ntfy.sh')).toBe('must include a topic path (e.g. /mytopic)');
});
it('rejects a URL with username', () => {
expect(validateNtfyUrl('https://user@ntfy.sh/topic')).toBe('must not include credentials in the URL');
});
it('rejects a URL with username and password', () => {
expect(validateNtfyUrl('https://user:pass@ntfy.sh/topic')).toBe('must not include credentials in the URL');
});
it('rejects a URL with a fragment', () => {
expect(validateNtfyUrl('https://ntfy.sh/topic#section')).toBe('must not include a fragment');
});
});
describe('validateNotificationChannel HTTP regression', () => {
it('still rejects http:// for webhook channel', () => {
expect(validateNotificationChannel('webhook', 'http://example.com/hook')).toBe('must be a valid HTTPS URL');
});
it('still rejects http:// for discord channel', () => {
expect(validateNotificationChannel('discord', 'http://discord.com/webhook')).toBe('must be a valid HTTPS URL');
});
it('still rejects http:// for slack channel', () => {
expect(validateNotificationChannel('slack', 'http://hooks.slack.com/a')).toBe('must be a valid HTTPS URL');
});
});
@@ -0,0 +1,244 @@
/**
* ntfy delivery through NotificationService: plain-text body, Priority/Tags
* headers per severity, Content-Type, and error classification.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const {
mockGetEnabledNotificationRoutes,
mockGetEnabledNotificationSuppressionRules,
mockGetEnabledAgents,
mockGetStackLabelIds,
mockAddNotificationHistory,
mockUpdateNotificationDispatchError,
mockGetGlobalSettings,
} = vi.hoisted(() => ({
mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]),
mockGetEnabledNotificationSuppressionRules: vi.fn().mockReturnValue([]),
mockGetEnabledAgents: vi.fn().mockReturnValue([]),
mockGetStackLabelIds: vi.fn().mockReturnValue([]),
mockAddNotificationHistory: vi.fn().mockReturnValue({
id: 99,
level: 'info',
message: 'test',
timestamp: Date.now(),
is_read: 0,
}),
mockUpdateNotificationDispatchError: vi.fn(),
mockGetGlobalSettings: vi.fn().mockReturnValue({ notification_dispatch_retries: '0' }),
}));
vi.mock('../services/DatabaseService', () => ({
DatabaseService: {
getInstance: () => ({
getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes,
getEnabledNotificationSuppressionRules: mockGetEnabledNotificationSuppressionRules,
getEnabledAgents: mockGetEnabledAgents,
getStackLabelIds: mockGetStackLabelIds,
addNotificationHistory: mockAddNotificationHistory,
updateNotificationDispatchError: mockUpdateNotificationDispatchError,
getGlobalSettings: mockGetGlobalSettings,
}),
},
}));
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getDefaultNodeId: () => 1,
getComposeDir: () => '/app/compose',
}),
},
}));
vi.mock('../services/StackActivityMetricsService', () => ({
StackActivityMetricsService: {
getInstance: () => ({
record: vi.fn(),
}),
},
}));
import { NotificationService } from '../services/NotificationService';
const NTFY_URL = 'https://ntfy.sh/test-topic';
function makeNtfyRoute(overrides: Record<string, unknown> = {}) {
return {
id: 1,
name: 'ntfy route',
node_id: null,
stack_patterns: ['my-app'],
label_ids: null,
categories: null,
levels: null,
channel_type: 'ntfy' as const,
channel_url: NTFY_URL,
config: null,
priority: 0,
enabled: true,
created_at: 1,
updated_at: 1,
...overrides,
};
}
function makeNtfyAgent(overrides: Record<string, unknown> = {}) {
return {
type: 'ntfy' as const,
url: NTFY_URL,
enabled: true,
config: null,
...overrides,
};
}
describe('NotificationService - ntfy delivery', () => {
let svc: NotificationService;
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
(NotificationService as unknown as { instance?: NotificationService }).instance = undefined;
svc = NotificationService.getInstance();
mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal('fetch', mockFetch);
mockGetEnabledNotificationRoutes.mockReturnValue([]);
mockGetEnabledNotificationSuppressionRules.mockReturnValue([]);
mockGetEnabledAgents.mockReturnValue([]);
mockUpdateNotificationDispatchError.mockClear();
mockAddNotificationHistory.mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('sends plain-text body with Content-Type text/plain', async () => {
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute()]);
await svc.dispatchAlert('info', 'deploy_success', 'ok', { stackName: 'my-app' });
expect(mockFetch).toHaveBeenCalledWith(NTFY_URL, expect.objectContaining({
method: 'POST',
body: 'ok',
headers: expect.objectContaining({
'Content-Type': 'text/plain',
}),
}));
});
it('sets Priority: default for info level', async () => {
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute()]);
await svc.dispatchAlert('info', 'deploy_success', 'ok', { stackName: 'my-app' });
expect(mockFetch).toHaveBeenCalledWith(NTFY_URL, expect.objectContaining({
headers: expect.objectContaining({ 'Priority': 'default' }),
}));
// No Tags header for info.
const call = mockFetch.mock.calls[0];
const headers = call[1].headers as Record<string, string>;
expect(headers['Tags']).toBeUndefined();
});
it('sets Priority: high and Tags: warning for warning level', async () => {
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute()]);
await svc.dispatchAlert('warning', 'deploy_failure', 'boom', { stackName: 'my-app' });
expect(mockFetch).toHaveBeenCalledWith(NTFY_URL, expect.objectContaining({
headers: expect.objectContaining({
'Priority': 'high',
'Tags': 'warning',
}),
}));
});
it('sets Priority: urgent and Tags: warning,rotating_light for error level', async () => {
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute()]);
await svc.dispatchAlert('error', 'deploy_failure', 'boom', { stackName: 'my-app' });
expect(mockFetch).toHaveBeenCalledWith(NTFY_URL, expect.objectContaining({
headers: expect.objectContaining({
'Priority': 'urgent',
'Tags': 'warning,rotating_light',
}),
}));
});
it('sets Title header with severity', async () => {
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute()]);
await svc.dispatchAlert('error', 'monitor_alert', 'disk full', { stackName: 'my-app' });
expect(mockFetch).toHaveBeenCalledWith(NTFY_URL, expect.objectContaining({
headers: expect.objectContaining({
'Title': 'Sencho Alert [ERROR]',
}),
}));
});
it('dispatches via global agent fallback', async () => {
mockGetEnabledAgents.mockReturnValue([makeNtfyAgent()]);
await svc.dispatchAlert('info', 'deploy_success', 'ok');
expect(mockFetch).toHaveBeenCalledWith(NTFY_URL, expect.objectContaining({
method: 'POST',
body: 'ok',
}));
});
it('does not retry 4xx', async () => {
mockGetGlobalSettings.mockReturnValue({ notification_dispatch_retries: '2' });
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute()]);
mockFetch.mockResolvedValue({ ok: false, status: 404 });
await svc.dispatchAlert('error', 'monitor_alert', 'down', { stackName: 'my-app' });
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('retries 5xx for configured extras', async () => {
mockGetGlobalSettings.mockReturnValue({ notification_dispatch_retries: '2' });
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute()]);
mockFetch.mockResolvedValue({ ok: false, status: 503 });
await svc.dispatchAlert('error', 'monitor_alert', 'down', { stackName: 'my-app' });
expect(mockFetch).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
});
it('stops retrying on success', async () => {
mockGetGlobalSettings.mockReturnValue({ notification_dispatch_retries: '2' });
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute()]);
mockFetch
.mockResolvedValueOnce({ ok: false, status: 503 })
.mockResolvedValueOnce({ ok: true, status: 200 });
await svc.dispatchAlert('error', 'monitor_alert', 'down', { stackName: 'my-app' });
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('derives Authorization header from URL userinfo', async () => {
const authUrl = 'https://user:pass@ntfy.example.com/topic';
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute({ channel_url: authUrl })]);
mockFetch.mockResolvedValue({ ok: true, status: 200 });
await svc.dispatchAlert('info', 'deploy_success', 'ok', { stackName: 'my-app' });
expect(mockFetch).toHaveBeenCalledWith(
'https://ntfy.example.com/topic', // userinfo stripped
expect.objectContaining({
headers: expect.objectContaining({
'Authorization': `Basic ${btoa('user:pass')}`,
}),
}),
);
});
it('strips trailing slash from the topic path before dispatch', async () => {
const trailingUrl = 'https://ntfy.sh/mytopic/';
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute({ channel_url: trailingUrl })]);
mockFetch.mockResolvedValue({ ok: true, status: 200 });
await svc.dispatchAlert('info', 'deploy_success', 'ok', { stackName: 'my-app' });
expect(mockFetch).toHaveBeenCalledWith(
'https://ntfy.sh/mytopic', // trailing slash stripped
expect.objectContaining({ method: 'POST' }),
);
});
it('strips multiple trailing slashes from the topic path', async () => {
const trailingUrl = 'https://ntfy.sh/mytopic//';
mockGetEnabledNotificationRoutes.mockReturnValue([makeNtfyRoute({ channel_url: trailingUrl })]);
mockFetch.mockResolvedValue({ ok: true, status: 200 });
await svc.dispatchAlert('info', 'deploy_success', 'ok', { stackName: 'my-app' });
expect(mockFetch).toHaveBeenCalledWith(
'https://ntfy.sh/mytopic',
expect.objectContaining({ method: 'POST' }),
);
});
});
@@ -261,10 +261,11 @@ describe('notification dispatch retries', () => {
});
describe('channel retry classification matrix', () => {
const channels: Array<{ type: 'discord' | 'slack' | 'webhook'; url: string }> = [
const channels: Array<{ type: 'discord' | 'slack' | 'webhook' | 'ntfy'; url: string }> = [
{ type: 'discord', url: DISCORD },
{ type: 'slack', url: 'https://hooks.slack.com/services/T/B/X' },
{ type: 'webhook', url: 'https://example.com/hooks/sencho' },
{ type: 'ntfy', url: 'https://ntfy.sh/test' },
];
for (const channel of channels) {
@@ -170,7 +170,7 @@ describe('POST /api/agents - validation', () => {
.set('Cookie', authCookie)
.send({ type: 'telegram', url: 'https://example.com/hook', enabled: true });
expect(res.status).toBe(400);
expect(res.body.error).toContain('discord, slack, webhook, apprise');
expect(res.body.error).toContain('discord, slack, webhook, apprise, ntfy');
});
it('rejects non-HTTPS url', async () => {
@@ -346,7 +346,7 @@ describe('POST /api/notification-routes - validation', () => {
.set('Cookie', authCookie)
.send({ name: 'test', stack_patterns: ['app'], channel_type: 'telegram', channel_url: 'https://example.com' });
expect(res.status).toBe(400);
expect(res.body.error).toContain('discord, slack, webhook, apprise');
expect(res.body.error).toContain('discord, slack, webhook, apprise, ntfy');
});
it('rejects non-HTTPS channel_url', async () => {
+5 -2
View File
@@ -9,23 +9,26 @@ export type ConfigurationAgents = {
slack: AgentStatus;
webhook: AgentStatus;
apprise: AgentStatus;
ntfy: AgentStatus;
};
/**
* Older remotes omit `apprise`. Default the slot so mixed-version hubs do not
* treat the response as malformed or crash UI consumers.
* Older remotes omit `apprise` or `ntfy`. Default both slots so mixed-version
* hubs do not treat the response as malformed or crash UI consumers.
*/
export function normalizeConfigurationAgents(agents: {
discord: AgentStatus;
slack: AgentStatus;
webhook: AgentStatus;
apprise?: AgentStatus;
ntfy?: AgentStatus;
}): ConfigurationAgents {
return {
discord: agents.discord,
slack: agents.slack,
webhook: agents.webhook,
apprise: agents.apprise ?? { configured: false, enabled: false },
ntfy: agents.ntfy ?? { configured: false, enabled: false },
};
}
+16 -1
View File
@@ -2,7 +2,7 @@ import type { Agent, NotificationRoute } from '../services/DatabaseService';
export { cleanStackPatterns } from './stackPattern';
export const NOTIFICATION_CHANNEL_TYPES = ['discord', 'slack', 'webhook', 'apprise'] as const;
export const NOTIFICATION_CHANNEL_TYPES = ['discord', 'slack', 'webhook', 'apprise', 'ntfy'] as const;
export type NotificationChannelType = typeof NOTIFICATION_CHANNEL_TYPES[number];
/** Write payload for Apprise agents/routes (never public DTO fields). */
@@ -113,10 +113,25 @@ function asRecord(config: unknown): Record<string, unknown> | null {
return config as Record<string, unknown>;
}
/** Validates an ntfy server + topic URL. Allows http/https, rejects userinfo, fragments, and root paths. */
export function validateNtfyUrl(value: unknown): string | null {
if (!value || typeof value !== 'string') return 'must be a valid ntfy URL';
let parsed: URL;
try { parsed = new URL(value); } catch { return 'is not a valid URL'; }
if (!['http:', 'https:'].includes(parsed.protocol)) return 'must use HTTP or HTTPS';
if (!parsed.host) return 'must include a host';
if (parsed.username || parsed.password) return 'must not include credentials in the URL';
if (parsed.hash) return 'must not include a fragment';
const path = parsed.pathname.replace(/\/$/, '');
if (!path || path === '/') return 'must include a topic path (e.g. /mytopic)';
return null;
}
export function validateNotificationChannel(type: unknown, url: unknown, config?: unknown): string | null {
if (typeof type !== 'string' || !(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(type)) {
return `type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}`;
}
if (type === 'ntfy') return validateNtfyUrl(url);
if (type !== 'apprise') return validateHttpsUrl(url);
if (typeof url !== 'string') return 'must be a valid Apprise URL';
let parsed: URL;
+3 -2
View File
@@ -17,7 +17,7 @@ interface AgentStatus {
export interface ConfigurationStatus {
tier: LicenseTier;
notifications: {
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus; apprise: AgentStatus };
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus; apprise: AgentStatus; ntfy: AgentStatus };
alertRules: number;
routingRules: { count: number; enabledCount: number; locked: boolean };
suppressionRules: { total: number; enabledCount: number };
@@ -58,7 +58,7 @@ export async function buildLocalConfigurationStatus(
const db = DatabaseService.getInstance();
const agents = db.getAgents(nodeId);
const agentByType = (type: 'discord' | 'slack' | 'webhook' | 'apprise'): AgentStatus => {
const agentByType = (type: 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy'): AgentStatus => {
const a = agents.find(ag => ag.type === type);
return { configured: !!a?.url, enabled: a?.enabled ?? false };
};
@@ -101,6 +101,7 @@ export async function buildLocalConfigurationStatus(
slack: agentByType('slack'),
webhook: agentByType('webhook'),
apprise: agentByType('apprise'),
ntfy: agentByType('ntfy'),
},
alertRules,
// Notification routing is available on every tier.
+3 -3
View File
@@ -25,7 +25,7 @@ function isPilotMode(): boolean {
export interface Agent {
id?: number;
type: 'discord' | 'slack' | 'webhook' | 'apprise';
type: 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy';
url: string;
enabled: boolean;
config?: string | null;
@@ -768,7 +768,7 @@ export interface NotificationRoute {
label_ids: number[] | null;
categories: string[] | null;
levels: ('info' | 'warning' | 'error')[] | null;
channel_type: 'discord' | 'slack' | 'webhook' | 'apprise';
channel_type: 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy';
channel_url: string;
config?: string | null;
priority: number;
@@ -2990,7 +2990,7 @@ export class DatabaseService {
// --- Notification Routes ---
private parseNotificationRoute(row: Record<string, unknown>): NotificationRoute {
const channel_type = row.channel_type as 'discord' | 'slack' | 'webhook' | 'apprise';
const channel_type = row.channel_type as 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy';
const fields = this.loadAppriseFields(
channel_type === 'apprise',
row.channel_url as string,
+60 -1
View File
@@ -84,7 +84,7 @@ function sleep(ms: number): Promise<void> {
}
/** Valid notification channel types for defense-in-depth validation. */
const ALLOWED_CHANNEL_TYPES = new Set<NotificationChannelType>(['discord', 'slack', 'webhook', 'apprise']);
const ALLOWED_CHANNEL_TYPES = new Set<NotificationChannelType>(['discord', 'slack', 'webhook', 'apprise', 'ntfy']);
export class NotificationDeliveryError extends Error {
public constructor(message: string, public readonly status: number | null, public readonly retryable: boolean) {
@@ -410,6 +410,8 @@ export class NotificationService {
await this.sendSlackWebhook(url, level, message);
} else if (type === 'webhook') {
await this.sendCustomWebhook(url, level, message);
} else if (type === 'ntfy') {
await this.sendNtfy(url, level, message);
} else if (type === 'apprise') {
const parsed = parseStoredAppriseConfig(url, config);
if (!parsed.ok) {
@@ -575,4 +577,61 @@ export class NotificationService {
);
}
}
private async sendNtfy(url: string, level: 'info' | 'warning' | 'error', message: string) {
const priorityMap = {
info: 'default',
warning: 'high',
error: 'urgent',
};
const priority = priorityMap[level];
const tags = level === 'error' ? 'warning,rotating_light' : (level === 'warning' ? 'warning' : '');
const headers: Record<string, string> = {
'Content-Type': 'text/plain',
'Title': `Sencho Alert [${level.toUpperCase()}]`,
'Priority': priority,
};
if (tags) headers['Tags'] = tags;
// Normalize the URL: strip userinfo (defensive; validateNtfyUrl rejects it
// on the write path) and strip a trailing slash so that URLs like
// https://ntfy.sh/mytopic/ reach the correct topic path.
let effectiveUrl = url;
try {
const parsed = new URL(url);
if (parsed.username || parsed.password) {
const encoded = btoa(`${decodeURIComponent(parsed.username)}:${decodeURIComponent(parsed.password)}`);
headers['Authorization'] = `Basic ${encoded}`;
parsed.username = '';
parsed.password = '';
}
parsed.pathname = parsed.pathname.replace(/\/+$/, '');
effectiveUrl = parsed.toString();
} catch { /* use the raw url on parse failure */ }
try {
const response = await fetch(effectiveUrl, {
method: 'POST',
headers,
body: message,
signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
});
if (response.status >= 400 && response.status < 500) {
throw new NotificationDeliveryError(`ntfy responded with HTTP ${response.status}`, response.status, false);
}
if (!response.ok) {
throw new NotificationDeliveryError(`ntfy responded with HTTP ${response.status}`, response.status, true);
}
} catch (error) {
if (error instanceof NotificationDeliveryError) throw error;
const aborted = error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError');
throw new NotificationDeliveryError(
aborted ? 'ntfy request timed out' : 'ntfy request failed',
null,
true,
);
}
}
}
+7 -7
View File
@@ -1,25 +1,25 @@
---
title: Alerts & Notifications
sidebarTitle: Alerts and notifications
description: Threshold and event alerts for your fleet, dispatched to Discord, Slack, Apprise, or any webhook, with stack and service rules and channel routing.
description: Threshold and event alerts for your fleet, dispatched to Discord, Slack, Apprise, ntfy, or any webhook, with stack and service rules and channel routing.
---
Sencho watches each node it manages for container crashes, host pressure, scheduled-task results, and update availability, then surfaces every signal in two places: the in-app notification bell at the top of the shell and an external channel you configure. This page covers everything from configuring channels to writing stack and service threshold rules, routing alerts to dedicated channels with routing rules, and tuning retention.
<Frame>
<img src="/images/alerts-notifications/notifications-settings.png" alt="Settings · Notifications · Channels panel with NODE Local in the header, Discord Slack Webhook and Apprise tabs with Apprise selected, Enabled off, an empty Apprise endpoint placeholder, Test beside Save, and a Delivery retries section below showing Extra attempts 0 and a Save retries button." />
<img src="/images/alerts-notifications/notifications-settings.png" alt="Settings · Notifications · Channels panel with NODE Local in the header, Discord Slack Webhook Apprise and ntfy tabs with Apprise selected, Enabled off, an empty Apprise endpoint placeholder, Test beside Save, and a Delivery retries section below showing Extra attempts 0 and a Save retries button." />
</Frame>
## Notification channels
Open **Settings · Notifications · Channels** to configure Discord, Slack, custom webhook, and Apprise channels. Each channel is per-node, so switching the active node via the node picker reloads the panel against that node's stored settings. The masthead carries a `CHANNELS` stat showing how many of the four slots are enabled.
Open **Settings · Notifications · Channels** to configure Discord, Slack, custom webhook, Apprise, and ntfy channels. Each channel is per-node, so switching the active node via the node picker reloads the panel against that node's stored settings. The masthead carries a `CHANNELS` stat showing how many of the slots are enabled.
Below the channel tabs, **Delivery retries** sets how many extra in-process attempts (0 to 3) Sencho makes after a transient delivery failure on that node. The default is `0` (single-shot). Extra attempts wait a fixed one second between tries. Admin role is required to change the value.
Each Discord, Slack, and Webhook tab carries an **Enabled** toggle, a **Webhook URL** input (HTTPS only), and **Test** / **Save**. The Apprise tab uses an **Apprise endpoint** instead: keyed `/notify/{key}` shows optional **Tags**; stateless `/notify` shows **Destination URLs**. The kicker on each tab toggles between `enabled` and `off` so you can see at a glance which slots are wired up.
Each Discord, Slack, and Webhook tab carries an **Enabled** toggle, a **Webhook URL** input (HTTPS only), and **Test** / **Save**. The Apprise tab uses an **Apprise endpoint**: keyed `/notify/{key}` shows optional **Tags**; stateless `/notify` shows **Destination URLs**. The ntfy tab accepts an ntfy server and topic URL with an **Enabled** toggle and **Test** / **Save**. The kicker on each tab toggles between `enabled` and `off` so you can see at a glance which slots are wired up.
<Note>
Discord, Slack, and webhook URLs must use HTTPS. Apprise endpoints may use HTTP or HTTPS. Every endpoint must parse as a URL.
Discord, Slack, and webhook URLs must use HTTPS. Apprise and ntfy endpoints may use HTTP or HTTPS. Every endpoint must parse as a URL.
</Note>
### Discord
@@ -85,7 +85,7 @@ Open **Settings · Notifications · Notification Routing** and click **+ Add Rou
| **Labels** *(optional)* | A combobox of stack labels on the active node. Empty matches any label. |
| **Categories** *(optional)* | A combobox of notification categories. The helper line reads `Leave blank to match all categories. All non-empty filters must match (AND).` |
| **Severity** *(optional)* | One or more of info, warning, or error. Empty matches any severity. |
| **Channel** | Tabs for Discord, Slack, Webhook, and Apprise. Discord, Slack, and Webhook require HTTPS. Apprise accepts HTTP or HTTPS. |
| **Channel** | Tabs for Discord, Slack, Webhook, Apprise, and ntfy. Discord, Slack, and Webhook require HTTPS. Apprise and ntfy accept HTTP or HTTPS. |
| **Priority** | A number used to sort the rule list. Lower numbers appear higher up. Priority does not gate dispatch: when multiple rules match the same alert, every matching rule fires concurrently. |
| **Enabled** | Toggle the rule on or off without deleting it. |
@@ -447,7 +447,7 @@ Switching the active node tears down per-stack rule editors and reloads channel
<AccordionGroup>
<Accordion title="Notifications never arrive">
Check three things in order. First, the channel toggle in **Settings · Notifications · Channels** must be on; the kicker on each tab reads `enabled` or `off`. Second, Discord, Slack, and webhook URLs must use HTTPS (the form rejects plain `http://` for those channels); Apprise endpoints may use HTTP or HTTPS. Third, a routing rule with unconstrained Node plus empty Stacks, Labels, Categories, and Severity matchers will intercept every alert and skip the global channels. Use the per-channel **Test** button to issue a one-shot dispatch and watch your endpoint for the literal message `🔌 Test Notification from Sencho!` Sencho records the failure reason in `notification_history.dispatch_error` when delivery throws, so a row that appears in the bell with no follow-up at the endpoint usually means a 4xx or timeout at the receiver.
Check three things in order. First, the channel toggle in **Settings · Notifications · Channels** must be on; the kicker on each tab reads `enabled` or `off`. Second, Discord, Slack, and webhook URLs must use HTTPS (the form rejects plain `http://` for those channels); Apprise and ntfy endpoints may use HTTP or HTTPS. Third, a routing rule with unconstrained Node plus empty Stacks, Labels, Categories, and Severity matchers will intercept every alert and skip the global channels. Use the per-channel **Test** button to issue a one-shot dispatch and watch your endpoint for the literal message `🔌 Test Notification from Sencho!` Sencho records the failure reason in `notification_history.dispatch_error` when delivery throws, so a row that appears in the bell with no follow-up at the endpoint usually means a 4xx or timeout at the receiver.
</Accordion>
<Accordion title="An alert rule never fires even when the threshold is breached">
Three causes account for almost every case. First, the rule's **Duration** has not elapsed yet: that container's breach must persist for the full duration before the rule fires (a healthy sibling service does not clear another container's timer). Second, the same Compose service is still in cooldown after a previous fire. Third, the panel's banner is not green: a remote-node banner means the rule was saved on a remote whose channels you may not have configured, and an amber `No notification channels configured` banner means the rule evaluates fine but Sencho has nowhere to send the alert. The evaluator runs on a 30-second tick, so expect up to 30 seconds of latency between the breach starting and the timer engaging.
+2 -2
View File
@@ -401,7 +401,7 @@ Quick reference:
**Scope:** Per-node (each node has its own notification agents; remote nodes dispatch alerts through their own channels)
Configure external destinations for alert notifications. Four agent types are available on separate tabs: **Discord**, **Slack**, **Webhook**, and **Apprise**. The masthead publishes a **CHANNELS** pill showing how many agents are enabled (for example, `2/4`).
Configure external destinations for alert notifications. Five agent types are available on separate tabs: **Discord**, **Slack**, **Webhook**, **Apprise**, and **ntfy**. The masthead publishes a **CHANNELS** pill showing how many agents are enabled (for example, `2/5`).
Below the channel tabs, **Delivery retries** (admin-only) sets how many extra in-process attempts (0 to 3, default 0) this node makes after a transient channel failure, with a fixed one-second delay between attempts. There is no durable queue; ambiguous network failures can produce duplicate notifications.
@@ -437,7 +437,7 @@ Create routing rules that direct specific alert types to specific notification c
| **Labels** | Apply this rule to stacks that carry any of the selected labels. |
| **Categories** | Event categories that trigger the rule (crash, deploy, vulnerability, etc.). |
| **Severity** | Info, warning, and/or error levels. Empty matches any severity. |
| **Channel type** | `discord`, `slack`, `webhook`, or `apprise`. |
| **Channel type** | `discord`, `slack`, `webhook`, `apprise`, or `ntfy`. |
| **Channel URL** | The destination endpoint for this rule. |
| **Priority** | Sort order among matching rules. Every matching route fires; priority does not stop later matches. |
| **Enabled** toggle | Mute a rule without deleting it. |
+2 -1
View File
@@ -112,6 +112,7 @@ const agentTypeLabels: Record<string, string> = {
slack: 'Slack',
webhook: 'Webhook',
apprise: 'Apprise',
ntfy: 'ntfy',
};
const clampNonNegative = (setter: (v: string) => void) => (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -400,7 +401,7 @@ function AlertsTab({ stackName, initialService }: { stackName: string; initialSe
<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, Apprise, or a webhook in{' '}
Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, Apprise, ntfy, or a webhook in{' '}
<span className="font-medium">Settings &rarr; Notifications</span>.
</p>
</div>
@@ -116,12 +116,13 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
const { notifications, automation, security, thresholds, backup } = status;
const agentSummary = (() => {
const { discord, slack, webhook, apprise } = normalizeConfigurationAgents(notifications.agents);
const { discord, slack, webhook, apprise, ntfy } = normalizeConfigurationAgents(notifications.agents);
const active = [
discord.enabled ? 'Discord' : null,
slack.enabled ? 'Slack' : null,
webhook.enabled ? 'Webhook' : null,
apprise.enabled ? 'Apprise' : null,
ntfy.enabled ? 'ntfy' : null,
].filter(Boolean);
return active.length === 0 ? 'None' : active.join(', ');
})();
@@ -18,6 +18,7 @@ function makePayload(overrides: Partial<ConfigurationStatusPayload> = {}): Confi
slack: { configured: false, enabled: false },
webhook: { configured: false, enabled: false },
apprise: { configured: false, enabled: false },
ntfy: { configured: false, enabled: false },
},
alertRules: 0,
routingRules: { count: 0, enabledCount: 0, locked: true },
@@ -90,6 +91,7 @@ describe('ConfigurationStatus row visibility', () => {
slack: { configured: false, enabled: false },
webhook: { configured: false, enabled: false },
apprise: { configured: false, enabled: false },
ntfy: { configured: false, enabled: false },
},
alertRules: 2,
routingRules: { count: 1, enabledCount: 1, locked: false },
@@ -198,4 +200,13 @@ describe('ConfigurationStatus legacy remote agents', () => {
render(<ConfigurationStatus />);
expect(screen.getByText('Discord')).toBeDefined();
});
it('renders a payload that omits ntfy without throwing', () => {
const legacy = makePayload();
delete (legacy.notifications.agents as { ntfy?: unknown }).ntfy;
useConfigurationStatusMock.mockReturnValue({ status: legacy, loading: false });
expect(() => render(<ConfigurationStatus />)).not.toThrow();
const channelsRow = screen.getByText('Channels').closest('button');
expect(channelsRow?.textContent).toContain('None');
});
});
@@ -11,7 +11,7 @@ const INVALIDATE_DEBOUNCE_MS = 250;
export interface ConfigurationStatus {
tier: 'community' | 'paid';
notifications: {
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus; apprise: AgentStatus };
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus; apprise: AgentStatus; ntfy: AgentStatus };
alertRules: number;
routingRules: { count: number; enabledCount: number; locked: boolean };
suppressionRules: { total: number; enabledCount: number };
@@ -52,6 +52,7 @@ type WireConfigurationStatus = Omit<ConfigurationStatus, 'notifications'> & {
slack: AgentStatus;
webhook: AgentStatus;
apprise?: AgentStatus;
ntfy?: AgentStatus;
};
};
};
@@ -122,6 +122,7 @@ function NodeCard({ node, policySyncState }: {
agents.discord.enabled,
agents.slack.enabled,
agents.webhook.enabled,
agents.ntfy.enabled,
agents.apprise.enabled,
].filter(Boolean).length;
@@ -36,7 +36,7 @@ interface NotificationRoute {
label_ids: number[] | null;
categories: NotificationCategory[] | null;
levels: NotificationLevel[] | null;
channel_type: 'discord' | 'slack' | 'webhook' | 'apprise';
channel_type: 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy';
channel_url: string;
config: { mode: 'keyed' | 'stateless'; tags?: string; has_urls: boolean; providers?: string[]; url_count?: number } | null;
priority: number;
@@ -56,6 +56,7 @@ const CHANNEL_LABELS: Record<string, string> = {
slack: 'Slack',
webhook: 'Webhook',
apprise: 'Apprise',
ntfy: 'ntfy',
};
const CHANNEL_PLACEHOLDERS: Record<string, string> = {
@@ -63,6 +64,7 @@ const CHANNEL_PLACEHOLDERS: Record<string, string> = {
slack: 'https://hooks.slack.com/services/...',
webhook: 'https://example.com/webhook',
apprise: 'http://apprise.local/notify',
ntfy: 'https://ntfy.sh/mytopic',
};
export function NotificationRoutingSection() {
@@ -85,7 +87,7 @@ export function NotificationRoutingSection() {
const [formLabelIds, setFormLabelIds] = useState<number[]>([]);
const [formCategories, setFormCategories] = useState<NotificationCategory[]>([]);
const [formLevels, setFormLevels] = useState<NotificationLevel[]>([]);
const [formChannelType, setFormChannelType] = useState<'discord' | 'slack' | 'webhook' | 'apprise'>('discord');
const [formChannelType, setFormChannelType] = useState<'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy'>('discord');
const patternChipsRef = useRef<PatternChipsHandle>(null);
const [formChannelUrl, setFormChannelUrl] = useState('');
const [formAppriseUrls, setFormAppriseUrls] = useState('');
@@ -94,7 +96,7 @@ export function NotificationRoutingSection() {
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 [editOriginalChannelType, setEditOriginalChannelType] = useState<'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy' | null>(null);
const [formPriority, setFormPriority] = useState(0);
const [formEnabled, setFormEnabled] = useState(true);
@@ -189,8 +191,8 @@ export function NotificationRoutingSection() {
toast.error('Fix invalid stack patterns before saving.');
return;
}
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.');
if (!formChannelUrl.trim() || (formChannelType !== 'apprise' && formChannelType !== 'ntfy' && !formChannelUrl.startsWith('https://'))) {
toast.error(formChannelType === 'apprise' ? 'Enter a valid Apprise endpoint.' : formChannelType === 'ntfy' ? 'Enter a valid ntfy server and topic URL.' : 'Channel URL must be a valid HTTPS URL.');
return;
}
@@ -543,7 +545,7 @@ export function NotificationRoutingSection() {
<Tabs
value={formChannelType}
onValueChange={(v) => {
const next = v as 'discord' | 'slack' | 'webhook' | 'apprise';
const next = v as 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy';
if (next !== formChannelType) {
// Type change replaces credentials; never carry a redacted prior URL across types.
setFormChannelUrl('');
@@ -555,7 +557,7 @@ export function NotificationRoutingSection() {
setFormChannelType(next);
}}
>
<TabsList className="w-full grid grid-cols-4">
<TabsList className="w-full grid grid-cols-5">
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
<TabsHighlightItem value="discord">
<TabsTrigger value="discord">Discord</TabsTrigger>
@@ -569,6 +571,9 @@ export function NotificationRoutingSection() {
<TabsHighlightItem value="apprise">
<TabsTrigger value="apprise">Apprise</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="ntfy">
<TabsTrigger value="ntfy">ntfy</TabsTrigger>
</TabsHighlightItem>
</TabsHighlight>
</TabsList>
</Tabs>
@@ -20,7 +20,7 @@ import { classifyAppriseEndpoint, isKeyedAppriseEndpoint, isStatelessAppriseEndp
import { canManageNode } from '@/lib/canManageNode';
import { parseNotificationDispatchRetries } from '@/lib/notificationDispatchRetries';
type ChannelType = 'discord' | 'slack' | 'webhook' | 'apprise';
type ChannelType = 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy';
function emptyAgents(): Record<ChannelType, Agent> {
return {
@@ -28,6 +28,7 @@ function emptyAgents(): Record<ChannelType, Agent> {
slack: { type: 'slack', url: '', enabled: false },
webhook: { type: 'webhook', url: '', enabled: false },
apprise: { type: 'apprise', url: '', enabled: false, config: null },
ntfy: { type: 'ntfy', url: '', enabled: false },
};
}
@@ -207,7 +208,7 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp
useMastheadStats([
{
label: 'CHANNELS',
value: `${enabledCount}/4`,
value: `${enabledCount}/5`,
tone: enabledCount > 0 ? 'value' : 'subtitle',
},
...(retriesDirty
@@ -380,13 +381,13 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp
/>
</SettingsField>
<SettingsField
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.'}
label={type === 'apprise' ? 'Apprise endpoint' : type === 'ntfy' ? 'ntfy server and topic URL' : 'Webhook URL'}
helper={type === 'apprise' ? 'Use /notify/{key} for keyed delivery or /notify with destination URLs below.' : type === 'ntfy' ? 'Sencho posts a plain-text message. The URL must include the topic path.' : 'Sencho posts JSON payloads here. Use a private channel.'}
htmlFor={`${type}-url`}
>
<Input
id={`${type}-url`}
placeholder={type === 'apprise' ? 'http://apprise.local/notify' : 'https://...'}
placeholder={type === 'apprise' ? 'http://apprise.local/notify' : type === 'ntfy' ? 'https://ntfy.sh/mytopic' : 'https://...'}
value={agents[type].url}
onChange={(e) => {
if (type === 'apprise') setAppriseUrlDirty(true);
@@ -462,7 +463,7 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp
return (
<div className="flex flex-col gap-6">
<Tabs value={notifTab} onValueChange={(v) => setNotifTab(v as ChannelType)} className="w-full">
<TabsList className="w-full mb-4 grid grid-cols-4">
<TabsList className="w-full mb-4 grid grid-cols-5">
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
<TabsHighlightItem value="discord">
<TabsTrigger value="discord">Discord</TabsTrigger>
@@ -476,12 +477,16 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp
<TabsHighlightItem value="apprise">
<TabsTrigger value="apprise">Apprise</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="ntfy">
<TabsTrigger value="ntfy">ntfy</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>
<TabsContent value="ntfy">{renderAgentTab('ntfy', 'ntfy')}</TabsContent>
</Tabs>
<fieldset disabled={readOnly} className="min-w-0 border-0 p-0 m-0">
<SettingsSection title="Delivery retries" kicker={retriesKicker}>
@@ -99,7 +99,7 @@ describe('NotificationsSection', () => {
it('reports CHANNELS as n/4 in the masthead', async () => {
render(<NotificationsSection />);
await waitFor(() => expect(masthead.last?.[0]?.value).toBe('1/4'));
await waitFor(() => expect(masthead.last?.[0]?.value).toBe('1/5'));
expect(masthead.last?.[0]?.label).toBe('CHANNELS');
});
@@ -346,11 +346,11 @@ describe('NotificationsSection', () => {
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');
expect(masthead.last?.[0]?.value).toBe('1/5');
nodeState.activeNode = { id: 2 };
rerender(<NotificationsSection />);
await waitFor(() => expect(masthead.last?.[0]?.value).toBe('0/4'));
await waitFor(() => expect(masthead.last?.[0]?.value).toBe('0/5'));
await userEvent.click(await screen.findByRole('tab', { name: 'Apprise' }));
await waitFor(() => expect(screen.getByLabelText(/Apprise endpoint/i)).toHaveValue(''));
});
@@ -386,21 +386,21 @@ describe('NotificationsSection', () => {
nodeState.activeNode = { id: 2 };
rerender(<NotificationsSection />);
await waitFor(() => expect(masthead.last?.[0]?.value).toBe('0/4'));
await waitFor(() => expect(masthead.last?.[0]?.value).toBe('0/5'));
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');
expect(masthead.last?.[0]?.value).toBe('0/5');
await userEvent.click(await screen.findByRole('tab', { name: 'Apprise' }));
expect(screen.getByLabelText(/Apprise endpoint/i)).toHaveValue('');
});
it('preserves CHANNELS masthead and loads retries with explicit nodeId', async () => {
render(<NotificationsSection />);
await waitFor(() => expect(masthead.last?.[0]).toMatchObject({ label: 'CHANNELS', value: '1/4' }));
await waitFor(() => expect(masthead.last?.[0]).toMatchObject({ label: 'CHANNELS', value: '1/5' }));
await waitFor(() =>
expect(mockedFetch.mock.calls.some(
([url, opts]) => url === '/settings' && (opts as { nodeId?: number })?.nodeId === 1,
+2 -2
View File
@@ -209,8 +209,8 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
id: 'notifications',
group: 'notifications',
label: 'Channels',
description: 'Discord, Slack, Apprise, and custom webhook destinations for Sencho alerts.',
keywords: ['discord', 'slack', 'apprise', 'webhook', 'channels', 'destinations', 'alerts', 'retry', 'retries'],
description: 'Discord, Slack, Apprise, ntfy, and custom webhook destinations for Sencho alerts.',
keywords: ['discord', 'slack', 'apprise', 'webhook', 'ntfy', 'channels', 'destinations', 'alerts', 'retry', 'retries'],
tier: null,
scope: 'node',
},
+1 -1
View File
@@ -87,7 +87,7 @@ export type SectionId =
| 'about';
export interface Agent {
type: 'discord' | 'slack' | 'webhook' | 'apprise';
type: 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy';
url: string;
enabled: boolean;
config?: { mode?: 'keyed' | 'stateless'; tags?: string; urls?: string; has_urls?: boolean; providers?: string[]; url_count?: number } | null;
+6 -2
View File
@@ -10,22 +10,26 @@ export type ConfigurationAgents = {
slack: AgentStatus;
webhook: AgentStatus;
apprise: AgentStatus;
ntfy: AgentStatus;
};
/**
* Older remotes omit `apprise`. Treat a missing slot as unconfigured/disabled so
* upgraded hubs do not throw when reading mixed-version fleet/dashboard payloads.
* Older remotes omit `apprise` or `ntfy`. Treat missing slots as
* unconfigured/disabled so upgraded hubs do not throw when reading
* mixed-version fleet/dashboard payloads.
*/
export function normalizeConfigurationAgents(agents: {
discord: AgentStatus;
slack: AgentStatus;
webhook: AgentStatus;
apprise?: AgentStatus;
ntfy?: AgentStatus;
}): ConfigurationAgents {
return {
discord: agents.discord,
slack: agents.slack,
webhook: agents.webhook,
apprise: agents.apprise ?? { configured: false, enabled: false },
ntfy: agents.ntfy ?? { configured: false, enabled: false },
};
}