mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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:
@@ -124,3 +124,122 @@ describe('POST /api/agents', () => {
|
||||
expect(Boolean(agents[0].enabled)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Apprise agents - redaction and preserve-on-write', () => {
|
||||
const keyedUrl = 'http://apprise.local/notify/key-secret-value';
|
||||
const serviceUrl = 'discord://webhook-id/webhook-token?token=query-secret';
|
||||
|
||||
it('GET redacts keyed endpoint and never returns destination secrets', async () => {
|
||||
DatabaseService.getInstance().upsertAgent(1, {
|
||||
type: 'apprise',
|
||||
url: keyedUrl,
|
||||
enabled: true,
|
||||
config: JSON.stringify({ tags: 'ops' }),
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/agents').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body[0].url).toBe('http://apprise.local/notify/<redacted>');
|
||||
expect(res.body[0].config).toMatchObject({ mode: 'keyed', tags: 'ops', has_urls: false });
|
||||
expect(res.body[0].secrets_redacted).toBe(true);
|
||||
expect(JSON.stringify(res.body)).not.toContain('key-secret-value');
|
||||
});
|
||||
|
||||
it('GET for stateless mode exposes providers and url_count only', async () => {
|
||||
DatabaseService.getInstance().upsertAgent(1, {
|
||||
type: 'apprise',
|
||||
url: 'http://apprise.local/notify',
|
||||
enabled: true,
|
||||
config: JSON.stringify({ urls: serviceUrl }),
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/agents').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body[0].url).toBe('http://apprise.local/notify');
|
||||
expect(res.body[0].config).toMatchObject({
|
||||
mode: 'stateless',
|
||||
has_urls: true,
|
||||
providers: ['discord'],
|
||||
url_count: 1,
|
||||
});
|
||||
expect(JSON.stringify(res.body)).not.toContain('webhook-token');
|
||||
expect(JSON.stringify(res.body)).not.toContain('query-secret');
|
||||
});
|
||||
|
||||
it('rejects posting a redacted endpoint URL', async () => {
|
||||
DatabaseService.getInstance().upsertAgent(1, {
|
||||
type: 'apprise',
|
||||
url: keyedUrl,
|
||||
enabled: true,
|
||||
config: JSON.stringify({ tags: 'ops' }),
|
||||
});
|
||||
|
||||
const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
|
||||
type: 'apprise',
|
||||
url: 'http://apprise.local/notify/<redacted>',
|
||||
enabled: true,
|
||||
config: { tags: 'ops' },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects public DTO config shapes on write', async () => {
|
||||
const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
|
||||
type: 'apprise',
|
||||
url: 'http://apprise.local/notify',
|
||||
enabled: true,
|
||||
config: { mode: 'stateless', has_urls: true, providers: ['discord'], url_count: 1 },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('preserves stored secrets when url and config are omitted', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.upsertAgent(1, {
|
||||
type: 'apprise',
|
||||
url: keyedUrl,
|
||||
enabled: true,
|
||||
config: JSON.stringify({ tags: 'ops' }),
|
||||
});
|
||||
|
||||
const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
|
||||
type: 'apprise',
|
||||
enabled: false,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const agents = db.getAgents(1);
|
||||
expect(agents[0].url).toBe(keyedUrl);
|
||||
expect(agents[0].config).toBe(JSON.stringify({ tags: 'ops' }));
|
||||
expect(Boolean(agents[0].enabled)).toBe(false);
|
||||
});
|
||||
|
||||
it('creates a keyed agent with no config and persists {}', async () => {
|
||||
const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
|
||||
type: 'apprise',
|
||||
url: 'http://apprise.local/notify/new-key',
|
||||
enabled: true,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const agent = DatabaseService.getInstance().getAgents(1).find(a => a.type === 'apprise');
|
||||
expect(agent?.url).toBe('http://apprise.local/notify/new-key');
|
||||
expect(agent?.config).toBe('{}');
|
||||
});
|
||||
|
||||
it('rejects preserve-on-write when stored Apprise config is malformed', async () => {
|
||||
DatabaseService.getInstance().upsertAgent(1, {
|
||||
type: 'apprise',
|
||||
url: keyedUrl,
|
||||
enabled: true,
|
||||
config: '{not-json',
|
||||
});
|
||||
|
||||
const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
|
||||
type: 'apprise',
|
||||
enabled: false,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid/i);
|
||||
expect(JSON.stringify(res.body)).not.toContain('key-secret-value');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -242,7 +242,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');
|
||||
expect(res.body.error).toContain('discord, slack, webhook, apprise');
|
||||
});
|
||||
|
||||
it('rejects missing type with 400', async () => {
|
||||
@@ -277,4 +277,77 @@ describe('POST /api/notifications/test', () => {
|
||||
.send({ type: 'discord', url: 'https://' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('dispatches keyed Apprise test with exact payload', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/test')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ type: 'apprise', url: 'http://apprise.local/notify/test-key', config: { tags: 'ops' } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://apprise.local/notify/test-key',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: 'Sencho Alert [INFO]',
|
||||
body: '🔌 Test Notification from Sencho!',
|
||||
type: 'info',
|
||||
tag: 'ops',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('dispatches stateless Apprise test with urls and rejects scheme-less tokens', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
const bad = await request(app)
|
||||
.post('/api/notifications/test')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ type: 'apprise', url: 'http://apprise.local/notify', config: { urls: 'no-scheme' } });
|
||||
expect(bad.status).toBe(400);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
const ok = await request(app)
|
||||
.post('/api/notifications/test')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ type: 'apprise', url: 'http://apprise.local/notify', config: { urls: 'discord://token' } });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://apprise.local/notify',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
title: 'Sencho Alert [INFO]',
|
||||
body: '🔌 Test Notification from Sencho!',
|
||||
type: 'info',
|
||||
urls: 'discord://token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns sanitized details on Apprise 204 without leaking secrets', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204 }));
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/test')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ type: 'apprise', url: 'http://apprise.local/notify/secret-key', config: {} });
|
||||
expect(res.status).toBe(500);
|
||||
expect(JSON.stringify(res.body)).toContain('HTTP 204');
|
||||
expect(JSON.stringify(res.body)).not.toContain('secret-key');
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Downgrade-safety: Apprise url/config must be encrypted at rest so a
|
||||
* pre-Apprise binary's SELECT * + unsanitized GET cannot return raw secrets.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
import { serializePublicAgent, serializePublicNotificationRoute } from '../helpers/notificationChannels';
|
||||
|
||||
let tmpDir: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
beforeEach(() => {
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
db.prepare('DELETE FROM agents').run();
|
||||
db.prepare('DELETE FROM notification_routes').run();
|
||||
});
|
||||
|
||||
describe('Apprise secrets at rest (downgrade-safe)', () => {
|
||||
it('stores keyed agent secrets as ciphertext; SELECT * does not leak the notify key', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const keySecret = 'SuperSecretKey99';
|
||||
|
||||
db.upsertAgent(1, {
|
||||
type: 'apprise',
|
||||
url: `http://apprise.local/notify/${keySecret}`,
|
||||
enabled: true,
|
||||
config: '{}',
|
||||
});
|
||||
|
||||
const raw = db.getDb().prepare('SELECT * FROM agents WHERE type = ?').get('apprise') as {
|
||||
url: string;
|
||||
config: string | null;
|
||||
};
|
||||
expect(raw.url).toMatch(/^enc:/);
|
||||
expect(raw.url).not.toContain(keySecret);
|
||||
expect(raw.url).not.toContain('apprise.local');
|
||||
expect(raw.config).toMatch(/^enc:/);
|
||||
|
||||
const apprise = db.getAgents(1).find(a => a.type === 'apprise')!;
|
||||
expect(apprise.url).toBe(`http://apprise.local/notify/${keySecret}`);
|
||||
const pub = serializePublicAgent(apprise);
|
||||
expect(pub.secrets_redacted).toBe(true);
|
||||
expect(pub.url).toContain('<redacted>');
|
||||
expect(JSON.stringify(pub)).not.toContain(keySecret);
|
||||
});
|
||||
|
||||
it('stores stateless destination URLs as ciphertext; SELECT * does not leak credentials', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const destSecret = 'mailto://user:smtp-password@example.com';
|
||||
|
||||
db.upsertAgent(1, {
|
||||
type: 'apprise',
|
||||
url: 'http://apprise.local/notify',
|
||||
enabled: true,
|
||||
config: JSON.stringify({ urls: destSecret }),
|
||||
});
|
||||
|
||||
const raw = db.getDb().prepare('SELECT * FROM agents WHERE type = ?').get('apprise') as {
|
||||
url: string;
|
||||
config: string | null;
|
||||
};
|
||||
expect(raw.url).toMatch(/^enc:/);
|
||||
expect(raw.config).toMatch(/^enc:/);
|
||||
expect(raw.config).not.toContain(destSecret);
|
||||
expect(raw.config).not.toContain('smtp-password');
|
||||
|
||||
const apprise = db.getAgents(1).find(a => a.type === 'apprise')!;
|
||||
expect(apprise.url).toBe('http://apprise.local/notify');
|
||||
expect(apprise.config).toBe(JSON.stringify({ urls: destSecret }));
|
||||
const pub = serializePublicAgent(apprise);
|
||||
expect(pub.secrets_redacted).toBe(true);
|
||||
expect(JSON.stringify(pub)).not.toContain(destSecret);
|
||||
});
|
||||
|
||||
it('encrypts Apprise route channel_url and config the same way', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const keySecret = 'RouteKey_01';
|
||||
const now = Date.now();
|
||||
const route = db.createNotificationRoute({
|
||||
name: 'apprise-route',
|
||||
node_id: null,
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
channel_type: 'apprise',
|
||||
channel_url: `http://apprise.local/notify/${keySecret}`,
|
||||
config: '{}',
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
|
||||
const raw = db.getDb().prepare('SELECT * FROM notification_routes WHERE id = ?').get(route.id) as {
|
||||
channel_url: string;
|
||||
config: string | null;
|
||||
};
|
||||
expect(raw.channel_url).toMatch(/^enc:/);
|
||||
expect(raw.channel_url).not.toContain(keySecret);
|
||||
expect(raw.config).toMatch(/^enc:/);
|
||||
|
||||
const loaded = db.getNotificationRoute(route.id)!;
|
||||
expect(loaded.channel_url).toBe(`http://apprise.local/notify/${keySecret}`);
|
||||
const pub = serializePublicNotificationRoute(loaded);
|
||||
expect(pub.secrets_redacted).toBe(true);
|
||||
expect(pub.channel_url).toContain('<redacted>');
|
||||
expect(JSON.stringify(pub)).not.toContain(keySecret);
|
||||
});
|
||||
|
||||
it('does not encrypt Discord agent URLs (unchanged channel behavior)', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const url = 'https://discord.com/api/webhooks/123/plaintext-token';
|
||||
db.upsertAgent(1, { type: 'discord', url, enabled: true });
|
||||
const raw = db.getDb().prepare('SELECT * FROM agents WHERE type = ?').get('discord') as { url: string };
|
||||
expect(raw.url).toBe(url);
|
||||
expect(raw.url.startsWith('enc:')).toBe(false);
|
||||
const pub = serializePublicAgent(db.getAgents(1).find(a => a.type === 'discord')!);
|
||||
expect(pub.secrets_redacted).toBe(false);
|
||||
expect(pub.url).toBe(url);
|
||||
});
|
||||
|
||||
it('isolates corrupt Apprise ciphertext so sibling channels still load and Apprise can be repaired', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const discordUrl = 'https://discord.com/api/webhooks/123/plaintext-token';
|
||||
db.upsertAgent(1, { type: 'discord', url: discordUrl, enabled: true });
|
||||
db.upsertAgent(1, {
|
||||
type: 'apprise',
|
||||
url: 'http://apprise.local/notify/good-key',
|
||||
enabled: true,
|
||||
config: '{}',
|
||||
});
|
||||
|
||||
// Simulate backup-restore / key-rotation damage: enc: prefix with invalid payload.
|
||||
db.getDb().prepare('UPDATE agents SET url = ?, config = ? WHERE type = ?').run(
|
||||
'enc:000000000000000000000000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:bbbb',
|
||||
'enc:000000000000000000000000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:bbbb',
|
||||
'apprise',
|
||||
);
|
||||
|
||||
const agents = db.getAgents(1);
|
||||
expect(agents.find(a => a.type === 'discord')!.url).toBe(discordUrl);
|
||||
const broken = agents.find(a => a.type === 'apprise')!;
|
||||
expect(broken.url).toBe('');
|
||||
expect(broken.config).toBeNull();
|
||||
|
||||
const enabled = db.getEnabledAgents(1);
|
||||
expect(enabled.some(a => a.type === 'discord')).toBe(true);
|
||||
|
||||
db.upsertAgent(1, {
|
||||
type: 'apprise',
|
||||
url: 'http://apprise.local/notify/repaired-key',
|
||||
enabled: true,
|
||||
config: '{}',
|
||||
});
|
||||
expect(db.getAgents(1).find(a => a.type === 'apprise')!.url).toBe(
|
||||
'http://apprise.local/notify/repaired-key',
|
||||
);
|
||||
});
|
||||
|
||||
it('isolates corrupt Apprise route ciphertext from the routes list', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const now = Date.now();
|
||||
db.createNotificationRoute({
|
||||
name: 'discord-ok',
|
||||
node_id: null,
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
channel_type: 'discord',
|
||||
channel_url: 'https://discord.com/api/webhooks/1/token',
|
||||
config: null,
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
const apprise = db.createNotificationRoute({
|
||||
name: 'apprise-broken',
|
||||
node_id: null,
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
channel_type: 'apprise',
|
||||
channel_url: 'http://apprise.local/notify/route-key',
|
||||
config: '{}',
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
db.getDb().prepare('UPDATE notification_routes SET channel_url = ? WHERE id = ?').run(
|
||||
'enc:000000000000000000000000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:bbbb',
|
||||
apprise.id,
|
||||
);
|
||||
|
||||
const routes = db.getNotificationRoutes();
|
||||
expect(routes.find(r => r.name === 'discord-ok')!.channel_url).toContain('discord.com');
|
||||
expect(routes.find(r => r.name === 'apprise-broken')!.channel_url).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normalizeConfigurationAgents, normalizeRemoteConfigurationStatus } from '../helpers/configurationStatus';
|
||||
|
||||
describe('normalizeConfigurationAgents', () => {
|
||||
it('defaults missing apprise 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.discord.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves an explicit apprise slot', () => {
|
||||
const normalized = normalizeConfigurationAgents({
|
||||
discord: { configured: false, enabled: false },
|
||||
slack: { configured: false, enabled: false },
|
||||
webhook: { configured: false, enabled: false },
|
||||
apprise: { configured: true, enabled: true },
|
||||
});
|
||||
expect(normalized.apprise).toEqual({ configured: true, enabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRemoteConfigurationStatus', () => {
|
||||
it('passes through stub payloads without notifications.agents', () => {
|
||||
const stub = { ssoConfigured: false, alertsConfigured: true };
|
||||
expect(normalizeRemoteConfigurationStatus(stub)).toEqual(stub);
|
||||
});
|
||||
|
||||
it('fills missing apprise when an agents block is present', () => {
|
||||
const raw = {
|
||||
notifications: {
|
||||
agents: {
|
||||
discord: { configured: true, enabled: true },
|
||||
slack: { configured: false, enabled: false },
|
||||
webhook: { configured: false, enabled: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
const normalized = normalizeRemoteConfigurationStatus(raw);
|
||||
expect(normalized.notifications.agents).toEqual({
|
||||
discord: { configured: true, enabled: true },
|
||||
slack: { configured: false, enabled: false },
|
||||
webhook: { configured: false, enabled: false },
|
||||
apprise: { configured: false, enabled: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -54,7 +54,12 @@ describe('GET /api/dashboard/configuration', () => {
|
||||
expect(res.body).toMatchObject({
|
||||
tier: expect.any(String),
|
||||
notifications: {
|
||||
agents: { discord: { configured: expect.any(Boolean) }, slack: { configured: expect.any(Boolean) }, webhook: { configured: expect.any(Boolean) } },
|
||||
agents: {
|
||||
discord: { configured: expect.any(Boolean) },
|
||||
slack: { configured: expect.any(Boolean) },
|
||||
webhook: { configured: expect.any(Boolean) },
|
||||
apprise: { configured: expect.any(Boolean) },
|
||||
},
|
||||
alertRules: expect.any(Number),
|
||||
routingRules: { count: expect.any(Number), enabledCount: expect.any(Number), locked: expect.any(Boolean) },
|
||||
},
|
||||
|
||||
@@ -108,4 +108,29 @@ describe('conditionalJsonParser remote-proxy bypass', () => {
|
||||
expect(lastUpstreamAuth).toBeNull();
|
||||
expect([200, 404]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('forwards Apprise agent config bodies intact to the remote', async () => {
|
||||
lastUpstreamBody = null;
|
||||
lastUpstreamAuth = null;
|
||||
|
||||
const payload = {
|
||||
type: 'apprise',
|
||||
url: 'http://apprise.local/notify',
|
||||
enabled: true,
|
||||
config: { urls: 'discord://webhook-id/webhook-token?token=query-secret' },
|
||||
};
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/agents')
|
||||
.set('Authorization', authHeader)
|
||||
.set('x-node-id', String(remoteNodeId))
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(payload);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(lastUpstreamBody).not.toBeNull();
|
||||
expect(JSON.parse(lastUpstreamBody!.toString('utf-8'))).toEqual(payload);
|
||||
expect(lastUpstreamAuth).toBe('Bearer bypass-test-token');
|
||||
expect(JSON.stringify(res.body)).not.toContain('query-secret');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Apprise delivery through NotificationService: payloads, status classes,
|
||||
* fail-closed malformed config, and dispatch_error recording.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const {
|
||||
mockGetEnabledNotificationRoutes,
|
||||
mockGetEnabledNotificationSuppressionRules,
|
||||
mockGetEnabledAgents,
|
||||
mockGetStackLabelIds,
|
||||
mockAddNotificationHistory,
|
||||
mockUpdateNotificationDispatchError,
|
||||
} = 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(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/DatabaseService', () => ({
|
||||
DatabaseService: {
|
||||
getInstance: () => ({
|
||||
getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes,
|
||||
getEnabledNotificationSuppressionRules: mockGetEnabledNotificationSuppressionRules,
|
||||
getEnabledAgents: mockGetEnabledAgents,
|
||||
getStackLabelIds: mockGetStackLabelIds,
|
||||
addNotificationHistory: mockAddNotificationHistory,
|
||||
updateNotificationDispatchError: mockUpdateNotificationDispatchError,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/NodeRegistry', () => ({
|
||||
NodeRegistry: {
|
||||
getInstance: () => ({
|
||||
getDefaultNodeId: () => 1,
|
||||
getComposeDir: () => '/app/compose',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/StackActivityMetricsService', () => ({
|
||||
StackActivityMetricsService: {
|
||||
getInstance: () => ({
|
||||
record: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { NotificationService, NotificationDeliveryError } from '../services/NotificationService';
|
||||
|
||||
const KEYED = 'http://apprise.local/notify/key-secret';
|
||||
const STATELESS = 'http://apprise.local/notify';
|
||||
|
||||
function makeAppriseRoute(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'Apprise route',
|
||||
node_id: null,
|
||||
stack_patterns: ['my-app'],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
channel_type: 'apprise' as const,
|
||||
channel_url: KEYED,
|
||||
config: '{}',
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('NotificationService - Apprise 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 keyed payload without tag when tags are empty', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeAppriseRoute({ config: '{}' })]);
|
||||
await svc.dispatchAlert('warning', 'deploy_failure', 'boom', { stackName: 'my-app' });
|
||||
expect(mockFetch).toHaveBeenCalledWith(KEYED, expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: 'Sencho Alert [WARNING]',
|
||||
body: 'boom',
|
||||
type: 'warning',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('sends keyed tag and maps error to failure', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([
|
||||
makeAppriseRoute({ config: JSON.stringify({ tags: 'ops' }) }),
|
||||
]);
|
||||
await svc.dispatchAlert('error', 'deploy_failure', 'boom', { stackName: 'my-app' });
|
||||
expect(mockFetch).toHaveBeenCalledWith(KEYED, expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
title: 'Sencho Alert [ERROR]',
|
||||
body: 'boom',
|
||||
type: 'failure',
|
||||
tag: 'ops',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('sends stateless urls from stored config via agent fallback', async () => {
|
||||
mockGetEnabledAgents.mockReturnValue([{
|
||||
type: 'apprise',
|
||||
url: STATELESS,
|
||||
enabled: true,
|
||||
config: JSON.stringify({ urls: 'discord://token mailto://a@b.com' }),
|
||||
}]);
|
||||
await svc.dispatchAlert('info', 'deploy_success', 'ok');
|
||||
expect(mockFetch).toHaveBeenCalledWith(STATELESS, expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
title: 'Sencho Alert [INFO]',
|
||||
body: 'ok',
|
||||
type: 'info',
|
||||
urls: 'discord://token mailto://a@b.com',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('treats HTTP 204 as non-retryable failure and records dispatch_error', async () => {
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 204 });
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeAppriseRoute()]);
|
||||
await svc.dispatchAlert('info', 'deploy_success', 'ok', { stackName: 'my-app' });
|
||||
expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith(
|
||||
99,
|
||||
expect.stringContaining('HTTP 204'),
|
||||
);
|
||||
const errText = String(mockUpdateNotificationDispatchError.mock.calls[0][1]);
|
||||
expect(errText).not.toContain('key-secret');
|
||||
});
|
||||
|
||||
it('classifies 4xx as non-retryable and 5xx as retryable via testDispatch', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: false, status: 400 });
|
||||
await expect(svc.testDispatch('apprise', KEYED, {})).rejects.toMatchObject({
|
||||
message: expect.stringContaining('HTTP 400'),
|
||||
retryable: false,
|
||||
} satisfies Partial<NotificationDeliveryError>);
|
||||
|
||||
mockFetch.mockResolvedValueOnce({ ok: false, status: 503 });
|
||||
await expect(svc.testDispatch('apprise', KEYED, {})).rejects.toMatchObject({
|
||||
message: expect.stringContaining('HTTP 503'),
|
||||
retryable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('wraps network failures as retryable sanitized errors', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new TypeError('fetch failed'));
|
||||
await expect(svc.testDispatch('apprise', KEYED, {})).rejects.toMatchObject({
|
||||
message: 'Apprise request failed',
|
||||
retryable: true,
|
||||
status: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not fetch when stored config is malformed and records a sanitized error', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([
|
||||
makeAppriseRoute({ config: '{broken', channel_url: KEYED }),
|
||||
]);
|
||||
await svc.dispatchAlert('info', 'deploy_success', 'ok', { stackName: 'my-app' });
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith(
|
||||
99,
|
||||
expect.stringContaining('Apprise configuration is missing or invalid'),
|
||||
);
|
||||
expect(String(mockUpdateNotificationDispatchError.mock.calls[0][1])).not.toContain('key-secret');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Additive `config` columns on agents and notification_routes.
|
||||
* Exercises production DatabaseService startup against a pre-config schema.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
|
||||
function resetDatabaseSingleton(): void {
|
||||
const holder = DatabaseService as unknown as { instance?: DatabaseService };
|
||||
const existing = holder.instance;
|
||||
if (existing) {
|
||||
try {
|
||||
existing.getDb().close();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
holder.instance = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
describe('notification channel config column migration', () => {
|
||||
let scratchDir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
resetDatabaseSingleton();
|
||||
if (scratchDir) {
|
||||
try {
|
||||
fs.rmSync(scratchDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
scratchDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('adds config columns via DatabaseService startup and preserves legacy row values', () => {
|
||||
scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-apprise-mig-'));
|
||||
const dbPath = path.join(scratchDir, 'sencho.db');
|
||||
const seed = new Database(dbPath);
|
||||
try {
|
||||
seed.exec(`
|
||||
CREATE TABLE agents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER NOT NULL DEFAULT 0,
|
||||
type TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 0
|
||||
);
|
||||
INSERT INTO agents (node_id, type, url, enabled)
|
||||
VALUES (1, 'discord', 'https://discord.example/webhook/legacy', 1);
|
||||
|
||||
CREATE TABLE notification_routes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
channel_type TEXT NOT NULL,
|
||||
channel_url TEXT NOT NULL,
|
||||
stack_patterns TEXT NOT NULL DEFAULT '[]',
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO notification_routes
|
||||
(name, channel_type, channel_url, stack_patterns, priority, enabled, created_at, updated_at)
|
||||
VALUES ('Legacy', 'slack', 'https://hooks.slack.com/services/legacy', '[]', 0, 1, 1, 1);
|
||||
`);
|
||||
} finally {
|
||||
seed.close();
|
||||
}
|
||||
|
||||
process.env.DATA_DIR = scratchDir;
|
||||
resetDatabaseSingleton();
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
const agentCols = db.getDb().prepare('PRAGMA table_info(agents)').all() as Array<{ name: string }>;
|
||||
const routeCols = db.getDb().prepare('PRAGMA table_info(notification_routes)').all() as Array<{ name: string }>;
|
||||
expect(agentCols.filter(c => c.name === 'config')).toHaveLength(1);
|
||||
expect(routeCols.filter(c => c.name === 'config')).toHaveLength(1);
|
||||
|
||||
const agent = db.getDb().prepare('SELECT type, url, config FROM agents WHERE type = ?').get('discord') as {
|
||||
type: string; url: string; config: string | null;
|
||||
};
|
||||
expect(agent.url).toBe('https://discord.example/webhook/legacy');
|
||||
expect(agent.config).toBeNull();
|
||||
|
||||
const route = db.getDb().prepare('SELECT name, channel_url, config FROM notification_routes WHERE name = ?').get('Legacy') as {
|
||||
name: string; channel_url: string; config: string | null;
|
||||
};
|
||||
expect(route.channel_url).toBe('https://hooks.slack.com/services/legacy');
|
||||
expect(route.config).toBeNull();
|
||||
|
||||
// Idempotent reopen: columns stay singular and values stay intact.
|
||||
resetDatabaseSingleton();
|
||||
process.env.DATA_DIR = scratchDir;
|
||||
const db2 = DatabaseService.getInstance();
|
||||
const agentCols2 = db2.getDb().prepare('PRAGMA table_info(agents)').all() as Array<{ name: string }>;
|
||||
expect(agentCols2.filter(c => c.name === 'config')).toHaveLength(1);
|
||||
const agent2 = db2.getDb().prepare('SELECT url, config FROM agents WHERE type = ?').get('discord') as {
|
||||
url: string; config: string | null;
|
||||
};
|
||||
expect(agent2.url).toBe('https://discord.example/webhook/legacy');
|
||||
expect(agent2.config).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,17 @@
|
||||
/**
|
||||
* Unit tests for notification channel helpers. Focused on maskWebhookUrl,
|
||||
* which must never let a channel's embedded auth token reach a log line.
|
||||
* Unit tests for notification channel helpers: masking, Apprise validation,
|
||||
* fail-closed stored parsing, and public DTO redaction.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { maskWebhookUrl } from '../helpers/notificationChannels';
|
||||
import {
|
||||
classifyAppriseConfig,
|
||||
maskWebhookUrl,
|
||||
normalizeAppriseStoredJson,
|
||||
parseStoredAppriseConfig,
|
||||
serializePublicAgent,
|
||||
serializePublicNotificationRoute,
|
||||
validateNotificationChannel,
|
||||
} from '../helpers/notificationChannels';
|
||||
|
||||
describe('maskWebhookUrl', () => {
|
||||
it('redacts the token-bearing path of a Discord webhook URL', () => {
|
||||
@@ -47,3 +55,172 @@ describe('maskWebhookUrl', () => {
|
||||
expect(maskWebhookUrl('not a url')).toBe('<invalid url>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Apprise channel helpers', () => {
|
||||
it('accepts empty keyed configuration (missing, null, or {})', () => {
|
||||
const endpoint = 'http://apprise.local/notify/key';
|
||||
expect(validateNotificationChannel('apprise', endpoint)).toBeNull();
|
||||
expect(validateNotificationChannel('apprise', endpoint, null)).toBeNull();
|
||||
expect(validateNotificationChannel('apprise', endpoint, {})).toBeNull();
|
||||
expect(classifyAppriseConfig(endpoint, null)).toEqual({ mode: 'keyed' });
|
||||
expect(normalizeAppriseStoredJson(endpoint, null)).toBe('{}');
|
||||
expect(normalizeAppriseStoredJson(endpoint, { tags: '' })).toBe('{}');
|
||||
expect(normalizeAppriseStoredJson(endpoint, { tags: 'ops' })).toBe(JSON.stringify({ tags: 'ops' }));
|
||||
});
|
||||
|
||||
it('validates and classifies keyed and stateless configurations', () => {
|
||||
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/key', { tags: 'ops' })).toBeNull();
|
||||
expect(classifyAppriseConfig('http://apprise.local/notify', { urls: 'discord://token slack://token' })).toEqual({
|
||||
mode: 'stateless',
|
||||
urls: ['discord://token', 'slack://token'],
|
||||
});
|
||||
expect(validateNotificationChannel('apprise', 'https://apprise.local/notify/key', { urls: 'discord://token' }))
|
||||
.toMatch(/urls|unknown/);
|
||||
});
|
||||
|
||||
it('rejects unknown config keys and scheme-less destination tokens', () => {
|
||||
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/key', { tags: 'ops', extra: 1 }))
|
||||
.toMatch(/unknown Apprise config field/);
|
||||
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify', { urls: 'discord://ok', mode: 'stateless' }))
|
||||
.toMatch(/public summary|unknown/);
|
||||
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify', { urls: 'not-a-scheme-token' }))
|
||||
.toMatch(/URI scheme/);
|
||||
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify', { urls: 'discord://ok', tags: 'x' }))
|
||||
.toMatch(/tags/);
|
||||
});
|
||||
|
||||
it('redacts Apprise endpoint keys and service URLs from public agents', () => {
|
||||
const secret = 'discord://webhook-id/webhook-token?secret=query';
|
||||
const agent = serializePublicAgent({
|
||||
id: 1,
|
||||
type: 'apprise',
|
||||
url: 'https://apprise.local/notify/key-secret',
|
||||
enabled: true,
|
||||
config: JSON.stringify({ tags: 'ops' }),
|
||||
});
|
||||
|
||||
expect(agent).toMatchObject({
|
||||
type: 'apprise',
|
||||
url: 'https://apprise.local/notify/<redacted>',
|
||||
config: { mode: 'keyed', tags: 'ops', has_urls: false },
|
||||
secrets_redacted: true,
|
||||
});
|
||||
expect(JSON.stringify(agent)).not.toContain(secret);
|
||||
});
|
||||
|
||||
it('never leaks Apprise secrets from userinfo, host, path, or query in public DTOs', () => {
|
||||
const secrets = [
|
||||
'userinfo-secret',
|
||||
'host-token.example',
|
||||
'path-secret',
|
||||
'query-secret',
|
||||
];
|
||||
const urls = [
|
||||
'discord://userinfo-secret@host/path-secret?token=query-secret',
|
||||
'feishu://host-token.example/path-secret',
|
||||
'jira://APIKey:userinfo-secret@host/path-secret?token=query-secret',
|
||||
].join(' ');
|
||||
|
||||
const agent = serializePublicAgent({
|
||||
type: 'apprise',
|
||||
url: 'http://apprise.local/notify',
|
||||
enabled: true,
|
||||
config: JSON.stringify({ urls }),
|
||||
});
|
||||
const route = serializePublicNotificationRoute({
|
||||
id: 1,
|
||||
name: 'r',
|
||||
node_id: null,
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
channel_type: 'apprise',
|
||||
channel_url: 'http://apprise.local/notify/path-secret',
|
||||
config: JSON.stringify({ tags: 'ops' }),
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
});
|
||||
|
||||
const blob = JSON.stringify({ agent, route });
|
||||
for (const secret of secrets) {
|
||||
expect(blob).not.toContain(secret);
|
||||
}
|
||||
expect(agent.config).toMatchObject({
|
||||
mode: 'stateless',
|
||||
has_urls: true,
|
||||
providers: expect.arrayContaining(['discord', 'feishu', 'jira']),
|
||||
url_count: 3,
|
||||
});
|
||||
expect(route.channel_url).toBe('http://apprise.local/notify/<redacted>');
|
||||
});
|
||||
|
||||
it('rejects public DTO config shapes on validate', () => {
|
||||
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify', {
|
||||
mode: 'stateless',
|
||||
has_urls: true,
|
||||
providers: ['discord'],
|
||||
url_count: 1,
|
||||
})).toMatch(/raw Apprise config/);
|
||||
});
|
||||
|
||||
it('fail-closes malformed stored JSON and never exposes raw tokens as providers', () => {
|
||||
const keyed = parseStoredAppriseConfig('http://apprise.local/notify/key', '{not-json');
|
||||
expect(keyed).toEqual({ ok: false, reason: 'Apprise configuration is missing or invalid' });
|
||||
|
||||
const badToken = parseStoredAppriseConfig(
|
||||
'http://apprise.local/notify',
|
||||
JSON.stringify({ urls: 'super-secret-token' }),
|
||||
);
|
||||
expect(badToken.ok).toBe(false);
|
||||
|
||||
const agent = serializePublicAgent({
|
||||
type: 'apprise',
|
||||
url: 'http://apprise.local/notify',
|
||||
enabled: true,
|
||||
config: JSON.stringify({ urls: 'super-secret-token' }),
|
||||
});
|
||||
expect(agent.config).toBeNull();
|
||||
expect(JSON.stringify(agent)).not.toContain('super-secret-token');
|
||||
});
|
||||
|
||||
it('rejects Apprise notify keys outside the official alphanumeric/underscore/dash charset', () => {
|
||||
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/bad.key')).toMatch(/notify key/);
|
||||
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/has%20space')).toMatch(/notify key|valid configuration/);
|
||||
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/bad!key')).toMatch(/notify key/);
|
||||
expect(validateNotificationChannel('apprise', `http://apprise.local/notify/${'a'.repeat(129)}`)).toMatch(/notify key/);
|
||||
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/a')).toBeNull();
|
||||
expect(validateNotificationChannel('apprise', `http://apprise.local/notify/${'A1_-'}${'b'.repeat(124)}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('sets secrets_redacted only for Apprise public DTOs', () => {
|
||||
expect(serializePublicAgent({
|
||||
type: 'discord',
|
||||
url: 'https://discord.com/api/webhooks/1/token',
|
||||
enabled: true,
|
||||
}).secrets_redacted).toBe(false);
|
||||
expect(serializePublicAgent({
|
||||
type: 'slack',
|
||||
url: 'https://hooks.slack.com/services/a/b/c',
|
||||
enabled: true,
|
||||
}).secrets_redacted).toBe(false);
|
||||
expect(serializePublicAgent({
|
||||
type: 'webhook',
|
||||
url: 'https://example.com/hook',
|
||||
enabled: true,
|
||||
}).secrets_redacted).toBe(false);
|
||||
expect(serializePublicAgent({
|
||||
type: 'apprise',
|
||||
url: 'http://apprise.local/notify/k',
|
||||
enabled: true,
|
||||
config: '{}',
|
||||
}).secrets_redacted).toBe(true);
|
||||
});
|
||||
|
||||
it('treats null/empty stored config as valid empty keyed', () => {
|
||||
expect(parseStoredAppriseConfig('http://apprise.local/notify/key', null)).toEqual({ ok: true, mode: 'keyed' });
|
||||
expect(parseStoredAppriseConfig('http://apprise.local/notify/key', '{}')).toEqual({ ok: true, mode: 'keyed' });
|
||||
expect(parseStoredAppriseConfig('http://apprise.local/notify', null).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
expect(res.body.error).toContain('discord, slack, webhook, apprise');
|
||||
});
|
||||
|
||||
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');
|
||||
expect(res.body.error).toContain('discord, slack, webhook, apprise');
|
||||
});
|
||||
|
||||
it('rejects non-HTTPS channel_url', async () => {
|
||||
@@ -464,6 +464,282 @@ describe('POST /api/notification-routes/:id/test', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Apprise notification routes - redaction and preserve-on-write', () => {
|
||||
const keyedUrl = 'http://apprise.local/notify/route-key-secret';
|
||||
const serviceUrl = 'mailto://user:pass@smtp.example.com?to=ops@example.com';
|
||||
let routeId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/notification-routes')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'Apprise route',
|
||||
stack_patterns: ['app'],
|
||||
channel_type: 'apprise',
|
||||
channel_url: keyedUrl,
|
||||
config: { tags: 'ops' },
|
||||
enabled: true,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
routeId = res.body.id;
|
||||
});
|
||||
|
||||
it('POST 201 and GET redact the keyed endpoint path', async () => {
|
||||
const created = await request(app)
|
||||
.post('/api/notification-routes')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'Apprise create redact',
|
||||
stack_patterns: [],
|
||||
channel_type: 'apprise',
|
||||
channel_url: keyedUrl,
|
||||
config: { tags: 'night' },
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body.channel_url).toBe('http://apprise.local/notify/<redacted>');
|
||||
expect(created.body.config).toMatchObject({ mode: 'keyed', tags: 'night', has_urls: false });
|
||||
expect(JSON.stringify(created.body)).not.toContain('route-key-secret');
|
||||
|
||||
const listed = await request(app).get('/api/notification-routes').set('Cookie', authCookie);
|
||||
const row = listed.body.find((r: { id: number }) => r.id === created.body.id);
|
||||
expect(row.channel_url).toBe('http://apprise.local/notify/<redacted>');
|
||||
expect(JSON.stringify(row)).not.toContain('route-key-secret');
|
||||
|
||||
await request(app).delete(`/api/notification-routes/${created.body.id}`).set('Cookie', authCookie);
|
||||
});
|
||||
|
||||
it('POST 201 for stateless mode returns providers and url_count only', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/notification-routes')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'Apprise stateless',
|
||||
stack_patterns: [],
|
||||
channel_type: 'apprise',
|
||||
channel_url: 'http://apprise.local/notify',
|
||||
config: { urls: serviceUrl },
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.channel_url).toBe('http://apprise.local/notify');
|
||||
expect(res.body.config).toMatchObject({
|
||||
mode: 'stateless',
|
||||
has_urls: true,
|
||||
providers: ['mailto'],
|
||||
url_count: 1,
|
||||
});
|
||||
expect(JSON.stringify(res.body)).not.toContain('pass@');
|
||||
expect(JSON.stringify(res.body)).not.toContain('ops@example.com');
|
||||
|
||||
const stored = DatabaseService.getInstance().getNotificationRoute(res.body.id);
|
||||
expect(stored?.channel_url).toBe('http://apprise.local/notify');
|
||||
expect(stored?.config).toContain(serviceUrl);
|
||||
|
||||
await request(app).delete(`/api/notification-routes/${res.body.id}`).set('Cookie', authCookie);
|
||||
});
|
||||
|
||||
it('PUT preserves secrets when channel_url and config are omitted', async () => {
|
||||
const res = await request(app)
|
||||
.put(`/api/notification-routes/${routeId}`)
|
||||
.set('Cookie', authCookie)
|
||||
.send({ name: 'Apprise route renamed', enabled: false });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('Apprise route renamed');
|
||||
expect(res.body.channel_url).toBe('http://apprise.local/notify/<redacted>');
|
||||
expect(JSON.stringify(res.body)).not.toContain('route-key-secret');
|
||||
|
||||
const stored = DatabaseService.getInstance().getNotificationRoute(routeId);
|
||||
expect(stored?.channel_url).toBe(keyedUrl);
|
||||
expect(stored?.config).toBe(JSON.stringify({ tags: 'ops' }));
|
||||
});
|
||||
|
||||
it('PUT rejects channel_type change without a new channel_url and leaves the row unchanged', async () => {
|
||||
const before = DatabaseService.getInstance().getDb()
|
||||
.prepare('SELECT channel_type, channel_url, config FROM notification_routes WHERE id = ?')
|
||||
.get(routeId) as { channel_type: string; channel_url: string; config: string | null };
|
||||
expect(before.channel_type).toBe('apprise');
|
||||
expect(before.channel_url.startsWith('enc:')).toBe(true);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/notification-routes/${routeId}`)
|
||||
.set('Cookie', authCookie)
|
||||
.send({ channel_type: 'discord' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/channel_url is required when changing channel_type/i);
|
||||
|
||||
const after = DatabaseService.getInstance().getDb()
|
||||
.prepare('SELECT channel_type, channel_url, config FROM notification_routes WHERE id = ?')
|
||||
.get(routeId) as { channel_type: string; channel_url: string; config: string | null };
|
||||
expect(after).toEqual(before);
|
||||
});
|
||||
|
||||
it('PUT Apprise-to-Discord with a raw URL stores plaintext Discord credentials', async () => {
|
||||
const created = await request(app)
|
||||
.post('/api/notification-routes')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'Apprise leave',
|
||||
stack_patterns: [],
|
||||
channel_type: 'apprise',
|
||||
channel_url: 'http://apprise.local/notify/leave-key',
|
||||
config: { tags: 'ops' },
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
const id = created.body.id as number;
|
||||
|
||||
const discordUrl = 'https://discord.com/api/webhooks/1/token-secret';
|
||||
const res = await request(app)
|
||||
.put(`/api/notification-routes/${id}`)
|
||||
.set('Cookie', authCookie)
|
||||
.send({ channel_type: 'discord', channel_url: discordUrl });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.channel_type).toBe('discord');
|
||||
|
||||
const raw = DatabaseService.getInstance().getDb()
|
||||
.prepare('SELECT channel_type, channel_url, config FROM notification_routes WHERE id = ?')
|
||||
.get(id) as { channel_type: string; channel_url: string; config: string | null };
|
||||
expect(raw.channel_type).toBe('discord');
|
||||
expect(raw.channel_url).toBe(discordUrl);
|
||||
expect(raw.channel_url.startsWith('enc:')).toBe(false);
|
||||
expect(raw.config).toBeNull();
|
||||
|
||||
await request(app).delete(`/api/notification-routes/${id}`).set('Cookie', authCookie);
|
||||
});
|
||||
|
||||
it('PUT webhook-to-Apprise seals both channel_url and config as enc: ciphertext', async () => {
|
||||
const notifyUrl = 'https://apprise.example/notify/promote-key';
|
||||
const created = await request(app)
|
||||
.post('/api/notification-routes')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'Webhook promote',
|
||||
stack_patterns: [],
|
||||
channel_type: 'webhook',
|
||||
channel_url: notifyUrl,
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
const id = created.body.id as number;
|
||||
|
||||
const before = DatabaseService.getInstance().getDb()
|
||||
.prepare('SELECT channel_url FROM notification_routes WHERE id = ?')
|
||||
.get(id) as { channel_url: string };
|
||||
expect(before.channel_url).toBe(notifyUrl);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/notification-routes/${id}`)
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
channel_type: 'apprise',
|
||||
channel_url: notifyUrl,
|
||||
config: { tags: 'fleet' },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.channel_type).toBe('apprise');
|
||||
expect(res.body.channel_url).toBe('https://apprise.example/notify/<redacted>');
|
||||
|
||||
const raw = DatabaseService.getInstance().getDb()
|
||||
.prepare('SELECT channel_type, channel_url, config FROM notification_routes WHERE id = ?')
|
||||
.get(id) as { channel_type: string; channel_url: string; config: string | null };
|
||||
expect(raw.channel_type).toBe('apprise');
|
||||
expect(raw.channel_url.startsWith('enc:')).toBe(true);
|
||||
expect(raw.channel_url).not.toContain('promote-key');
|
||||
expect(raw.config?.startsWith('enc:')).toBe(true);
|
||||
expect(raw.config).not.toContain('fleet');
|
||||
|
||||
const stored = DatabaseService.getInstance().getNotificationRoute(id);
|
||||
expect(stored?.channel_url).toBe(notifyUrl);
|
||||
expect(stored?.config).toBe(JSON.stringify({ tags: 'fleet' }));
|
||||
|
||||
await request(app).delete(`/api/notification-routes/${id}`).set('Cookie', authCookie);
|
||||
});
|
||||
|
||||
it('PUT rejects a redacted channel_url', async () => {
|
||||
const res = await request(app)
|
||||
.put(`/api/notification-routes/${routeId}`)
|
||||
.set('Cookie', authCookie)
|
||||
.send({ channel_url: 'http://apprise.local/notify/<redacted>' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('PUT rejects public DTO config shapes', async () => {
|
||||
const res = await request(app)
|
||||
.put(`/api/notification-routes/${routeId}`)
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
channel_url: keyedUrl,
|
||||
config: { mode: 'keyed', has_urls: false, providers: [] },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('stored-route test uses raw config and returns sanitized 204 errors', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
const ok = await request(app)
|
||||
.post(`/api/notification-routes/${routeId}/test`)
|
||||
.set('Cookie', authCookie);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
keyedUrl,
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
title: 'Sencho Alert [INFO]',
|
||||
body: '🔌 Test Notification from Sencho!',
|
||||
type: 'info',
|
||||
tag: 'ops',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204 }));
|
||||
try {
|
||||
const fail = await request(app)
|
||||
.post(`/api/notification-routes/${routeId}/test`)
|
||||
.set('Cookie', authCookie);
|
||||
expect(fail.status).toBe(500);
|
||||
expect(JSON.stringify(fail.body)).toContain('HTTP 204');
|
||||
expect(JSON.stringify(fail.body)).not.toContain('route-key-secret');
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('stored-route test fails closed on malformed config without fetch', async () => {
|
||||
const broken = await request(app)
|
||||
.post('/api/notification-routes')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'Broken Apprise',
|
||||
stack_patterns: [],
|
||||
channel_type: 'apprise',
|
||||
channel_url: 'http://apprise.local/notify/broken-key',
|
||||
config: { tags: 'x' },
|
||||
});
|
||||
expect(broken.status).toBe(201);
|
||||
const id = broken.body.id as number;
|
||||
DatabaseService.getInstance().updateNotificationRoute(id, { config: '{not-json' });
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post(`/api/notification-routes/${id}/test`)
|
||||
.set('Cookie', authCookie);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid/i);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(res.body)).not.toContain('broken-key');
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
await request(app).delete(`/api/notification-routes/${id}`).set('Cookie', authCookie);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- DELETE /api/notifications/:id NaN guard ---
|
||||
|
||||
describe('DELETE /api/notifications/:id - validation', () => {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Agent slot on dashboard/fleet configuration responses. */
|
||||
export interface AgentStatus {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type ConfigurationAgents = {
|
||||
discord: AgentStatus;
|
||||
slack: AgentStatus;
|
||||
webhook: AgentStatus;
|
||||
apprise: AgentStatus;
|
||||
};
|
||||
|
||||
/**
|
||||
* Older remotes omit `apprise`. Default the slot 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;
|
||||
}): ConfigurationAgents {
|
||||
return {
|
||||
discord: agents.discord,
|
||||
slack: agents.slack,
|
||||
webhook: agents.webhook,
|
||||
apprise: agents.apprise ?? { configured: false, enabled: false },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a remote `/dashboard/configuration` body when it carries an agents
|
||||
* block. Stub or partial JSON without `notifications.agents` is returned as-is
|
||||
* so a successful fetch still marks the node online.
|
||||
*/
|
||||
export function normalizeRemoteConfigurationStatus<T extends object>(raw: T): T {
|
||||
const notifications = (raw as { notifications?: { agents?: Parameters<typeof normalizeConfigurationAgents>[0] } }).notifications;
|
||||
if (!notifications?.agents) return raw;
|
||||
return {
|
||||
...raw,
|
||||
notifications: {
|
||||
...notifications,
|
||||
agents: normalizeConfigurationAgents(notifications.agents),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,54 @@
|
||||
export const NOTIFICATION_CHANNEL_TYPES = ['discord', 'slack', 'webhook'] as const;
|
||||
import type { Agent, NotificationRoute } from '../services/DatabaseService';
|
||||
|
||||
export const NOTIFICATION_CHANNEL_TYPES = ['discord', 'slack', 'webhook', 'apprise'] as const;
|
||||
export type NotificationChannelType = typeof NOTIFICATION_CHANNEL_TYPES[number];
|
||||
|
||||
/** Write payload for Apprise agents/routes (never public DTO fields). */
|
||||
export type AppriseWriteConfig = { tags?: string } | { urls: string };
|
||||
|
||||
/** Persisted JSON shape after normalize (keyed empty is `{}`). */
|
||||
export type AppriseStoredConfig = { tags?: string } | { urls: string };
|
||||
|
||||
export type PublicAppriseConfig = {
|
||||
mode: 'keyed' | 'stateless';
|
||||
tags?: string;
|
||||
has_urls: boolean;
|
||||
providers?: string[];
|
||||
url_count?: number;
|
||||
};
|
||||
|
||||
export type PublicAgent = Omit<Agent, 'config' | 'type'> & {
|
||||
type: NotificationChannelType;
|
||||
config: PublicAppriseConfig | null;
|
||||
/** True only when Apprise masking was applied; Discord/Slack/webhook still return raw URLs. */
|
||||
secrets_redacted: boolean;
|
||||
};
|
||||
|
||||
export type PublicNotificationRoute = Omit<NotificationRoute, 'config' | 'channel_type'> & {
|
||||
channel_type: NotificationChannelType;
|
||||
config: PublicAppriseConfig | null;
|
||||
secrets_redacted: boolean;
|
||||
};
|
||||
|
||||
/** Apprise notify key: 1–128 alphanumeric, underscore, or dash (official API notes). */
|
||||
export const APPRISE_NOTIFY_KEY = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
|
||||
export type ParsedAppriseConfig =
|
||||
| { ok: true; mode: 'keyed'; tags?: string }
|
||||
| { ok: true; mode: 'stateless'; urls: string[]; urlsJoined: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
const APPRISE_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
||||
const KEYED_KEYS = new Set(['tags']);
|
||||
const STATELESS_KEYS = new Set(['urls']);
|
||||
const INVALID_STORED = 'Apprise configuration is missing or invalid';
|
||||
|
||||
/** Path segment after `/notify/` when the path is `/notify/{key}` (key not yet validated). */
|
||||
function notifyKeyFromPath(path: string): string | null {
|
||||
const match = path.match(/^\/notify\/([^/]+)$/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export const cleanStackPatterns = (patterns: string[]): string[] =>
|
||||
[...new Set(patterns.map(p => p.trim()).filter(Boolean))];
|
||||
|
||||
@@ -10,6 +58,260 @@ export function validateHttpsUrl(value: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function classifyAppriseEndpoint(endpoint: string): 'keyed' | 'stateless' | null {
|
||||
try {
|
||||
const path = new URL(endpoint).pathname.replace(/\/$/, '');
|
||||
const key = notifyKeyFromPath(path);
|
||||
if (key !== null) return APPRISE_NOTIFY_KEY.test(key) ? 'keyed' : null;
|
||||
if (path === '/notify') return 'stateless';
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Thin classifier for tests; write validation uses validateNotificationChannel. */
|
||||
export function classifyAppriseConfig(endpoint: string, config: unknown): { mode: 'keyed' | 'stateless'; urls?: string[] } | null {
|
||||
const mode = classifyAppriseEndpoint(endpoint);
|
||||
if (!mode) return null;
|
||||
if (mode === 'keyed') {
|
||||
if (config !== undefined && config !== null && (typeof config !== 'object' || Array.isArray(config))) return null;
|
||||
return { mode: 'keyed' };
|
||||
}
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) return null;
|
||||
const urls = typeof (config as { urls?: unknown }).urls === 'string'
|
||||
? splitServiceUrls((config as { urls: string }).urls)
|
||||
: [];
|
||||
return urls.length > 0 ? { mode: 'stateless', urls } : null;
|
||||
}
|
||||
|
||||
export function isPublicAppriseConfigShape(config: unknown): boolean {
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) return false;
|
||||
const record = config as Record<string, unknown>;
|
||||
return 'has_urls' in record || 'providers' in record || 'url_count' in record || 'mode' in record || 'secrets_redacted' in record;
|
||||
}
|
||||
|
||||
function unknownKeyError(record: Record<string, unknown>, allowed: Set<string>): string | null {
|
||||
for (const key of Object.keys(record)) {
|
||||
if (!allowed.has(key)) return `unknown Apprise config field: ${key}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function splitServiceUrls(urls: string): string[] {
|
||||
return urls.split(/[\s,]+/).map(url => url.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function validateServiceUrlToken(token: string): string | null {
|
||||
if (token.length > 2000) return 'Apprise service URLs must be 2000 characters or fewer';
|
||||
if (!APPRISE_SCHEME.test(token)) return 'each Apprise service URL must include a URI scheme';
|
||||
return null;
|
||||
}
|
||||
|
||||
function asRecord(config: unknown): Record<string, unknown> | null {
|
||||
if (config === undefined || config === null) return {};
|
||||
if (typeof config !== 'object' || Array.isArray(config)) return null;
|
||||
return config as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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 !== 'apprise') return validateHttpsUrl(url);
|
||||
if (typeof url !== 'string') return 'must be a valid Apprise URL';
|
||||
let parsed: URL;
|
||||
try { parsed = new URL(url); } catch { return 'is not a valid URL'; }
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.host || parsed.username || parsed.password || parsed.hash) {
|
||||
return 'must be an HTTP or HTTPS Apprise URL without credentials or a fragment';
|
||||
}
|
||||
if (isPublicAppriseConfigShape(config)) {
|
||||
return 'must use raw Apprise config ({ urls } or { tags }), not a public summary';
|
||||
}
|
||||
const path = parsed.pathname.replace(/\/$/, '');
|
||||
const notifyKey = notifyKeyFromPath(path);
|
||||
if (notifyKey !== null && !APPRISE_NOTIFY_KEY.test(notifyKey)) {
|
||||
return 'Apprise notify key must be 1–128 characters of letters, digits, underscore, or dash';
|
||||
}
|
||||
const mode = classifyAppriseEndpoint(url);
|
||||
if (!mode) return 'must use /notify or /notify/{key} with valid configuration';
|
||||
const record = asRecord(config);
|
||||
if (!record) return 'must use /notify or /notify/{key} with valid configuration';
|
||||
|
||||
if (mode === 'keyed') {
|
||||
const keyErr = unknownKeyError(record, KEYED_KEYS);
|
||||
if (keyErr) return keyErr;
|
||||
if (record.urls !== undefined) return 'keyed Apprise endpoints cannot include urls';
|
||||
if (record.tags !== undefined && typeof record.tags !== 'string') return 'Apprise tags must be a string';
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyErr = unknownKeyError(record, STATELESS_KEYS);
|
||||
if (keyErr) return keyErr;
|
||||
if (record.tags !== undefined) return 'stateless Apprise endpoints cannot include tags';
|
||||
if (typeof record.urls !== 'string' || !record.urls.trim()) {
|
||||
return 'stateless Apprise endpoints require a nonempty urls string';
|
||||
}
|
||||
const urls = splitServiceUrls(record.urls);
|
||||
if (urls.length === 0) return 'stateless Apprise endpoints require a nonempty urls string';
|
||||
for (const token of urls) {
|
||||
const tokenErr = validateServiceUrlToken(token);
|
||||
if (tokenErr) return tokenErr;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Persist shape: keyed empty → `{}`; omit empty tags; stateless → `{ urls }`. */
|
||||
export function normalizeAppriseStoredJson(endpoint: string, config: unknown): string {
|
||||
const mode = classifyAppriseEndpoint(endpoint);
|
||||
if (mode === 'keyed') {
|
||||
const record = asRecord(config) ?? {};
|
||||
const tags = typeof record.tags === 'string' ? record.tags.trim() : '';
|
||||
return tags ? JSON.stringify({ tags }) : '{}';
|
||||
}
|
||||
const record = asRecord(config) ?? {};
|
||||
const urls = typeof record.urls === 'string' ? record.urls.trim() : '';
|
||||
return JSON.stringify({ urls });
|
||||
}
|
||||
|
||||
export function parseStoredAppriseConfig(endpoint: string, configJson: string | null | undefined): ParsedAppriseConfig {
|
||||
const mode = classifyAppriseEndpoint(endpoint);
|
||||
if (!mode) return { ok: false, reason: INVALID_STORED };
|
||||
|
||||
if (configJson == null || configJson === '') {
|
||||
if (mode === 'keyed') return { ok: true, mode: 'keyed' };
|
||||
return { ok: false, reason: INVALID_STORED };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(configJson);
|
||||
} catch {
|
||||
return { ok: false, reason: INVALID_STORED };
|
||||
}
|
||||
|
||||
const err = validateNotificationChannel('apprise', endpoint, parsed);
|
||||
if (err) return { ok: false, reason: INVALID_STORED };
|
||||
|
||||
// validateNotificationChannel already confirmed parsed is a plain object
|
||||
// whose only fields match the mode (tags for keyed, urls for stateless),
|
||||
// so no further shape narrowing is needed here.
|
||||
if (mode === 'keyed') {
|
||||
const tags = typeof (parsed as { tags?: string }).tags === 'string' ? (parsed as { tags: string }).tags.trim() : '';
|
||||
return tags ? { ok: true, mode: 'keyed', tags } : { ok: true, mode: 'keyed' };
|
||||
}
|
||||
|
||||
const urlsJoined = (parsed as { urls: string }).urls.trim();
|
||||
const urls = splitServiceUrls(urlsJoined);
|
||||
return { ok: true, mode: 'stateless', urls, urlsJoined };
|
||||
}
|
||||
|
||||
export function storedAppriseToWriteConfig(parsed: Extract<ParsedAppriseConfig, { ok: true }>): AppriseWriteConfig {
|
||||
if (parsed.mode === 'keyed') {
|
||||
return parsed.tags ? { tags: parsed.tags } : {};
|
||||
}
|
||||
return { urls: parsed.urlsJoined };
|
||||
}
|
||||
|
||||
/** Reconstruct the write config to preserve on an agent/route write that omits `config`. */
|
||||
export function resolvePreservedAppriseConfig(
|
||||
endpoint: string,
|
||||
existingConfigJson: string | null | undefined,
|
||||
): { ok: true; config: AppriseWriteConfig } | { ok: false; error: string } {
|
||||
const stored = parseStoredAppriseConfig(endpoint, existingConfigJson);
|
||||
if (!stored.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Stored Apprise configuration is invalid for this endpoint; provide a complete replacement',
|
||||
};
|
||||
}
|
||||
return { ok: true, config: storedAppriseToWriteConfig(stored) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared preserve-on-write guard for agent/route Apprise writes: blocks a
|
||||
* write that echoes a redacted public URL, config, or DTO shape back as raw
|
||||
* input. Identical across `agents.ts` POST and `notification-routes` PUT.
|
||||
*/
|
||||
export function redactedChannelWriteError(
|
||||
type: string,
|
||||
effectiveUrl: unknown,
|
||||
effectiveConfig: unknown,
|
||||
rawConfig: unknown,
|
||||
): string | null {
|
||||
const configText = effectiveConfig === undefined ? '' : JSON.stringify(effectiveConfig) ?? '';
|
||||
if (
|
||||
(typeof effectiveUrl === 'string' && effectiveUrl.includes('<redacted>'))
|
||||
|| configText.includes('<redacted>')
|
||||
|| (type === 'apprise' && rawConfig !== undefined && isPublicAppriseConfigShape(rawConfig))
|
||||
) {
|
||||
return 'Provide raw channel credentials to replace redacted values';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function providerSchemes(urls: string[]): string[] {
|
||||
const schemes: string[] = [];
|
||||
for (const value of urls) {
|
||||
if (!APPRISE_SCHEME.test(value)) continue;
|
||||
const scheme = value.split(':', 1)[0].toLowerCase();
|
||||
if (scheme && !schemes.includes(scheme)) schemes.push(scheme);
|
||||
}
|
||||
return schemes;
|
||||
}
|
||||
|
||||
function publicAppriseConfig(url: string, config: string | null): PublicAppriseConfig | null {
|
||||
const parsed = parseStoredAppriseConfig(url, config);
|
||||
if (!parsed.ok) {
|
||||
console.warn(`[apprise] stored config unreadable for public DTO (${parsed.reason})`);
|
||||
return null;
|
||||
}
|
||||
if (parsed.mode === 'keyed') {
|
||||
return { mode: 'keyed', tags: parsed.tags, has_urls: false };
|
||||
}
|
||||
const providers = providerSchemes(parsed.urls);
|
||||
return {
|
||||
mode: 'stateless',
|
||||
has_urls: parsed.urls.length > 0,
|
||||
...(providers.length > 0 ? { providers } : {}),
|
||||
url_count: parsed.urls.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function maskAppriseEndpoint(value: string): string {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
const path = parsed.pathname.replace(/\/$/, '');
|
||||
const key = notifyKeyFromPath(path);
|
||||
if (key !== null && APPRISE_NOTIFY_KEY.test(key)) return `${parsed.origin}/notify/<redacted>`;
|
||||
return `${parsed.origin}/notify`;
|
||||
} catch {
|
||||
return '<invalid url>';
|
||||
}
|
||||
}
|
||||
|
||||
export function serializePublicAgent(agent: Agent): PublicAgent {
|
||||
const isApprise = agent.type === 'apprise';
|
||||
return {
|
||||
...agent,
|
||||
type: agent.type,
|
||||
url: isApprise ? maskAppriseEndpoint(agent.url) : agent.url,
|
||||
config: isApprise ? publicAppriseConfig(agent.url, agent.config ?? null) : null,
|
||||
secrets_redacted: isApprise,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializePublicNotificationRoute(route: NotificationRoute): PublicNotificationRoute {
|
||||
const isApprise = route.channel_type === 'apprise';
|
||||
return {
|
||||
...route,
|
||||
channel_type: route.channel_type,
|
||||
channel_url: isApprise ? maskAppriseEndpoint(route.channel_url) : route.channel_url,
|
||||
config: isApprise ? publicAppriseConfig(route.channel_url, route.config ?? null) : null,
|
||||
secrets_redacted: isApprise,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask a channel webhook URL for logging. Discord/Slack/custom webhook URLs
|
||||
* embed their auth token in the path (and sometimes the query), so only the
|
||||
@@ -20,8 +322,6 @@ export function maskWebhookUrl(value: unknown): string {
|
||||
if (typeof value !== 'string' || value.length === 0) return '<no url>';
|
||||
try {
|
||||
const { origin, pathname, search } = new URL(value);
|
||||
// pathname is normalized to '/' for an origin-only URL, so anything else
|
||||
// (or any query string) is a token-bearing segment we must not log.
|
||||
const hasSecret = pathname !== '/' || search !== '';
|
||||
return hasSecret ? `${origin}/<redacted>` : origin;
|
||||
} catch {
|
||||
|
||||
@@ -4,7 +4,14 @@ import { authMiddleware } from '../middleware/auth';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { NOTIFICATION_CHANNEL_TYPES, validateHttpsUrl } from '../helpers/notificationChannels';
|
||||
import {
|
||||
NOTIFICATION_CHANNEL_TYPES,
|
||||
normalizeAppriseStoredJson,
|
||||
redactedChannelWriteError,
|
||||
resolvePreservedAppriseConfig,
|
||||
serializePublicAgent,
|
||||
validateNotificationChannel,
|
||||
} from '../helpers/notificationChannels';
|
||||
|
||||
export const agentsRouter = Router();
|
||||
|
||||
@@ -12,7 +19,7 @@ agentsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promi
|
||||
try {
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const agents = DatabaseService.getInstance().getAgents(nodeId);
|
||||
res.json(agents);
|
||||
res.json(agents.map(serializePublicAgent));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch agents:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch agents' });
|
||||
@@ -22,19 +29,36 @@ agentsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promi
|
||||
agentsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { type, url, enabled } = req.body;
|
||||
const { type, url, enabled, config } = req.body;
|
||||
if (!type || !(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(type)) {
|
||||
res.status(400).json({ error: `type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
const urlErr = validateHttpsUrl(url);
|
||||
if (urlErr) { res.status(400).json({ error: `url ${urlErr}` }); return; }
|
||||
if (typeof enabled !== 'boolean') {
|
||||
res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
return;
|
||||
}
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
DatabaseService.getInstance().upsertAgent(nodeId, { type, url, enabled });
|
||||
const existing = DatabaseService.getInstance().getAgents(nodeId).find(agent => agent.type === type);
|
||||
const effectiveUrl = url === undefined ? existing?.url : url;
|
||||
|
||||
let effectiveConfig: unknown = config ?? null;
|
||||
if (type === 'apprise' && config === undefined && existing) {
|
||||
const resolved = resolvePreservedAppriseConfig(typeof effectiveUrl === 'string' ? effectiveUrl : existing.url, existing.config);
|
||||
if (!resolved.ok) { res.status(400).json({ error: resolved.error }); return; }
|
||||
effectiveConfig = resolved.config;
|
||||
}
|
||||
|
||||
const redactedErr = redactedChannelWriteError(type, effectiveUrl, effectiveConfig, config);
|
||||
if (redactedErr) { res.status(400).json({ error: redactedErr }); return; }
|
||||
const channelErr = validateNotificationChannel(type, effectiveUrl, effectiveConfig);
|
||||
if (channelErr) { res.status(400).json({ error: `url ${channelErr}` }); return; }
|
||||
DatabaseService.getInstance().upsertAgent(nodeId, {
|
||||
type,
|
||||
url: effectiveUrl.trim(),
|
||||
enabled,
|
||||
config: type === 'apprise' ? normalizeAppriseStoredJson(effectiveUrl.trim(), effectiveConfig) : null,
|
||||
});
|
||||
console.log('[Agents] Agent %s updated', sanitizeForLog(type));
|
||||
if (isDebugEnabled()) console.log('[Agents:diag] Agent %s upsert: enabled=%s', sanitizeForLog(type), sanitizeForLog(enabled));
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -16,7 +16,7 @@ interface AgentStatus {
|
||||
export interface ConfigurationStatus {
|
||||
tier: LicenseTier;
|
||||
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 };
|
||||
@@ -57,7 +57,7 @@ export function buildLocalConfigurationStatus(
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
const agents = db.getAgents(nodeId);
|
||||
const agentByType = (type: 'discord' | 'slack' | 'webhook'): AgentStatus => {
|
||||
const agentByType = (type: 'discord' | 'slack' | 'webhook' | 'apprise'): AgentStatus => {
|
||||
const a = agents.find(ag => ag.type === type);
|
||||
return { configured: !!a?.url, enabled: a?.enabled ?? false };
|
||||
};
|
||||
@@ -96,6 +96,7 @@ export function buildLocalConfigurationStatus(
|
||||
discord: agentByType('discord'),
|
||||
slack: agentByType('slack'),
|
||||
webhook: agentByType('webhook'),
|
||||
apprise: agentByType('apprise'),
|
||||
},
|
||||
alertRules,
|
||||
// Notification routing is available on every tier.
|
||||
|
||||
@@ -50,6 +50,7 @@ import { collectFleetLabelSummaries } from '../helpers/fleetLabelSummary';
|
||||
import { runLocalLabelAssign, validateLabelTemplate, validateRemoteAssignResults, failAllAssign, type AssignNodeResult } from '../helpers/fleetLabelAssign';
|
||||
import { MAX_ASSIGNMENTS } from '../helpers/constants';
|
||||
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
|
||||
import { normalizeRemoteConfigurationStatus } from '../helpers/configurationStatus';
|
||||
import { buildLocalGraph, mergeFleetGraph, isLocalDependencyGraph, type FleetNodeGraphResult } from '../services/DependencyGraphService';
|
||||
import { buildNodeLabelInventory, VALID_LABEL_SOURCES, type NodeLabelInventory } from '../services/LabelInventoryService';
|
||||
import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest';
|
||||
@@ -687,7 +688,8 @@ fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Resp
|
||||
signal: AbortSignal.timeout(10000),
|
||||
},
|
||||
);
|
||||
const configuration = resp.ok ? (await resp.json() as ConfigurationStatus) : null;
|
||||
const raw = resp.ok ? (await resp.json() as ConfigurationStatus) : null;
|
||||
const configuration = raw ? normalizeRemoteConfigurationStatus(raw) : null;
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
|
||||
@@ -7,9 +7,15 @@ import { authMiddleware } from '../middleware/auth';
|
||||
import { requireAdmin, requireNodeProxy } from '../middleware/tierGates';
|
||||
import {
|
||||
NOTIFICATION_CHANNEL_TYPES,
|
||||
validateHttpsUrl,
|
||||
serializePublicNotificationRoute,
|
||||
validateNotificationChannel,
|
||||
cleanStackPatterns,
|
||||
maskWebhookUrl,
|
||||
normalizeAppriseStoredJson,
|
||||
parseStoredAppriseConfig,
|
||||
redactedChannelWriteError,
|
||||
resolvePreservedAppriseConfig,
|
||||
storedAppriseToWriteConfig,
|
||||
} from '../helpers/notificationChannels';
|
||||
import {
|
||||
deleteSuppressionRuleFromFleet,
|
||||
@@ -246,14 +252,14 @@ notificationsRouter.delete('/', authMiddleware, async (req: Request, res: Respon
|
||||
notificationsRouter.post('/test', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { type, url } = req.body;
|
||||
const { type, url, config } = req.body;
|
||||
if (!type || !(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(type)) {
|
||||
res.status(400).json({ error: `type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
const urlErr = validateHttpsUrl(url);
|
||||
if (urlErr) { res.status(400).json({ error: `url ${urlErr}` }); return; }
|
||||
await NotificationService.getInstance().testDispatch(type, url);
|
||||
const channelErr = validateNotificationChannel(type, url, config);
|
||||
if (channelErr) { res.status(400).json({ error: `url ${channelErr}` }); return; }
|
||||
await NotificationService.getInstance().testDispatch(type, url, config);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Test failed', details: getErrorMessage(error, String(error)) });
|
||||
@@ -266,7 +272,7 @@ notificationRoutesRouter.get('/', authMiddleware, (req: Request, res: Response):
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const routes = DatabaseService.getInstance().getNotificationRoutes();
|
||||
res.json(routes);
|
||||
res.json(routes.map(serializePublicNotificationRoute));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch notification routes:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch notification routes' });
|
||||
@@ -276,7 +282,7 @@ notificationRoutesRouter.get('/', authMiddleware, (req: Request, res: Response):
|
||||
notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, priority, enabled } = req.body;
|
||||
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, config, priority, enabled } = req.body;
|
||||
|
||||
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||
res.status(400).json({ error: 'Name is required' });
|
||||
@@ -299,7 +305,7 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res
|
||||
res.status(400).json({ error: `channel_type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
const channelUrlErr = validateHttpsUrl(channel_url);
|
||||
const channelUrlErr = validateNotificationChannel(channel_type, channel_url, config);
|
||||
if (channelUrlErr) { res.status(400).json({ error: `channel_url ${channelUrlErr}` }); return; }
|
||||
if (priority !== undefined && (typeof priority !== 'number' || !Number.isFinite(priority))) {
|
||||
res.status(400).json({ error: 'priority must be a finite number' });
|
||||
@@ -315,6 +321,7 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res
|
||||
categories: Array.isArray(categories) && categories.length > 0 ? (categories as NotificationCategory[]) : null,
|
||||
channel_type,
|
||||
channel_url: channel_url.trim(),
|
||||
config: channel_type === 'apprise' ? normalizeAppriseStoredJson(channel_url.trim(), config) : null,
|
||||
priority: typeof priority === 'number' ? priority : 0,
|
||||
enabled: enabled !== false,
|
||||
created_at: now,
|
||||
@@ -322,7 +329,7 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res
|
||||
});
|
||||
console.log(`[Routes] Route "${sanitizeForLog(route.name)}" created (id=${route.id})`);
|
||||
if (isDebugEnabled()) console.log(`[Routes:diag] Route "${sanitizeForLog(route.name)}" created with patterns=[${sanitizeForLog(cleanedPatterns.join(', '))}], channel=${channel_type}`);
|
||||
res.status(201).json(route);
|
||||
res.status(201).json(serializePublicNotificationRoute(route));
|
||||
} catch (error) {
|
||||
console.error('Failed to create notification route:', error);
|
||||
res.status(500).json({ error: 'Failed to create notification route' });
|
||||
@@ -338,7 +345,7 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
|
||||
const existing = DatabaseService.getInstance().getNotificationRoute(id);
|
||||
if (!existing) { res.status(404).json({ error: 'Route not found' }); return; }
|
||||
|
||||
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, priority, enabled } = req.body;
|
||||
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, config, priority, enabled } = req.body;
|
||||
|
||||
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
|
||||
res.status(400).json({ error: 'Name must be a non-empty string' });
|
||||
@@ -368,10 +375,29 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
|
||||
res.status(400).json({ error: `channel_type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
if (channel_url !== undefined) {
|
||||
const urlErr = validateHttpsUrl(channel_url);
|
||||
if (urlErr) { res.status(400).json({ error: `channel_url ${urlErr}` }); return; }
|
||||
const typeChanged = channel_type !== undefined && channel_type !== existing.channel_type;
|
||||
// Type changes replace credentials; never reuse a prior channel's URL/config (ciphertext or plaintext).
|
||||
if (typeChanged && (typeof channel_url !== 'string' || !channel_url.trim())) {
|
||||
res.status(400).json({ error: 'channel_url is required when changing channel_type' });
|
||||
return;
|
||||
}
|
||||
const effectiveType = channel_type ?? existing.channel_type;
|
||||
const effectiveUrl = channel_url !== undefined ? String(channel_url).trim() : existing.channel_url;
|
||||
let effectiveConfig: unknown = config ?? null;
|
||||
if (effectiveType === 'apprise' && config === undefined) {
|
||||
if (typeChanged) {
|
||||
// Fresh Apprise credentials: empty keyed (or stateless urls required via validate).
|
||||
effectiveConfig = null;
|
||||
} else {
|
||||
const resolved = resolvePreservedAppriseConfig(effectiveUrl, existing.config);
|
||||
if (!resolved.ok) { res.status(400).json({ error: resolved.error }); return; }
|
||||
effectiveConfig = resolved.config;
|
||||
}
|
||||
}
|
||||
const redactedErr = redactedChannelWriteError(effectiveType, effectiveUrl, effectiveConfig, config);
|
||||
if (redactedErr) { res.status(400).json({ error: redactedErr }); return; }
|
||||
const urlErr = validateNotificationChannel(effectiveType, effectiveUrl, effectiveConfig);
|
||||
if (urlErr) { res.status(400).json({ error: `channel_url ${urlErr}` }); return; }
|
||||
if (priority !== undefined && (typeof priority !== 'number' || !Number.isFinite(priority))) {
|
||||
res.status(400).json({ error: 'priority must be a finite number' });
|
||||
return;
|
||||
@@ -388,7 +414,9 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
|
||||
if ('label_ids' in req.body) updates.label_ids = Array.isArray(label_ids) && label_ids.length > 0 ? label_ids : null;
|
||||
if ('categories' in req.body) updates.categories = Array.isArray(categories) && categories.length > 0 ? categories : null;
|
||||
if (channel_type !== undefined) updates.channel_type = channel_type;
|
||||
if (channel_url !== undefined) updates.channel_url = channel_url.trim();
|
||||
if (channel_url !== undefined || typeChanged) updates.channel_url = effectiveUrl;
|
||||
if (effectiveType === 'apprise') updates.config = normalizeAppriseStoredJson(effectiveUrl, effectiveConfig);
|
||||
else if (typeChanged || channel_type !== undefined) updates.config = null;
|
||||
if (priority !== undefined) updates.priority = priority;
|
||||
if (enabled !== undefined) updates.enabled = enabled;
|
||||
|
||||
@@ -397,7 +425,7 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
|
||||
const updated = db.getNotificationRoute(id);
|
||||
console.log(`[Routes] Route ${id} updated`);
|
||||
if (isDebugEnabled()) console.log(`[Routes:diag] Route ${id} update fields: ${Object.keys(updates).filter(k => k !== 'updated_at')}`);
|
||||
res.json(updated);
|
||||
res.json(serializePublicNotificationRoute(updated!));
|
||||
} catch (error) {
|
||||
console.error('Failed to update notification route:', error);
|
||||
res.status(500).json({ error: 'Failed to update notification route' });
|
||||
@@ -430,7 +458,18 @@ notificationRoutesRouter.post('/:id/test', authMiddleware, async (req: Request,
|
||||
if (!route) { res.status(404).json({ error: 'Route not found' }); return; }
|
||||
|
||||
if (isDebugEnabled()) console.log(`[Routes:diag] Test dispatch for route ${id} (${route.channel_type} -> ${maskWebhookUrl(route.channel_url)})`);
|
||||
await NotificationService.getInstance().testDispatch(route.channel_type, route.channel_url);
|
||||
let testConfig: unknown;
|
||||
if (route.channel_type === 'apprise') {
|
||||
const stored = parseStoredAppriseConfig(route.channel_url, route.config);
|
||||
if (!stored.ok) {
|
||||
res.status(400).json({ error: stored.reason });
|
||||
return;
|
||||
}
|
||||
testConfig = storedAppriseToWriteConfig(stored);
|
||||
} else {
|
||||
testConfig = route.config ? JSON.parse(route.config) : undefined;
|
||||
}
|
||||
await NotificationService.getInstance().testDispatch(route.channel_type, route.channel_url, testConfig);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Test failed', details: getErrorMessage(error, String(error)) });
|
||||
|
||||
@@ -16,9 +16,10 @@ function isPilotMode(): boolean {
|
||||
|
||||
export interface Agent {
|
||||
id?: number;
|
||||
type: 'discord' | 'slack' | 'webhook';
|
||||
type: 'discord' | 'slack' | 'webhook' | 'apprise';
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
config?: string | null;
|
||||
}
|
||||
|
||||
export interface GlobalSetting {
|
||||
@@ -613,8 +614,9 @@ export interface NotificationRoute {
|
||||
stack_patterns: string[];
|
||||
label_ids: number[] | null;
|
||||
categories: string[] | null;
|
||||
channel_type: 'discord' | 'slack' | 'webhook';
|
||||
channel_type: 'discord' | 'slack' | 'webhook' | 'apprise';
|
||||
channel_url: string;
|
||||
config?: string | null;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
created_at: number;
|
||||
@@ -909,6 +911,7 @@ export class DatabaseService {
|
||||
this.migrateNotificationRoutes();
|
||||
this.migrateNotificationRoutesNodeId();
|
||||
this.migrateNotificationRoutesMatchers();
|
||||
this.migrateNotificationChannelConfig();
|
||||
this.migrateNotificationSuppressionRules();
|
||||
this.migrateNotificationHistoryContext();
|
||||
this.migrateScanPolicyFleetColumns();
|
||||
@@ -1907,6 +1910,11 @@ export class DatabaseService {
|
||||
this.tryAddColumn('notification_routes', 'categories', 'TEXT NULL');
|
||||
}
|
||||
|
||||
private migrateNotificationChannelConfig(): void {
|
||||
this.tryAddColumn('agents', 'config', 'TEXT NULL');
|
||||
this.tryAddColumn('notification_routes', 'config', 'TEXT NULL');
|
||||
}
|
||||
|
||||
private migrateNotificationSuppressionRules(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notification_suppression_rules (
|
||||
@@ -2295,36 +2303,101 @@ export class DatabaseService {
|
||||
|
||||
// --- Agents ---
|
||||
|
||||
/** Encrypt Apprise secrets at rest so a downgraded binary's SELECT * cannot return raw keys/URLs. */
|
||||
private sealAppriseSecret(value: string | null | undefined): string | null {
|
||||
if (value == null) return null;
|
||||
if (value === '') return value;
|
||||
const crypto = CryptoService.getInstance();
|
||||
return crypto.isEncrypted(value) ? value : crypto.encrypt(value);
|
||||
}
|
||||
|
||||
private openAppriseSecret(value: string | null | undefined): string | null {
|
||||
if (value == null) return null;
|
||||
return CryptoService.getInstance().decrypt(value);
|
||||
}
|
||||
|
||||
/** Seal or pass through url/config depending on whether the channel is Apprise. */
|
||||
private storeAppriseFields(
|
||||
isApprise: boolean,
|
||||
url: string,
|
||||
config: string | null | undefined,
|
||||
): { url: string; config: string | null } {
|
||||
if (!isApprise) return { url, config: config ?? null };
|
||||
return {
|
||||
url: this.sealAppriseSecret(url) ?? '',
|
||||
config: this.sealAppriseSecret(config ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt Apprise fields for one row. Corrupt ciphertext or a key mismatch
|
||||
* must not throw out of list/dispatch paths: one bad Apprise row would
|
||||
* otherwise 500 GET /agents and silently drop every notification channel.
|
||||
* Empty url/config forces the operator to re-enter credentials to repair.
|
||||
*/
|
||||
private loadAppriseFields(
|
||||
isApprise: boolean,
|
||||
url: string,
|
||||
config: string | null | undefined,
|
||||
): { url: string; config: string | null } {
|
||||
if (!isApprise) return { url, config: config ?? null };
|
||||
try {
|
||||
return {
|
||||
url: this.openAppriseSecret(url) ?? '',
|
||||
config: this.openAppriseSecret(config ?? null),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(
|
||||
'[DatabaseService] Failed to decrypt Apprise credentials; isolating row:',
|
||||
(e as Error).message,
|
||||
);
|
||||
return { url: '', config: null };
|
||||
}
|
||||
}
|
||||
|
||||
private mapAgentRow(row: any): Agent {
|
||||
const type = row.type as Agent['type'];
|
||||
const fields = this.loadAppriseFields(type === 'apprise', row.url as string, row.config as string | null);
|
||||
return {
|
||||
...row,
|
||||
type,
|
||||
enabled: row.enabled === 1,
|
||||
url: fields.url,
|
||||
config: fields.config,
|
||||
};
|
||||
}
|
||||
|
||||
public getAgents(nodeId: number): Agent[] {
|
||||
const stmt = this.db.prepare('SELECT * FROM agents WHERE node_id = ?');
|
||||
return stmt.all(nodeId).map((row: any) => ({
|
||||
...row,
|
||||
enabled: row.enabled === 1
|
||||
}));
|
||||
return stmt.all(nodeId).map((row: any) => this.mapAgentRow(row));
|
||||
}
|
||||
|
||||
public getEnabledAgents(nodeId: number): Agent[] {
|
||||
const stmt = this.db.prepare('SELECT * FROM agents WHERE node_id = ? AND enabled = 1');
|
||||
return stmt.all(nodeId).map((row: any) => ({
|
||||
...row,
|
||||
enabled: row.enabled === 1
|
||||
}));
|
||||
return stmt.all(nodeId).map((row: any) => this.mapAgentRow(row));
|
||||
}
|
||||
|
||||
public upsertAgent(nodeId: number, agent: Agent): void {
|
||||
const stored = this.storeAppriseFields(agent.type === 'apprise', agent.url, agent.config);
|
||||
const existing = this.db.prepare('SELECT id FROM agents WHERE node_id = ? AND type = ?').get(nodeId, agent.type) as any;
|
||||
if (existing) {
|
||||
const stmt = this.db.prepare('UPDATE agents SET url = ?, enabled = ? WHERE node_id = ? AND type = ?');
|
||||
stmt.run(agent.url, agent.enabled ? 1 : 0, nodeId, agent.type);
|
||||
const stmt = this.db.prepare('UPDATE agents SET url = ?, enabled = ?, config = ? WHERE node_id = ? AND type = ?');
|
||||
stmt.run(stored.url, agent.enabled ? 1 : 0, stored.config, nodeId, agent.type);
|
||||
} else {
|
||||
const stmt = this.db.prepare('INSERT INTO agents (node_id, type, url, enabled) VALUES (?, ?, ?, ?)');
|
||||
stmt.run(nodeId, agent.type, agent.url, agent.enabled ? 1 : 0);
|
||||
const stmt = this.db.prepare('INSERT INTO agents (node_id, type, url, enabled, config) VALUES (?, ?, ?, ?, ?)');
|
||||
stmt.run(nodeId, agent.type, stored.url, agent.enabled ? 1 : 0, stored.config);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Notification Routes ---
|
||||
|
||||
private parseNotificationRoute(row: Record<string, unknown>): NotificationRoute {
|
||||
const channel_type = row.channel_type as 'discord' | 'slack' | 'webhook' | 'apprise';
|
||||
const fields = this.loadAppriseFields(
|
||||
channel_type === 'apprise',
|
||||
row.channel_url as string,
|
||||
row.config as string | null,
|
||||
);
|
||||
return {
|
||||
id: row.id as number,
|
||||
name: row.name as string,
|
||||
@@ -2332,8 +2405,9 @@ export class DatabaseService {
|
||||
stack_patterns: JSON.parse(row.stack_patterns as string) as string[],
|
||||
label_ids: row.label_ids ? JSON.parse(row.label_ids as string) as number[] : null,
|
||||
categories: row.categories ? JSON.parse(row.categories as string) as string[] : null,
|
||||
channel_type: row.channel_type as 'discord' | 'slack' | 'webhook',
|
||||
channel_url: row.channel_url as string,
|
||||
channel_type,
|
||||
channel_url: fields.url,
|
||||
config: fields.config,
|
||||
priority: row.priority as number,
|
||||
enabled: row.enabled === 1,
|
||||
created_at: row.created_at as number,
|
||||
@@ -2366,8 +2440,9 @@ export class DatabaseService {
|
||||
}
|
||||
|
||||
public createNotificationRoute(route: Omit<NotificationRoute, 'id'>): NotificationRoute {
|
||||
const stored = this.storeAppriseFields(route.channel_type === 'apprise', route.channel_url, route.config);
|
||||
const result = this.db.prepare(
|
||||
'INSERT INTO notification_routes (name, node_id, stack_patterns, label_ids, categories, channel_type, channel_url, priority, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO notification_routes (name, node_id, stack_patterns, label_ids, categories, channel_type, channel_url, config, priority, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
route.name,
|
||||
route.node_id ?? null,
|
||||
@@ -2375,7 +2450,8 @@ export class DatabaseService {
|
||||
route.label_ids ? JSON.stringify(route.label_ids) : null,
|
||||
route.categories ? JSON.stringify(route.categories) : null,
|
||||
route.channel_type,
|
||||
route.channel_url,
|
||||
stored.url,
|
||||
stored.config,
|
||||
route.priority,
|
||||
route.enabled ? 1 : 0,
|
||||
route.created_at,
|
||||
@@ -2387,6 +2463,9 @@ export class DatabaseService {
|
||||
public updateNotificationRoute(id: number, updates: Partial<Omit<NotificationRoute, 'id' | 'created_at'>>): void {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
const existing = this.getNotificationRoute(id);
|
||||
const effectiveType = updates.channel_type ?? existing?.channel_type;
|
||||
const sealApprise = effectiveType === 'apprise';
|
||||
|
||||
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
|
||||
if ('node_id' in updates) { fields.push('node_id = ?'); values.push(updates.node_id ?? null); }
|
||||
@@ -2394,7 +2473,14 @@ export class DatabaseService {
|
||||
if ('label_ids' in updates) { fields.push('label_ids = ?'); values.push(updates.label_ids ? JSON.stringify(updates.label_ids) : null); }
|
||||
if ('categories' in updates) { fields.push('categories = ?'); values.push(updates.categories ? JSON.stringify(updates.categories) : null); }
|
||||
if (updates.channel_type !== undefined) { fields.push('channel_type = ?'); values.push(updates.channel_type); }
|
||||
if (updates.channel_url !== undefined) { fields.push('channel_url = ?'); values.push(updates.channel_url); }
|
||||
if (updates.channel_url !== undefined) {
|
||||
fields.push('channel_url = ?');
|
||||
values.push(sealApprise ? (this.sealAppriseSecret(updates.channel_url) ?? '') : updates.channel_url);
|
||||
}
|
||||
if ('config' in updates) {
|
||||
fields.push('config = ?');
|
||||
values.push(sealApprise ? this.sealAppriseSecret(updates.config ?? null) : (updates.config ?? null));
|
||||
}
|
||||
if (updates.priority !== undefined) { fields.push('priority = ?'); values.push(updates.priority); }
|
||||
if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
|
||||
if (updates.updated_at !== undefined) { fields.push('updated_at = ?'); values.push(updates.updated_at); }
|
||||
|
||||
@@ -12,6 +12,13 @@ import {
|
||||
matchesNotificationFilters,
|
||||
ruleNeedsStackLabels,
|
||||
} from '../helpers/notificationMatchers';
|
||||
import {
|
||||
type NotificationChannelType,
|
||||
type ParsedAppriseConfig,
|
||||
normalizeAppriseStoredJson,
|
||||
parseStoredAppriseConfig,
|
||||
validateNotificationChannel,
|
||||
} from '../helpers/notificationChannels';
|
||||
|
||||
export type NotificationCategory =
|
||||
| 'deploy_success'
|
||||
@@ -65,7 +72,13 @@ export const ALL_SUPPRESSIBLE_CATEGORIES: readonly NotificationCategory[] = [
|
||||
const WEBHOOK_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Valid notification channel types for defense-in-depth validation. */
|
||||
const ALLOWED_CHANNEL_TYPES = new Set(['discord', 'slack', 'webhook']);
|
||||
const ALLOWED_CHANNEL_TYPES = new Set<NotificationChannelType>(['discord', 'slack', 'webhook', 'apprise']);
|
||||
|
||||
export class NotificationDeliveryError extends Error {
|
||||
public constructor(message: string, public readonly status: number | null, public readonly retryable: boolean) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotificationService {
|
||||
private static instance: NotificationService;
|
||||
@@ -251,7 +264,7 @@ export class NotificationService {
|
||||
if (isDebugEnabled()) console.log(`[Notify:diag] Matched ${matched.length} route(s) for stack "${sanitizeForLog(stackName ?? '(none)')}", category="${sanitizeForLog(category)}"`);
|
||||
await Promise.allSettled(
|
||||
matched.map(route =>
|
||||
this.sendToChannel(route.channel_type, route.channel_url, level, sanitized)
|
||||
this.sendToChannel(route.channel_type, route.channel_url, level, sanitized, route.config)
|
||||
.then(() => {
|
||||
if (isDebugEnabled()) console.log(`[Notify:diag] Dispatched ${level} via route "${sanitizeForLog(route.name)}" (${route.channel_type})`);
|
||||
})
|
||||
@@ -275,7 +288,7 @@ export class NotificationService {
|
||||
if (isDebugEnabled()) console.log(`[Notify:diag] Falling back to ${agents.length} global agent(s)`);
|
||||
await Promise.allSettled(
|
||||
agents.map(agent =>
|
||||
this.sendToChannel(agent.type, agent.url, level, sanitized)
|
||||
this.sendToChannel(agent.type, agent.url, level, sanitized, agent.config)
|
||||
.then(() => {
|
||||
if (isDebugEnabled()) console.log(`[Notify:diag] Dispatched ${level} via global agent (${agent.type})`);
|
||||
})
|
||||
@@ -302,22 +315,66 @@ export class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
private async sendToChannel(type: string, url: string, level: 'info' | 'warning' | 'error', message: string): Promise<void> {
|
||||
private async sendToChannel(type: string, url: string, level: 'info' | 'warning' | 'error', message: string, config?: string | null): Promise<void> {
|
||||
if (type === 'discord') {
|
||||
await this.sendDiscordWebhook(url, level, message);
|
||||
} else if (type === 'slack') {
|
||||
await this.sendSlackWebhook(url, level, message);
|
||||
} else if (type === 'webhook') {
|
||||
await this.sendCustomWebhook(url, level, message);
|
||||
} else if (type === 'apprise') {
|
||||
const parsed = parseStoredAppriseConfig(url, config);
|
||||
if (!parsed.ok) {
|
||||
throw new NotificationDeliveryError(parsed.reason, null, false);
|
||||
}
|
||||
await this.sendAppriseNotify(url, level, message, parsed);
|
||||
} else {
|
||||
throw new Error(`Unsupported channel type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async testDispatch(type: 'discord' | 'slack' | 'webhook', url: string) {
|
||||
public async testDispatch(type: NotificationChannelType, url: string, config?: unknown) {
|
||||
if (!ALLOWED_CHANNEL_TYPES.has(type)) throw new Error(`Invalid notification type: ${type}`);
|
||||
if (!url || !url.startsWith('https://')) throw new Error('URL must use HTTPS');
|
||||
await this.sendToChannel(type, url, 'info', '🔌 Test Notification from Sencho!');
|
||||
const validation = validateNotificationChannel(type, url, config);
|
||||
if (validation) throw new Error(`URL ${validation}`);
|
||||
const stored = type === 'apprise' ? normalizeAppriseStoredJson(url, config) : (config == null ? null : JSON.stringify(config));
|
||||
await this.sendToChannel(type, url, 'info', '🔌 Test Notification from Sencho!', stored);
|
||||
}
|
||||
|
||||
private async sendAppriseNotify(
|
||||
url: string,
|
||||
level: 'info' | 'warning' | 'error',
|
||||
message: string,
|
||||
config: Extract<ParsedAppriseConfig, { ok: true }>,
|
||||
): Promise<void> {
|
||||
const payload: Record<string, string> = {
|
||||
title: `Sencho Alert [${level.toUpperCase()}]`,
|
||||
body: message,
|
||||
type: level === 'error' ? 'failure' : level,
|
||||
};
|
||||
if (config.mode === 'stateless') payload.urls = config.urlsJoined;
|
||||
else if (config.tags) payload.tag = config.tags;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
|
||||
});
|
||||
if (response.status === 204) throw new NotificationDeliveryError('Apprise returned no delivery (HTTP 204)', 204, false);
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
throw new NotificationDeliveryError(`Apprise responded with HTTP ${response.status}`, response.status, false);
|
||||
}
|
||||
if (!response.ok) throw new NotificationDeliveryError(`Apprise 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 ? 'Apprise request timed out' : 'Apprise request failed',
|
||||
null,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendDiscordWebhook(url: string, level: 'info' | 'warning' | 'error', message: string) {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
---
|
||||
title: Alerts & Notifications
|
||||
sidebarTitle: Alerts and notifications
|
||||
description: Threshold and event alerts for your fleet, dispatched to Discord, Slack, or any webhook, with per-stack rules and channel routing.
|
||||
description: Threshold and event alerts for your fleet, dispatched to Discord, Slack, Apprise, or any webhook, with per-stack 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 one of three external channels you configure. This page covers everything from configuring channels to writing per-stack threshold rules, routing alerts to dedicated channels with routing rules, and tuning retention.
|
||||
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 per-stack 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 showing the Discord, Slack, and Webhook tabs with the masthead breadcrumb, the CHANNELS 3/3 stat, the active Discord tab with its Enabled toggle on, the Webhook URL input, and the Test and Save actions." />
|
||||
<img src="/images/alerts-notifications/notifications-settings.png" alt="Settings · Notifications · Channels panel with Discord, Slack, Webhook, and Apprise tabs, CHANNELS 1/4 in the masthead, the Apprise tab active with Enabled on, a redacted Apprise endpoint, an empty Tags field, and Test disabled beside Save." />
|
||||
</Frame>
|
||||
|
||||
## Notification channels
|
||||
|
||||
Open **Settings · Notifications · Channels** to configure the three channel types. 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 three slots are enabled.
|
||||
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.
|
||||
|
||||
Each tab carries the same controls: an **Enabled** toggle (helper: `Send Sencho events to this <name> channel.`), a **Webhook URL** input (placeholder `https://...`, helper: `Sencho posts JSON payloads here. Use a private channel.`), and the **Test** and **Save** buttons. 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** 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.
|
||||
|
||||
<Note>
|
||||
Webhook URLs must use HTTPS. Sencho rejects `http://` and any string that does not parse as a URL. The same rule applies to test sends and to routing-rule URLs.
|
||||
Discord, Slack, and webhook URLs must use HTTPS. Apprise endpoints may use HTTP or HTTPS. Every endpoint must parse as a URL.
|
||||
</Note>
|
||||
|
||||
### Discord
|
||||
@@ -43,6 +43,12 @@ Use the Generic Webhook tab when you have your own receiver: a Mattermost or Tea
|
||||
|
||||
`level` is one of `info`, `warning`, `error`. `source` is always the literal string `sencho`. `timestamp` is ISO-8601 with millisecond precision.
|
||||
|
||||
### Apprise
|
||||
|
||||
Use a keyed Apprise endpoint ending in `/notify/<key>` with optional tags, or a stateless endpoint ending in `/notify` with one or more destination URLs. Separate stateless destination URLs with commas or whitespace. Apprise endpoints may use HTTP for a local gateway.
|
||||
|
||||
Sencho sends the alert title, body, severity, and either tags or destination URLs to Apprise. An HTTP 204 response means Apprise accepted no delivery and is reported as a failed test or dispatch. Configuration reads expose only destination provider names and counts, never destination URLs or endpoint keys.
|
||||
|
||||
### Test sends and delivery semantics
|
||||
|
||||
The **Test** button on each tab dispatches the literal message `🔌 Test Notification from Sencho!` at level `info` through the same path a real alert would take. Test sends require the admin role; the server returns 403 if an operator or viewer submits one.
|
||||
@@ -74,7 +80,7 @@ A route with all four matchers left empty matches every alert and intercepts glo
|
||||
Open **Settings · Notifications · Notification Routing** and click **+ Add Route**.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/alerts-notifications/routing-modal.png" alt="The 'New routing rule' modal with the 'ROUTING · NEW RULE' kicker, a Name input filled with 'Production alerts', a Node scope select reading 'Any node', empty Stacks, Labels, and Categories combobox pickers, the helper line 'Leave blank to match all categories. All non-empty filters must match (AND).', a Channel tab strip with Discord selected and a webhook URL filled in, and Priority and Enabled fields with Cancel and CREATE actions in the footer." />
|
||||
<img src="/images/alerts-notifications/routing-modal.png" alt="The New routing rule modal with empty Name, Node scope set to Any node, empty Stacks, Labels, and Categories fields, Channel tabs with Apprise selected, and Cancel and CREATE actions. Priority and Enabled may be scrolled out of the cropped frame." />
|
||||
</Frame>
|
||||
|
||||
| Field | Purpose |
|
||||
@@ -84,7 +90,7 @@ Open **Settings · Notifications · Notification Routing** and click **+ Add Rou
|
||||
| **Stacks** *(optional)* | A combobox of stacks on the active node. Selected stacks appear as removable pills. Empty matches any stack. |
|
||||
| **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).` |
|
||||
| **Channel** | Tabs for Discord, Slack, and Webhook with a URL input below. URL must use HTTPS. |
|
||||
| **Channel** | Tabs for Discord, Slack, Webhook, and Apprise. Discord, Slack, and Webhook require HTTPS. Apprise accepts 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. |
|
||||
|
||||
@@ -164,7 +170,7 @@ Each stack carries its own set of threshold rules that fire when a metric stays
|
||||
Open the rules editor by right-clicking a stack in the sidebar and choosing **Alerts**, or by pressing `A` while focused on a stack.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/alerts-notifications/alert-panel.png" alt="The Stack › PLEX › MONITOR sheet with the Alerts tab active, a green 'Notifications active via Discord, Slack, Webhook' banner under the NOTIFICATION CHANNELS heading, an ACTIVE RULES section listing 'CPU Usage (%) > 80' with the secondary line 'Trigger after 5m • Cooldown 60m', and an ADD NEW RULE form with Metric, Operator, Threshold, Duration, Cooldown fields and an Add Rule submit button." />
|
||||
<img src="/images/alerts-notifications/alert-panel.png" alt="The Stack › PLEX › MONITOR sheet with the Alerts tab active, a green notification-channel banner under the NOTIFICATION CHANNELS heading, an ACTIVE RULES section listing 'CPU Usage (%) > 80' with the secondary line 'Trigger after 5m • Cooldown 60m', and an ADD NEW RULE form with Metric, Operator, Threshold, Duration, Cooldown fields and an Add Rule submit button." />
|
||||
</Frame>
|
||||
|
||||
### Alert fields
|
||||
@@ -196,7 +202,7 @@ The **NOTIFICATION CHANNELS** banner above the rules list reflects what dispatch
|
||||
|
||||
- **Loading** is a spinner with `Checking notification channels...` while Sencho asks the target node for its agent state.
|
||||
- **Remote node** is a blue banner reading `Remote node: <name>`, with the body `Alert rules are stored and evaluated on this remote instance. Notifications are dispatched using that node's configured channels.` A sub-line reports whether the remote has any channels configured.
|
||||
- **No channels** is an amber banner reading `No notification channels configured`, body `Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, or a webhook in Settings → Notifications → Channels.`
|
||||
- **No channels** is an amber banner reading `No notification channels configured`, body `Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, Apprise, or a webhook in Settings → Notifications → Channels.`
|
||||
- **Active** is a green banner reading `Notifications active via Discord, Slack, …` with the configured channels listed.
|
||||
|
||||
<Frame>
|
||||
@@ -287,7 +293,7 @@ The bell aggregates across the entire fleet by hitting `/api/notifications` agai
|
||||
|
||||
Per-stack alert rules and channel configuration are **stored on the node where the stack runs**. To configure a rule on a remote, switch the active node via the picker, open the stack's **Monitor** sheet, and the form's POST is forwarded to the remote.
|
||||
|
||||
Crash detection runs only on local Docker; remote nodes run their own copy of `DockerEventService` and emit through the proxy. On a multi-node fleet the bell attributes remotes with a node-name badge rather than embedding the satellite-local name in the message. External channel payloads (Slack, Discord, custom webhook) receive the same sanitized body with no structured source-node id.
|
||||
Crash detection runs only on local Docker; remote nodes run their own copy of `DockerEventService` and emit through the proxy. On a multi-node fleet the bell attributes remotes with a node-name badge rather than embedding the satellite-local name in the message. External channel payloads (Slack, Discord, Apprise, custom webhook) receive the same sanitized body with no structured source-node id.
|
||||
|
||||
Switching the active node only affects per-stack rule editing and the Settings panels. The bell aggregates every node regardless of which one is active.
|
||||
|
||||
@@ -433,8 +439,8 @@ Switching the active node tears down per-stack rule editors and reloads channel
|
||||
## Troubleshooting
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Notifications never arrive in Discord, Slack, or my webhook">
|
||||
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, the URL must use HTTPS; the form rejects plain `http://` outright. Third, a routing rule with empty `Stacks`, `Labels`, and `Categories` 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 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 empty `Stacks`, `Labels`, and `Categories` 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: the breach must persist for the full duration before the rule fires. Second, the rule 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.
|
||||
|
||||
@@ -87,7 +87,7 @@ The card is divided into four sections.
|
||||
|
||||
| Row | What it shows |
|
||||
|-----|---------------|
|
||||
| **Notification agents** | The list of enabled delivery agents from `Discord`, `Slack`, `Webhook`, joined by commas; reads `None` when no agent is enabled |
|
||||
| **Notification agents** | The list of enabled delivery agents from `Discord`, `Slack`, `Webhook`, and `Apprise`, joined by commas; reads `None` when no agent is enabled |
|
||||
| **Alert rules** | Total per-stack alert rules in effect, formatted `<n> rules` |
|
||||
| **Notification routing** | Number of enabled routing rules that direct categories to specific agents, formatted `<n> routes` |
|
||||
|
||||
|
||||
@@ -36,12 +36,12 @@ See [the pricing page](https://sencho.io/pricing) for current pricing.
|
||||
- Atomic deployments with automatic rollback, and one-click rollback to the previous deployment
|
||||
- Auto-update policies for stack images and auto-heal policies for failed containers
|
||||
- Scheduled operations across the full action catalog (lifecycle, updates, scans, snapshots, prune)
|
||||
- Webhooks (incoming, to trigger deploys from CI/CD) and notification routing (per-stack and per-category rules to Discord, Slack, or any webhook)
|
||||
- Webhooks (incoming, to trigger deploys from CI/CD) and notification routing (per-stack and per-category rules to Discord, Slack, Apprise, or any webhook)
|
||||
- Custom S3-compatible backup target (bring your own AWS S3, Cloudflare R2, MinIO, Backblaze B2, or Wasabi bucket)
|
||||
- Vulnerability scanning: install, update, uninstall, and auto-update the managed Trivy binary, on-demand scans for vulnerabilities, secrets, and misconfigurations, scan comparison, CVE suppressions, and single-scan SBOM export (SPDX, CycloneDX)
|
||||
- Private registry credentials for Docker Hub, GitHub Container Registry (GHCR), and custom or self-hosted registries (admin role required)
|
||||
- A 14-day recent-activity audit log with Stream and Table views and filtering
|
||||
- Alert rules with Discord, Slack, and webhook targets
|
||||
- Alert rules with Discord, Slack, Apprise, and webhook targets
|
||||
- API tokens for CI/CD pipelines and scripts (admin role required)
|
||||
- Unlimited accounts with the Admin and Viewer roles
|
||||
- Two-factor authentication (TOTP plus backup codes)
|
||||
|
||||
@@ -189,7 +189,7 @@ When you select a remote node in the switcher, the Settings hub filters to the p
|
||||
|-------|:-----:|-------|
|
||||
| Appearance | Per browser | Theme and density preferences are stored in your browser, not on the node. |
|
||||
| Host Alerts | Per node | Host CPU, RAM, and disk thresholds for the selected node. |
|
||||
| Channels | Per node | Discord, Slack, and Webhook channels fire from the node that detects the event. |
|
||||
| Channels | Per node | Discord, Slack, Webhook, and Apprise channels fire from the node that detects the event. |
|
||||
| Labels | Per node | Stack and container label palettes. |
|
||||
| Vulnerability Scanning | Per node | Trivy install state and scanner readiness for the selected node. |
|
||||
| Developer | Per node | Retention windows for metrics and logs, plus Developer Mode. |
|
||||
|
||||
@@ -147,7 +147,7 @@ The **Logs** view aggregates output from every container across every stack into
|
||||
|
||||
### Alerts and notifications
|
||||
|
||||
Configure threshold-based alerts per stack and route notifications to Discord, Slack, or any generic webhook. Per-stack [routing rules](/features/alerts-notifications#notification-routing) send each stack's alerts to the right channel. [Learn more →](/features/alerts-notifications)
|
||||
Configure threshold-based alerts per stack and route notifications to Discord, Slack, Apprise, or any generic webhook. Per-stack [routing rules](/features/alerts-notifications#notification-routing) send each stack's alerts to the right channel. [Learn more →](/features/alerts-notifications)
|
||||
|
||||
### Audit log
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ Use the **Enabled** toggle in the row to pause a schedule without losing its con
|
||||
|
||||
## Failure notifications
|
||||
|
||||
When a scheduled task fails, Sencho dispatches an `error`-level notification in the `system` category through your configured channels (Discord, Slack, custom webhooks, and the in-app bell). The notification carries the task name, the action, and the error message so you can diagnose without opening the run history.
|
||||
When a scheduled task fails, Sencho dispatches an `error`-level notification in the `system` category through your configured channels (Discord, Slack, Apprise, custom webhooks, and the in-app bell). The notification carries the task name, the action, and the error message so you can diagnose without opening the run history.
|
||||
|
||||
When a task that previously failed succeeds again, Sencho dispatches an `info`-level recovery notification in the same category to confirm the issue is resolved. Tasks that succeed on every run do not produce per-run notifications: the recovery-only approach keeps the channel quiet during steady-state operation.
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 51 KiB |
@@ -501,7 +501,7 @@ docker compose pull && docker compose up -d
|
||||
|
||||
## Notification routing rule matches but alerts still go to global channels
|
||||
|
||||
**Symptom:** You created a routing rule for a stack, but alerts from that stack still arrive on the global Discord/Slack/Webhook channels.
|
||||
**Symptom:** You created a routing rule for a stack, but alerts from that stack still arrive on the global channels.
|
||||
|
||||
**Checks:**
|
||||
|
||||
|
||||
@@ -378,7 +378,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. Three agent types are available on separate tabs: **Discord**, **Slack**, and **Webhook**. The masthead publishes a **CHANNELS** pill showing how many agents are enabled (for example, `2/3`).
|
||||
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`).
|
||||
|
||||
For each agent:
|
||||
|
||||
@@ -386,6 +386,7 @@ For each agent:
|
||||
|-------|-------------|
|
||||
| **Enabled** toggle | Activates or deactivates this agent. Disabled agents receive no messages even if a URL is saved. |
|
||||
| **Webhook URL** | The endpoint Sencho will POST to when an alert fires. |
|
||||
| **Apprise** | Use a keyed `/notify/<key>` endpoint with optional tags, or a stateless `/notify` endpoint with destination URLs. Apprise accepts HTTP or HTTPS. |
|
||||
|
||||
Click **Save** to persist changes. Click **Test** to send a test payload immediately and verify delivery.
|
||||
|
||||
@@ -410,9 +411,9 @@ Create routing rules that direct specific alert types to specific notification c
|
||||
| **Stack patterns** | Glob patterns to match stack names (multi-select). |
|
||||
| **Labels** | Apply this rule to stacks that carry any of the selected labels. |
|
||||
| **Categories** | Event categories that trigger the rule (crash, deploy, vulnerability, etc.). |
|
||||
| **Channel type** | `discord`, `slack`, or `webhook`. |
|
||||
| **Channel type** | `discord`, `slack`, `webhook`, or `apprise`. |
|
||||
| **Channel URL** | The destination endpoint for this rule. |
|
||||
| **Priority** | Order in which rules are evaluated; the first match wins. |
|
||||
| **Priority** | Sort order among matching rules. Every matching route fires; priority does not stop later matches. |
|
||||
| **Enabled** toggle | Mute a rule without deleting it. |
|
||||
|
||||
Each rule keeps an execution history (collapsible per row) showing which alerts triggered it, when, and whether the delivery succeeded.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
classifyAppriseEndpoint,
|
||||
isKeyedAppriseEndpoint,
|
||||
isStatelessAppriseEndpoint,
|
||||
} from '../appriseEndpoint';
|
||||
|
||||
describe('classifyAppriseEndpoint', () => {
|
||||
it('classifies keyed and stateless endpoints', () => {
|
||||
expect(classifyAppriseEndpoint('http://apprise.local/notify/my-key')).toBe('keyed');
|
||||
expect(classifyAppriseEndpoint('http://apprise.local/notify')).toBe('stateless');
|
||||
expect(classifyAppriseEndpoint('http://apprise.local/other')).toBeNull();
|
||||
});
|
||||
|
||||
it('treats a public redacted notify key as keyed so Tags remain visible', () => {
|
||||
expect(classifyAppriseEndpoint('http://apprise.local/notify/<redacted>')).toBe('keyed');
|
||||
expect(isKeyedAppriseEndpoint('http://apprise.local/notify/<redacted>')).toBe(true);
|
||||
expect(isStatelessAppriseEndpoint('http://apprise.local/notify/<redacted>')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Pathname-based Apprise endpoint mode; mirrors backend classifyAppriseEndpoint. */
|
||||
export const APPRISE_NOTIFY_KEY = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
|
||||
function notifyKeyFromPath(path: string): string | null {
|
||||
const match = path.match(/^\/notify\/([^/]+)$/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export function classifyAppriseEndpoint(url: string): 'keyed' | 'stateless' | null {
|
||||
try {
|
||||
const path = new URL(url).pathname.replace(/\/$/, '');
|
||||
const key = notifyKeyFromPath(path);
|
||||
if (key !== null) {
|
||||
// Public DTOs mask the notify key as <redacted>; URL() percent-encodes the brackets.
|
||||
let decodedKey = key;
|
||||
try {
|
||||
decodedKey = decodeURIComponent(key);
|
||||
} catch {
|
||||
// Keep the raw path segment when it is not valid percent-encoding.
|
||||
}
|
||||
if (decodedKey === '<redacted>') return 'keyed';
|
||||
return APPRISE_NOTIFY_KEY.test(key) ? 'keyed' : null;
|
||||
}
|
||||
if (path === '/notify') return 'stateless';
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isStatelessAppriseEndpoint(url: string): boolean {
|
||||
return classifyAppriseEndpoint(url) === 'stateless';
|
||||
}
|
||||
|
||||
export function isKeyedAppriseEndpoint(url: string): boolean {
|
||||
return classifyAppriseEndpoint(url) === 'keyed';
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Wire shape for a single notification agent slot on /dashboard/configuration. */
|
||||
export interface AgentStatus {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Agents block as returned by current hubs (four channels). */
|
||||
export type ConfigurationAgents = {
|
||||
discord: AgentStatus;
|
||||
slack: AgentStatus;
|
||||
webhook: AgentStatus;
|
||||
apprise: 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.
|
||||
*/
|
||||
export function normalizeConfigurationAgents(agents: {
|
||||
discord: AgentStatus;
|
||||
slack: AgentStatus;
|
||||
webhook: AgentStatus;
|
||||
apprise?: AgentStatus;
|
||||
}): ConfigurationAgents {
|
||||
return {
|
||||
discord: agents.discord,
|
||||
slack: agents.slack,
|
||||
webhook: agents.webhook,
|
||||
apprise: agents.apprise ?? { configured: false, enabled: false },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user