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