Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions server/notification-providers/trigv.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
const { DOWN, UP } = require("../../src/util");

class Trigv extends NotificationProvider {
name = "trigv";

/**
* @inheritdoc
*/
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
const okMsg = "Sent Successfully.";

try {
let config = {
headers: {
Authorization: "Bearer " + notification.trigvApiKey,
"Content-Type": "application/json",
Accept: "application/json",
},
};
config = this.getAxiosConfigWithProxy(config);

let title = monitorJSON?.name || "Uptime Kuma";
let description = msg;
let level = "info";
if (heartbeatJSON != null) {
if (heartbeatJSON.status === DOWN) {
title += " is down";
level = "error";
} else if (heartbeatJSON.status === UP) {
title += " is up";
level = "success";
}
description = heartbeatJSON.msg + `\nTime (${heartbeatJSON.timezone}): ${heartbeatJSON.localDateTime}`;
}

const data = {
channel: notification.trigvChannel || "general",
title: title.slice(0, 255),
description: description.slice(0, 1000),
level,
};
if (monitorJSON?.url && monitorJSON.url.length <= 2048 && URL.canParse(monitorJSON.url)) {
const url = new URL(monitorJSON.url);
if (url.protocol === "http:" || url.protocol === "https:") {
data.url = monitorJSON.url;
}
}

await axios.post("https://api.trigv.com/v1/events", data, {
...config,
validateStatus: (status) => status === 202,
});
return okMsg;
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}

module.exports = Trigv;
2 changes: 2 additions & 0 deletions server/notification.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const Telegram = require("./notification-providers/telegram");
const Teltonika = require("./notification-providers/teltonika");
const Telnyx = require("./notification-providers/telnyx");
const Threema = require("./notification-providers/threema");
const Trigv = require("./notification-providers/trigv");
const Twilio = require("./notification-providers/twilio");
const Splunk = require("./notification-providers/splunk");
const Webhook = require("./notification-providers/webhook");
Expand Down Expand Up @@ -207,6 +208,7 @@ class Notification {
new Teltonika(),
new Telnyx(),
new Threema(),
new Trigv(),
new Twilio(),
new Splunk(),
new Webhook(),
Expand Down
1 change: 1 addition & 0 deletions src/components/NotificationDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ export default {
pushover: "Pushover",
pushy: "Pushy",
signalgrid: "Signalgrid",
trigv: "Trigv",
Webpush: "Webpush",
};

Expand Down
36 changes: 36 additions & 0 deletions src/components/notifications/Trigv.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<template>
<div class="mb-3">
<label for="trigv-api-key" class="form-label">{{ $t("API Key") }}</label>
<HiddenInput
id="trigv-api-key"
v-model="$parent.notification.trigvApiKey"
:required="true"
autocomplete="new-password"
></HiddenInput>
</div>
<div class="mb-3">
<label for="trigv-channel" class="form-label">{{ $t("Channel") }}</label>
<input
id="trigv-channel"
v-model="$parent.notification.trigvChannel"
type="text"
class="form-control"
maxlength="120"
:placeholder="$t('trigvDefaultChannel')"
/>
<div class="form-text">{{ $t("trigvChannelHelp") }}</div>
</div>
<i18n-t tag="p" keypath="More info on:" style="margin-top: 8px">
<a href="https://trigv.com/docs/learn/api-keys/" target="_blank">{{ $t("trigvApiKeyDocs") }}</a>
</i18n-t>
</template>

<script>
import HiddenInput from "../HiddenInput.vue";

export default {
components: {
HiddenInput,
},
};
</script>
2 changes: 2 additions & 0 deletions src/components/notifications/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import Telegram from "./Telegram.vue";
import Teltonika from "./Teltonika.vue";
import Telnyx from "./Telnyx.vue";
import Threema from "./Threema.vue";
import Trigv from "./Trigv.vue";
import Twilio from "./Twilio.vue";
import Webhook from "./Webhook.vue";
import WeCom from "./WeCom.vue";
Expand Down Expand Up @@ -195,6 +196,7 @@ const NotificationFormList = {
Teltonika: Teltonika,
telnyx: Telnyx,
threema: Threema,
trigv: Trigv,
twilio: Twilio,
Splunk: Splunk,
SpugPush: SpugPush,
Expand Down
5 changes: 4 additions & 1 deletion src/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1650,5 +1650,8 @@
"msGraphClientSecretLink": "create a client secret",
"OpsiMate URL": "OpsiMate URL",
"opsimateUrlDescription": "The base URL of the OpsiMate API (the server port, not the web UI).",
"opsimateApiTokenDescription": "The value of the API_TOKEN setting of your OpsiMate server (default: opsimate)."
"opsimateApiTokenDescription": "The value of the API_TOKEN setting of your OpsiMate server (default: opsimate).",
"trigvDefaultChannel": "general",
"trigvChannelHelp": "Enter your channel slug (up to 120 characters). Leave empty to use general.",
"trigvApiKeyDocs": "Trigv API key documentation"
}
148 changes: 148 additions & 0 deletions test/backend-test/notification-providers/test-trigv.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
const { describe, test, beforeEach, afterEach, mock } = require("node:test");
const assert = require("node:assert");
const axios = require("axios");

const Trigv = require("../../../server/notification-providers/trigv");
const { DOWN, UP } = require("../../../src/util");

describe("Trigv notification provider", () => {
const notification = {
trigvApiKey: "trgv_Ab123456_" + "a".repeat(32),
trigvChannel: "monitoring",
};
const monitor = { name: "Website", url: "https://example.com/status" };
const heartbeat = {
status: DOWN,
msg: "Connection timed out",
timezone: "UTC",
localDateTime: "2026-11-14 12:00:00",
};
let post;

beforeEach(() => {
post = mock.method(axios, "post", async () => ({ status: 202 }));
});

afterEach(() => mock.restoreAll());

test("sends one DOWN alert with the endpoint, headers, body and proxy config", async () => {
const provider = new Trigv();
const proxyConfig = mock.method(provider, "getAxiosConfigWithProxy", (config) => ({ ...config, proxy: false }));
const result = await provider.send(notification, "Alert", monitor, heartbeat);

assert.strictEqual(provider.name, "trigv");
assert.strictEqual(result, "Sent Successfully.");
assert.strictEqual(post.mock.callCount(), 1);
const [url, body, config] = post.mock.calls[0].arguments;
assert.strictEqual(url, "https://api.trigv.com/v1/events");
assert.deepStrictEqual(config.headers, {
Authorization: "Bearer " + notification.trigvApiKey,
"Content-Type": "application/json",
Accept: "application/json",
});
assert.strictEqual(proxyConfig.mock.callCount(), 1);
assert.strictEqual(config.proxy, false);
assert.strictEqual(config.validateStatus(202), true);
for (const status of [200, 401, 404, 422, 429]) {
assert.strictEqual(config.validateStatus(status), false);
}
assert.deepStrictEqual(body, {
channel: "monitoring",
title: "Website is down",
description: "Connection timed out\nTime (UTC): 2026-11-14 12:00:00",
level: "error",
url: monitor.url,
});
});

test("sends an UP alert", async () => {
await new Trigv().send(notification, "Alert", monitor, { ...heartbeat, status: UP, msg: "OK" });
assert.deepStrictEqual(post.mock.calls[0].arguments[1], {
channel: "monitoring",
title: "Website is up",
description: "OK\nTime (UTC): 2026-11-14 12:00:00",
level: "success",
url: monitor.url,
});
});

test("sends test notifications when both JSON objects are null", async () => {
await new Trigv().send(notification, "Test notification", null, null);
assert.deepStrictEqual(post.mock.calls[0].arguments[1], {
channel: "monitoring",
title: "Uptime Kuma",
description: "Test notification",
level: "info",
});
});

for (const message of ["Certificate expires soon", "Domain expires soon"]) {
test(`sends an info alert for ${message}`, async () => {
await new Trigv().send(notification, message, monitor, null);
assert.deepStrictEqual(post.mock.calls[0].arguments[1], {
channel: "monitoring",
title: "Website",
description: message,
level: "info",
url: monitor.url,
});
});
}

test("defaults missing and empty channels to general", async () => {
for (const trigvChannel of [undefined, ""]) {
await new Trigv().send({ ...notification, trigvChannel }, "Test");
}
for (const call of post.mock.calls) {
assert.strictEqual(call.arguments[1].channel, "general");
}
});

test("truncates titles and descriptions for heartbeats and info alerts", async () => {
const name = "n".repeat(256);
const message = "m".repeat(1001);
for (const event of [null, { ...heartbeat, msg: message }]) {
await new Trigv().send(notification, message, { name }, event);
}
for (const call of post.mock.calls) {
assert.strictEqual(call.arguments[1].title, "n".repeat(255));
assert.strictEqual(call.arguments[1].description, "m".repeat(1000));
}
});

test("includes only usable HTTP and HTTPS URLs within the API limit", async () => {
const urls = [
"http://example.com/",
"https://example.com/",
undefined,
"",
"https://",
"example.com",
"127.0.0.1:3000",
"ftp://example.com/",
"docker://container",
"https://example.com/" + "a".repeat(2048),
];
for (const url of urls) {
await new Trigv().send(notification, "Test", { name: "Monitor", url });
}
for (const [index, call] of post.mock.calls.entries()) {
const body = call.arguments[1];
if (index < 2) {
assert.strictEqual(body.url, urls[index]);
} else {
assert.strictEqual(Object.hasOwn(body, "url"), false);
}
}
});

test("surfaces API errors with their status and message", async () => {
post.mock.mockImplementation(async () => {
throw new axios.AxiosError("Request failed with status code 401", "ERR_BAD_REQUEST", undefined, undefined, {
status: 401,
data: { message: "Invalid API key" },
});
});
await assert.rejects(new Trigv().send(notification, "Test"), /HTTP 401.*Invalid API key/);
});
});
Loading