diff --git a/src/lib/server/notifications/index.ts b/src/lib/server/notifications/index.ts index 5e7d91a..b9a511b 100644 --- a/src/lib/server/notifications/index.ts +++ b/src/lib/server/notifications/index.ts @@ -40,6 +40,7 @@ import { sendApprise } from './apprise'; import { sendPushover } from './pushover'; import { sendGenericWebhook } from './generic-webhook'; import { sendWorkflows } from './workflows'; +import { sendZabbix } from './zabbix'; // Send to every URL in an Apprise channel. Errors are aggregated so a single // bad URL doesn't silently mask a healthy one. @@ -110,6 +111,9 @@ async function sendToAppriseUrl(url: string, payload: NotificationPayload): Prom return await sendGenericWebhook(url, payload); case 'workflows': return await sendWorkflows(url, payload); + case 'zabbix': + case 'zabbixs': + return await sendZabbix(url, payload); default: return { success: false, error: `Unsupported Apprise protocol: ${protocol}` }; } @@ -149,7 +153,8 @@ export async function testNotification(setting: NotificationSettingData): Promis const payload: NotificationPayload = { title: 'Dockhand Test Notification', message: 'This is a test notification from Dockhand. If you receive this, your notification settings are configured correctly.', - type: 'info' + type: 'info', + eventType: 'test' }; if (setting.type === 'smtp') { @@ -214,6 +219,7 @@ export async function sendEnvironmentNotification( const enrichedPayload: NotificationPayload = { ...payload, + eventType, environmentId, environmentName: env.name }; @@ -251,7 +257,10 @@ export async function sendEventNotification( payload: NotificationPayload, environmentId?: number ): Promise<{ success: boolean; sent: number }> { - let enrichedPayload = { ...payload }; + let enrichedPayload: NotificationPayload = { + ...payload, + eventType + }; if (environmentId) { const env = await getEnvironment(environmentId); if (env) { diff --git a/src/lib/server/notifications/shared.ts b/src/lib/server/notifications/shared.ts index 26c9c74..e308b76 100644 --- a/src/lib/server/notifications/shared.ts +++ b/src/lib/server/notifications/shared.ts @@ -14,6 +14,7 @@ export interface NotificationPayload { type?: 'info' | 'success' | 'warning' | 'error'; environmentId?: number; environmentName?: string; + eventType?: string; } export interface NotificationResult { diff --git a/src/lib/server/notifications/zabbix.ts b/src/lib/server/notifications/zabbix.ts new file mode 100644 index 0000000..cd7f28a --- /dev/null +++ b/src/lib/server/notifications/zabbix.ts @@ -0,0 +1,180 @@ +import { + notificationFetch, + type NotificationPayload, + type NotificationResult +} from './shared'; + +interface ZabbixApiResponse { + jsonrpc?: string; + + result?: { + response?: string; + data?: Array<{ + itemid?: string; + error?: string; + }>; + }; + + error?: { + code?: number; + message?: string; + data?: string; + }; + + id?: number; +} + +export async function sendZabbix( + zabbixUrl: string, + payload: NotificationPayload +): Promise { + try { + const secure = /^zabbixs:\/\//i.test(zabbixUrl); + const httpUrl = zabbixUrl.replace( + /^zabbixs?:\/\//i, + secure ? 'https://' : 'http://' + ); + const url = new URL(httpUrl); + const token = url.searchParams.get('token'); + const host = url.searchParams.get('host'); + const key = url.searchParams.get('key'); + + if (!token) { + return { + success: false, + error: 'Zabbix API token is required' + }; + } + + if (!host) { + return { + success: false, + error: 'Zabbix host is required' + }; + } + + if (!key) { + return { + success: false, + error: 'Zabbix item key is required' + }; + } + + /* + * These parameters belong to Dockhand's Zabbix URL syntax, + * not to the actual Zabbix API URL. + */ + url.searchParams.delete('token'); + url.searchParams.delete('host'); + url.searchParams.delete('key'); + + /* + * Allow shorthand: + * + * zabbixs://zabbix.example.com?... + * + * instead of requiring /api_jsonrpc.php explicitly. + */ + if (!url.pathname || url.pathname === '/') { + url.pathname = '/api_jsonrpc.php'; + } + + const event = { + event_type: payload.eventType ?? null, + title: payload.title, + message: payload.message, + type: payload.type ?? 'info', + environment: payload.environmentName ?? null, + timestamp: new Date().toISOString() + }; + + const requestBody = { + jsonrpc: '2.0', + method: 'history.push', + params: [ + { + host, + key, + value: JSON.stringify(event) + } + ], + id: 1 + }; + + const response = await notificationFetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json-rpc', + 'Accept': 'application/json' + }, + body: JSON.stringify(requestBody) + }); + + const responseText = await response.text(); + + if (!response.ok) { + return { + success: false, + error: `Zabbix HTTP error ${response.status}: ${ + responseText || response.statusText + }` + }; + } + + let result: ZabbixApiResponse; + + try { + result = JSON.parse(responseText); + } catch { + return { + success: false, + error: 'Zabbix returned an invalid JSON response' + }; + } + + if (result.error) { + const parts = [ + result.error.message, + result.error.data + ].filter(Boolean); + + return { + success: false, + error: `Zabbix API error: ${parts.join(' - ')}` + }; + } + + if (result.result?.response !== 'success') { + return { + success: false, + error: 'Zabbix history.push did not return success' + }; + } + + const itemErrors = + result.result.data + ?.filter(item => item.error) + .map(item => item.error!) ?? []; + + if (itemErrors.length > 0) { + return { + success: false, + error: `Zabbix history.push error: ${itemErrors.join('; ')}` + }; + } + + return { + success: true + }; + + } catch (error) { + return { + success: false, + error: + error instanceof Error + ? `Zabbix connection failed: ${error.message}` + : `Zabbix connection failed: ${String(error)}` + }; + } +} diff --git a/src/routes/settings/notifications/NotificationModal.svelte b/src/routes/settings/notifications/NotificationModal.svelte index df53a10..0c71293 100644 --- a/src/routes/settings/notifications/NotificationModal.svelte +++ b/src/routes/settings/notifications/NotificationModal.svelte @@ -455,12 +455,18 @@ bark://host/bark_key barks://host/bark_key signal://host:8080/+sender/+recipient apprise://host:8000/your-key -jsons://hostname/webhook/path" +jsons://hostname/webhook/path +zabbix://hostname/api_jsonrpc.php?token=TOKEN&host=HOST&key=ITEM_KEY +zabbixs://hostname/api_jsonrpc.php?token=TOKEN&host=HOST&key=ITEM_KEY" class="flex min-h-[220px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" >

- Supports Discord, Slack, Mattermost, Telegram, ntfy, Gotify, Pushover, Bark, Signal (via signal-cli-rest-api), Microsoft Teams (via Workflows), and generic JSON. Or use apprise:// to forward to a self-hosted caronc/apprise-api server for any provider Apprise upstream supports. -

+ Supports Discord, Slack, Mattermost, Telegram, ntfy, Gotify, Pushover, Bark, Signal (via signal-cli-rest-api), Microsoft Teams (via Workflows), Zabbix (via history.push), and generic JSON. Or use apprise:// to forward to a self-hosted caronc/apprise-api server for any provider Apprise upstream supports. +

+

+ Zabbix 7.x: + zabbixs://hostname/api_jsonrpc.php?token=TOKEN&host=HOST&key=ITEM_KEY +

{/if} diff --git a/src/routes/settings/notifications/NotificationsTab.svelte b/src/routes/settings/notifications/NotificationsTab.svelte index cda1b0b..3fbc3b5 100644 --- a/src/routes/settings/notifications/NotificationsTab.svelte +++ b/src/routes/settings/notifications/NotificationsTab.svelte @@ -133,7 +133,7 @@

Notification channels

- Configure notification channels to receive alerts about Docker events. Supports SMTP email and webhook URLs (Discord, Slack, Telegram, ntfy, Bark, Signal, Apprise, and more). + Configure notification channels to receive alerts about Docker events. Supports SMTP email and webhook URLs (Discord, Slack, Telegram, ntfy, Bark, Signal, Zabbix, Apprise, and more).

diff --git a/tests/zabbix-notifications.test.ts b/tests/zabbix-notifications.test.ts new file mode 100644 index 0000000..b41e9fa --- /dev/null +++ b/tests/zabbix-notifications.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, spyOn, test } from 'bun:test'; +import * as shared from '../src/lib/server/notifications/shared'; +import { sendZabbix } from '../src/lib/server/notifications/zabbix'; + +const successResponse = () => +new Response( +JSON.stringify({ +jsonrpc: '2.0', +result: { +response: 'success', +data: [{ itemid: '12345' }] +}, +id: 1 +}), +{ status: 200 } +); + +describe('Zabbix notifications', () => { +test('sends history.push with correct payload', async () => { +const fetchSpy = spyOn(shared, 'notificationFetch') +.mockResolvedValue(successResponse()); + +const result = await sendZabbix( +'zabbixs://zabbix.example.com?token=secret-token&host=Dockhand&key=dockhand.event', +{ +title: 'Container unhealthy', +message: 'Container test is unhealthy', +type: 'error', +environmentName: 'Docker Test', +eventType: 'container_unhealthy' +} +); + +expect(result.success).toBe(true); +expect(fetchSpy).toHaveBeenCalledTimes(1); + +const [url, options] = fetchSpy.mock.calls[0]; + +expect(url.toString()).toBe( +'https://zabbix.example.com/api_jsonrpc.php' +); + +const headers = options?.headers as Record; + +expect(headers.Authorization).toBe('Bearer secret-token'); +expect(headers['Content-Type']).toBe('application/json-rpc'); + +const body = JSON.parse(options?.body as string); + +expect(body.method).toBe('history.push'); +expect(body.params[0].host).toBe('Dockhand'); +expect(body.params[0].key).toBe('dockhand.event'); + +const event = JSON.parse(body.params[0].value); + +expect(event.event_type).toBe('container_unhealthy'); +expect(event.title).toBe('Container unhealthy'); +expect(event.environment).toBe('Docker Test'); +expect(event.type).toBe('error'); + +fetchSpy.mockRestore(); +}); + +test('converts zabbix scheme to HTTP', async () => { +const fetchSpy = spyOn(shared, 'notificationFetch') +.mockResolvedValue(successResponse()); + +const result = await sendZabbix( +'zabbix://10.112.8.22/zabbix/api_jsonrpc.php?token=test&host=Dockhand&key=dockhand.event', +{ +title: 'Test', +message: 'Test', +type: 'info', +eventType: 'test' +} +); + +expect(result.success).toBe(true); + +const [url] = fetchSpy.mock.calls[0]; + +expect(url.toString()).toBe( +'http://10.112.8.22/zabbix/api_jsonrpc.php' +); + +fetchSpy.mockRestore(); +}); + +test('returns error for Zabbix API error', async () => { +const fetchSpy = spyOn(shared, 'notificationFetch') +.mockResolvedValue( +new Response( +JSON.stringify({ +jsonrpc: '2.0', +error: { +code: -32602, +message: 'Invalid params.', +data: 'Invalid host.' +}, +id: 1 +}), +{ status: 200 } +) +); + +const result = await sendZabbix( +'zabbixs://zabbix.example.com?token=test&host=Dockhand&key=dockhand.event', +{ +title: 'Test', +message: 'Test' +} +); + +expect(result.success).toBe(false); +expect(result.error).toContain('Zabbix API error'); + +fetchSpy.mockRestore(); +}); + +test('returns error when history.push rejects item', async () => { +const fetchSpy = spyOn(shared, 'notificationFetch') +.mockResolvedValue( +new Response( +JSON.stringify({ +jsonrpc: '2.0', +result: { +response: 'success', +data: [ +{ +itemid: '12345', +error: 'No permissions to referred object.' +} +] +}, +id: 1 +}), +{ status: 200 } +) +); + +const result = await sendZabbix( +'zabbixs://zabbix.example.com?token=test&host=Dockhand&key=dockhand.event', +{ +title: 'Test', +message: 'Test' +} +); + +expect(result.success).toBe(false); +expect(result.error).toContain('history.push error'); + +fetchSpy.mockRestore(); +}); + +test('does not send request without API token', async () => { +const fetchSpy = spyOn(shared, 'notificationFetch'); + +const result = await sendZabbix( +'zabbixs://zabbix.example.com?host=Dockhand&key=dockhand.event', +{ +title: 'Test', +message: 'Test' +} +); + +expect(result.success).toBe(false); +expect(result.error).toContain('token'); +expect(fetchSpy).not.toHaveBeenCalled(); + +fetchSpy.mockRestore(); +}); +});