feat(notifications): add Zabbix history.push provider (#1419)

* feat(notifications): add Zabbix history.push provider

* test(notifications): add Zabbix provider tests
This commit is contained in:
Sergei Nesterkov authored and GitHub committed 2026-08-17 15:24:47 +02:00
1 parent 8f29216701
commit 7a007bd623
6 files changed
+374 -6

No files matched your search

+11 -2
View File
@@ -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) {
+1
View File
@@ -14,6 +14,7 @@ export interface NotificationPayload {
type?: 'info' | 'success' | 'warning' | 'error';
environmentId?: number;
environmentName?: string;
eventType?: string;
}
export interface NotificationResult {
+180
View File
@@ -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<NotificationResult> {
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)}`
};
}
}
@@ -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&amp;host=HOST&amp;key=ITEM_KEY
zabbixs://hostname/api_jsonrpc.php?token=TOKEN&amp;host=HOST&amp;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"
></textarea>
<p class="text-xs text-muted-foreground">
Supports Discord, Slack, Mattermost, Telegram, ntfy, Gotify, Pushover, Bark, Signal (via signal-cli-rest-api), Microsoft Teams (via Workflows), and generic JSON. Or use <code>apprise://</code> to forward to a self-hosted <a href="https://github.com/caronc/apprise-api" target="_blank" rel="noopener">caronc/apprise-api</a> server for any provider Apprise upstream supports.
</p>
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 <code>apprise://</code> to forward to a self-hosted <a href="https://github.com/caronc/apprise-api" target="_blank" rel="noopener">caronc/apprise-api</a> server for any provider Apprise upstream supports.
</p>
<p class="text-xs text-muted-foreground">
Zabbix 7.x:
<code>zabbixs://hostname/api_jsonrpc.php?token=TOKEN&amp;host=HOST&amp;key=ITEM_KEY</code>
</p>
</div>
</div>
{/if}
@@ -133,7 +133,7 @@
<div>
<p class="text-sm font-medium">Notification channels</p>
<p class="text-xs text-muted-foreground mt-1">
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).
</p>
<p class="text-xs text-amber-600 dark:text-amber-500 mt-2 flex items-center gap-1">
<Info class="w-3 h-3" />
+172
View File
@@ -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<string, string>;
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();
});
});