Skip to content
Merged
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
249 changes: 249 additions & 0 deletions server/notification-providers/fluxer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
const { DOWN, UP } = require("../../src/util");

class Fluxer extends NotificationProvider {
name = "fluxer";

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

try {
let config = this.getAxiosConfigWithProxy({});
const fluxerDisplayName = notification.fluxerUsername || "Uptime Kuma";
const webhookUrl = new URL(notification.fluxerWebhookUrl);

// Check if the webhook has an avatar
let webhookHasAvatar = true;
try {
const webhookInfo = await axios.get(webhookUrl.toString(), config);
webhookHasAvatar = !!webhookInfo.data.avatar;
} catch (e) {
// If we can't verify, we assume he has an avatar to avoid forcing the default avatar
webhookHasAvatar = true;
}

const messageFormat =
notification.fluxerMessageFormat || (notification.fluxerUseMessageTemplate ? "custom" : "normal");

// If heartbeatJSON is null, assume we're testing.
if (heartbeatJSON == null) {
let content = msg;
if (messageFormat === "minimalist") {
content = "Test: " + msg;
} else if (messageFormat === "custom") {
const customMessage = notification.fluxerMessageTemplate?.trim() || "";
if (customMessage !== "") {
content = await this.renderTemplate(customMessage, msg, monitorJSON, heartbeatJSON);
}
}
let fluxertestdata = {
username: fluxerDisplayName,
content: content,
};
if (!webhookHasAvatar) {
fluxertestdata.avatar_url = "https://github.com/louislam/uptime-kuma/raw/master/public/icon.png";
}
await axios.post(webhookUrl.toString(), fluxertestdata, config);
return okMsg;
}

// If heartbeatJSON is not null, we go into the normal alerting loop.
let addess = this.extractAddress(monitorJSON);

// Minimalist: status + name only (is down / is up; no "back up" — may be first trigger)
if (messageFormat === "minimalist") {
const content =
heartbeatJSON["status"] === DOWN
? "🔴 " + monitorJSON["name"] + " is down."
: "🟢 " + monitorJSON["name"] + " is up.";
let payload = {
username: fluxerDisplayName,
content: content,
};
if (!webhookHasAvatar) {
payload.avatar_url = "https://github.com/louislam/uptime-kuma/raw/master/public/icon.png";
}

await axios.post(webhookUrl.toString(), payload, config);
return okMsg;
}

// Custom template: send only content (no embeds)
const useCustomTemplate =
messageFormat === "custom" && (notification.fluxerMessageTemplate?.trim() || "") !== "";
if (useCustomTemplate) {
const content = await this.renderTemplate(
notification.fluxerMessageTemplate.trim(),
msg,
monitorJSON,
heartbeatJSON
);
let payload = {
username: fluxerDisplayName,
content: content,
};
if (!webhookHasAvatar) {
payload.avatar_url = "https://github.com/louislam/uptime-kuma/raw/master/public/icon.png";
}

await axios.post(webhookUrl.toString(), payload, config);
return okMsg;
}

if (heartbeatJSON["status"] === DOWN) {
const wentOfflineTimestamp = Math.floor(new Date(heartbeatJSON["time"]).getTime() / 1000);

let fluxerdowndata = {
username: fluxerDisplayName,
embeds: [
{
title: "❌ Your service " + monitorJSON["name"] + " went down. ❌",
color: 16711680,
fields: [
{
name: "Service Name",
value: monitorJSON["name"],
},
...(!notification.disableUrl && addess
? [
{
name: monitorJSON["type"] === "push" ? "Service Type" : "Service URL",
value: addess,
},
]
: []),
{
name: "Went Offline",
// F for full date/time
value: `<t:${wentOfflineTimestamp}:F>`,
},
{
name: `Time (${heartbeatJSON["timezone"]})`,
value: heartbeatJSON["localDateTime"],
},
{
name: "Error",
value: heartbeatJSON["msg"] == null ? "N/A" : heartbeatJSON["msg"],
},
],
},
],
};
if (!webhookHasAvatar) {
fluxerdowndata.avatar_url = "https://github.com/louislam/uptime-kuma/raw/master/public/icon.png";
}
if (notification.fluxerPrefixMessage) {
fluxerdowndata.content = notification.fluxerPrefixMessage;
}

await axios.post(webhookUrl.toString(), fluxerdowndata, config);
return okMsg;
} else if (heartbeatJSON["status"] === UP) {
const backOnlineTimestamp = Math.floor(new Date(heartbeatJSON["time"]).getTime() / 1000);
let downtimeDuration = null;
let wentOfflineTimestamp = null;
if (heartbeatJSON["lastDownTime"]) {
wentOfflineTimestamp = Math.floor(new Date(heartbeatJSON["lastDownTime"]).getTime() / 1000);
downtimeDuration = this.formatDuration(backOnlineTimestamp - wentOfflineTimestamp);
}

let fluxerupdata = {
username: fluxerDisplayName,
embeds: [
{
title: "✅ Your service " + monitorJSON["name"] + " is up! ✅",
color: 65280,
fields: [
{
name: "Service Name",
value: monitorJSON["name"],
},
...(!notification.disableUrl && addess
? [
{
name: monitorJSON["type"] === "push" ? "Service Type" : "Service URL",
value: addess,
},
]
: []),
...(wentOfflineTimestamp
? [
{
name: "Went Offline",
// F for full date/time
value: `<t:${wentOfflineTimestamp}:F>`,
},
]
: []),
...(downtimeDuration
? [
{
name: "Downtime Duration",
value: downtimeDuration,
},
]
: []),
// Show server timezone for parity with the DOWN notification embed
{
name: `Time (${heartbeatJSON["timezone"]})`,
value: heartbeatJSON["localDateTime"],
},
...(heartbeatJSON["ping"] != null
? [
{
name: "Ping",
value: heartbeatJSON["ping"] + " ms",
},
]
: []),
],
},
],
};
if (!webhookHasAvatar) {
fluxerupdata.avatar_url = "https://github.com/louislam/uptime-kuma/raw/master/public/icon.png";
}
if (notification.fluxerPrefixMessage) {
fluxerupdata.content = notification.fluxerPrefixMessage;
}

await axios.post(webhookUrl.toString(), fluxerupdata, config);
return okMsg;
}
} catch (error) {
this.throwGeneralAxiosError(error);
}
}

/**
* Format duration as human-readable string (e.g., "1h 23m", "45m 30s")
* TODO: Update below to `Intl.DurationFormat("en", { style: "short" }).format(duration)` once we are on a newer node version
* @param {number} timeInSeconds The time in seconds to format a duration for
* @returns {string} The formatted duration
*/
formatDuration(timeInSeconds) {
const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = timeInSeconds % 60;

const durationParts = [];
if (hours > 0) {
durationParts.push(`${hours}h`);
}
if (minutes > 0) {
durationParts.push(`${minutes}m`);
}
if (seconds > 0 && hours === 0) {
// Only show seconds if less than an hour
durationParts.push(`${seconds}s`);
}

return durationParts.length > 0 ? durationParts.join(" ") : "0s";
}
}

module.exports = Fluxer;
2 changes: 2 additions & 0 deletions server/notification.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const CallMeBot = require("./notification-providers/call-me-bot");
const SMSC = require("./notification-providers/smsc");
const DingDing = require("./notification-providers/dingding");
const Discord = require("./notification-providers/discord");
const Fluxer = require("./notification-providers/fluxer");
const Elks = require("./notification-providers/46elks");
const Feishu = require("./notification-providers/feishu");
const Notifery = require("./notification-providers/notifery");
Expand Down Expand Up @@ -117,6 +118,7 @@ class Notification {
new SMSC(),
new DingDing(),
new Discord(),
new Fluxer(),
new Elks(),
new Feishu(),
new FreeMobile(),
Expand Down
1 change: 1 addition & 0 deletions src/components/NotificationDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ export default {
bale: "Bale",
Bitrix24: "Bitrix24",
discord: "Discord",
fluxer: "Fluxer",
GoogleChat: "Google Chat (Google Workspace)",
gorush: "Gorush",
gotify: "Gotify",
Expand Down
88 changes: 88 additions & 0 deletions src/components/notifications/Fluxer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<template>
<div class="mb-3">
<label for="fluxer-webhook-url" class="form-label">{{ $t("Fluxer Webhook URL") }}</label>
<HiddenInput
id="fluxer-webhook-url"
v-model="$parent.notification.fluxerWebhookUrl"
type="url"
class="form-control"
required
autocomplete="false"
/>
<div class="form-text">
{{ $t("wayToGetFluxerURL") }}
</div>
</div>

<div class="mb-3">
<label for="fluxer-username" class="form-label">{{ $t("Bot Display Name") }}</label>
<input
id="fluxer-username"
v-model="$parent.notification.fluxerUsername"
type="text"
class="form-control"
autocomplete="false"
:placeholder="$root.appName"
/>
</div>

<div class="mb-3">
<label for="fluxer-prefix-message" class="form-label">{{ $t("Prefix Custom Message") }}</label>
<input
id="fluxer-prefix-message"
v-model="$parent.notification.fluxerPrefixMessage"
type="text"
class="form-control"
autocomplete="false"
:placeholder="$t('Hello @everyone is...')"
/>
</div>

<div class="mb-3">
<label for="fluxer-message-format" class="form-label">{{ $t("fluxerMessageFormat") }}</label>
<select id="fluxer-message-format" v-model="$parent.notification.fluxerMessageFormat" class="form-select">
<option value="normal">{{ $t("fluxerMessageFormatNormal") }}</option>
<option value="minimalist">{{ $t("fluxerMessageFormatMinimalist") }}</option>
<option value="custom">{{ $t("fluxerMessageFormatCustom") }}</option>
</select>
</div>

<div v-show="$parent.notification.fluxerMessageFormat === 'custom'">
<div class="mb-3">
<label for="fluxer-message-template" class="form-label">{{ $t("fluxerMessageTemplate") }}</label>
<TemplatedTextarea
id="fluxer-message-template"
v-model="$parent.notification.fluxerMessageTemplate"
:required="false"
placeholder=""
></TemplatedTextarea>
<div class="form-text">{{ $t("fluxerUseMessageTemplateDescription") }}</div>
</div>
</div>
</template>
<script>
import HiddenInput from "../HiddenInput.vue";
import TemplatedTextarea from "../TemplatedTextarea.vue";

export default {
components: {
TemplatedTextarea,
HiddenInput,
},
mounted() {
if (!this.$parent.notification.fluxerChannelType) {
this.$parent.notification.fluxerChannelType = "channel";
}
if (this.$parent.notification.disableUrl === undefined) {
this.$parent.notification.disableUrl = false;
}
// Message format: default "normal"; migrate from old checkbox
if (typeof this.$parent.notification.fluxerMessageFormat === "undefined") {
const hadCustom =
this.$parent.notification.fluxerUseMessageTemplate === true ||
!!this.$parent.notification.fluxerMessageTemplate?.trim();
this.$parent.notification.fluxerMessageFormat = hadCustom ? "custom" : "normal";
}
},
};
</script>
2 changes: 2 additions & 0 deletions src/components/notifications/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import CallMeBot from "./CallMeBot.vue";
import SMSC from "./SMSC.vue";
import DingDing from "./DingDing.vue";
import Discord from "./Discord.vue";
import Fluxer from "./Fluxer.vue";
import Elks from "./46elks.vue";
import Feishu from "./Feishu.vue";
import FreeMobile from "./FreeMobile.vue";
Expand Down Expand Up @@ -105,6 +106,7 @@ const NotificationFormList = {
smsir: SMSIR,
DingDing: DingDing,
discord: Discord,
fluxer: Fluxer,
Elks: Elks,
Feishu: Feishu,
FreeMobile: FreeMobile,
Expand Down
9 changes: 9 additions & 0 deletions src/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1358,6 +1358,15 @@
"discordUseMessageTemplate": "Use custom message template",
"discordUseMessageTemplateDescription": "If enabled, the message will be sent using a custom template (LiquidJS). Leave blank to use the default Uptime Kuma format.",
"discordMessageTemplate": "Message Template",
"fluxerMessageFormat": "Message Format",
"fluxerMessageFormatNormal": "Normal (rich embeds)",
"fluxerMessageFormatMinimalist": "Minimalist (short status)",
"fluxerMessageFormatCustom": "Custom template",
"fluxerUseMessageTemplate": "Use custom message template",
"fluxerUseMessageTemplateDescription": "If enabled, the message will be sent using a custom template (LiquidJS). Leave blank to use the default Uptime Kuma format.",
"fluxerMessageTemplate": "Message Template",
"Fluxer Webhook URL": "Fluxer Webhook URL",
"wayToGetFluxerURL": "You can get this by going to the target channel's settings > Webhooks > Create Webhook > Copy Webhook URL.",
"Ip Family": "IP Family",
"ipFamilyDescriptionAutoSelect": "Uses the {happyEyeballs} for determining the IP family.",
"Happy Eyeballs algorithm": "Happy Eyeballs algorithm",
Expand Down
Loading