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:
1 parent
8f29216701
commit
7a007bd623
6 files changed
+374
-6
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface NotificationPayload {
|
||||
type?: 'info' | 'success' | 'warning' | 'error';
|
||||
environmentId?: number;
|
||||
environmentName?: string;
|
||||
eventType?: string;
|
||||
}
|
||||
|
||||
export interface NotificationResult {
|
||||
|
||||
@@ -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&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"
|
||||
></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&host=HOST&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" />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user