Compare commits

...

35 Commits

Author SHA1 Message Date
Louis Lam
703ae3e082 Update Dependencies 2025-08-31 01:34:39 +08:00
Louis Lam
85455a1ebc Fix: update cloudflared installation to use bookworm instead of bullseye (#6093) 2025-08-31 01:29:32 +08:00
Louis Lam
a4d2e077b8 Fix: Check MySQL database name (#5991) 2025-08-31 01:26:32 +08:00
Cyril59310
668636c9d5 feat: add clear events botton (#6052)
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2025-08-27 15:04:21 +02:00
Toubi
8d3649966a Feature/deletion button on status list item (#6079) 2025-08-25 15:31:32 +02:00
Jona Bastian
bc2db2e36e fix: HeartbeatBar DOWN status showing green instead of red (#6081)
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2025-08-25 05:01:29 +02:00
Jan Niklas Benn
7587269b62 Fix monitor name cropping in nested groups (#5981) (#6080) 2025-08-25 02:31:29 +02:00
Erik
4f944cd869 feat: Templating and plaintext for Google Workspace Notification Provider (#6048)
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2025-08-09 19:31:44 +02:00
Kanwarpreet Singh
f027ce309e Made chart-period global instead of individual (#6049)
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2025-08-09 18:52:15 +02:00
Louis Lam
4b5ff08cdd Update dependencies (#6016) 2025-07-29 20:09:47 +08:00
Louis Lam
e4baa99088 Fix weblate conflict (#6017) 2025-07-29 20:03:10 +08:00
Louis Lam
b9ac9fbb08 Translations Update from Weblate (#5971) 2025-07-29 19:50:09 +08:00
JianChao Ye
771d05363e fix: send slack message throw 400 invalid_attachments (#6014) 2025-07-28 16:55:46 +02:00
Aluisio
d073d1642f Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (1132 of 1132 strings)

Co-authored-by: Aluisio <aluisiodeavila@hotmail.com>
Translate-URL: https://weblate.kuma.pet/projects/uptime-kuma/uptime-kuma/pt_BR/
Translation: Uptime Kuma/Uptime Kuma
2025-07-26 08:06:56 +00:00
stanol
20a11846d6 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1132 of 1132 strings)

Co-authored-by: stanol <stanol777@gmail.com>
Translate-URL: https://weblate.kuma.pet/projects/uptime-kuma/uptime-kuma/uk/
Translation: Uptime Kuma/Uptime Kuma
2025-07-26 08:06:56 +00:00
Cyril59310
d360ce808d Translated using Weblate (French)
Currently translated at 100.0% (1132 of 1132 strings)

Co-authored-by: Cyril59310 <archas.cyril@hotmail.fr>
Translate-URL: https://weblate.kuma.pet/projects/uptime-kuma/uptime-kuma/fr/
Translation: Uptime Kuma/Uptime Kuma
2025-07-26 08:06:56 +00:00
Marco
3c84abb3fd Translated using Weblate (German)
Currently translated at 100.0% (1132 of 1132 strings)

Translated using Weblate (German (Switzerland))

Currently translated at 100.0% (1132 of 1132 strings)

Co-authored-by: Marco <marco@nanoweb.ch>
Translate-URL: https://weblate.kuma.pet/projects/uptime-kuma/uptime-kuma/de/
Translate-URL: https://weblate.kuma.pet/projects/uptime-kuma/uptime-kuma/de_CH/
Translation: Uptime Kuma/Uptime Kuma
2025-07-26 08:06:56 +00:00
MrEddX
01e1edb545 Translated using Weblate (Bulgarian)
Currently translated at 100.0% (1132 of 1132 strings)

Co-authored-by: MrEddX <mreddx@chatrix.one>
Translate-URL: https://weblate.kuma.pet/projects/uptime-kuma/uptime-kuma/bg/
Translation: Uptime Kuma/Uptime Kuma
2025-07-26 08:06:56 +00:00
Jusi Monteiro
102d70d8a7 Translated using Weblate (Portuguese)
Currently translated at 19.3% (218 of 1127 strings)

Co-authored-by: Jusi Monteiro <jusi.monteiro@protonmail.com>
Translate-URL: https://weblate.kuma.pet/projects/uptime-kuma/uptime-kuma/pt/
Translation: Uptime Kuma/Uptime Kuma
2025-07-26 08:06:56 +00:00
Jochem Pluim
8191f49b6c Translated using Weblate (Dutch)
Currently translated at 95.4% (1076 of 1127 strings)

Translated using Weblate (Dutch)

Currently translated at 95.2% (1074 of 1127 strings)

Co-authored-by: Jochem Pluim <jochem@pluim.nu>
Translate-URL: https://weblate.kuma.pet/projects/uptime-kuma/uptime-kuma/nl/
Translation: Uptime Kuma/Uptime Kuma
2025-07-26 08:06:56 +00:00
Andrey Sheremetinskiy
29ec447cc8 Translated using Weblate (Russian)
Currently translated at 95.3% (1075 of 1127 strings)

Co-authored-by: Andrey Sheremetinskiy <andrew.sherd@outlook.com>
Translate-URL: https://weblate.kuma.pet/projects/uptime-kuma/uptime-kuma/ru/
Translation: Uptime Kuma/Uptime Kuma
2025-07-26 08:06:56 +00:00
Marco Ciotola
2f5ca5aa19 Translated using Weblate (Italian)
Currently translated at 66.1% (746 of 1127 strings)

Co-authored-by: Marco Ciotola <github@ciotola.dev>
Translate-URL: https://weblate.kuma.pet/projects/uptime-kuma/uptime-kuma/it/
Translation: Uptime Kuma/Uptime Kuma
2025-07-26 08:06:56 +00:00
Donker_Jumala
5a387538dc Translated using Weblate (Japanese)
Currently translated at 100.0% (1127 of 1127 strings)

Co-authored-by: Donker_Jumala <weareh0711@gmail.com>
Translate-URL: https://weblate.kuma.pet/projects/uptime-kuma/uptime-kuma/ja/
Translation: Uptime Kuma/Uptime Kuma
2025-07-26 08:06:56 +00:00
Buchtič
2e0299b76a Translated using Weblate (Czech)
Currently translated at 100.0% (1127 of 1127 strings)

Co-authored-by: Buchtič <martin.buchta@gmail.com>
Translate-URL: https://weblate.kuma.pet/projects/uptime-kuma/uptime-kuma/cs/
Translation: Uptime Kuma/Uptime Kuma
2025-07-26 08:06:56 +00:00
Talip ÇAKIR
2ec9fcca6d Translated using Weblate (Turkish)
Currently translated at 100.0% (1127 of 1127 strings)

Co-authored-by: Talip ÇAKIR <talip@cakir.info.tr>
Translate-URL: https://weblate.kuma.pet/projects/uptime-kuma/uptime-kuma/tr/
Translation: Uptime Kuma/Uptime Kuma
2025-07-26 08:06:56 +00:00
Aindriú Mac Giolla Eoin
0713d44d37 Translated using Weblate (Irish)
Currently translated at 100.0% (1127 of 1127 strings)

Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Translate-URL: https://weblate.kuma.pet/projects/uptime-kuma/uptime-kuma/ga/
Translation: Uptime Kuma/Uptime Kuma
2025-07-26 08:06:56 +00:00
Süleyman Ünlü
bdf40835cc Translated using Weblate (Turkish)
Currently translated at 99.9% (1126 of 1127 strings)

Co-authored-by: Süleyman Ünlü <suleymn20@proton.me>
Translate-URL: https://weblate.kuma.pet/projects/uptime-kuma/uptime-kuma/tr/
Translation: Uptime Kuma/Uptime Kuma
2025-07-26 08:06:56 +00:00
Vivek Pandey
c1adcfbfc2 feat(ui): Convert interval seconds to days, hours, minutes, and seconds (#5220)
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2025-07-26 10:06:51 +02:00
Paulus Lucas
2a6d9b4acd Add Websocket path to mqtt monitor for WebSocket connection (#6009)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lupaulus <20111917+lupaulus@users.noreply.github.com>
2025-07-24 22:04:43 +02:00
Otto Richter (fnetX)
2fd4e1cc72 Matrix token command hint should send JSON (#5990)
Co-authored-by: Otto Richter <otto@codeberg.org>
2025-07-17 08:47:51 +02:00
Ionys
7c88a38df3 Fixing recurring maintenance start (again) (#5914)
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2025-07-16 17:28:29 +02:00
yumeiyin
d490285a44 chore: fix some minor issues in comments (#5984)
Signed-off-by: yumeiyin <yin.yumei@qq.com>
2025-07-14 10:08:53 +02:00
Peak Twilight
5bbbef5305 feat: Add heartbeat tooltip while hovering over status page heartbeats (#5929)
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2025-07-12 13:34:33 +02:00
Lyall
487cb8fdc5 fix: refresh interval getting incremented by 10 on status page despite a minimum of 5 (#5961)
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2025-07-11 23:41:43 +02:00
Louis Lam
03037e2a9a Delete .github/workflows/pr-reply.yml (#5975) 2025-07-09 16:22:55 +02:00
47 changed files with 2688 additions and 1315 deletions

View File

@@ -1,36 +0,0 @@
# Replys a message to all new PRs
# The message:
# - Say hello and thanks to the contributor
# - Mention maintainers will review the PR soon
# - To other people, show the testing pr command: npx kuma-pr <username:branch>
# - Also show the advanced usage link: https://github.com/louislam/uptime-kuma/wiki/Test-Pull-Requests
name: Reply to PRs
on:
pull_request:
types: [opened, reopened]
permissions:
issues: write
pull-requests: write
contents: read
jobs:
reply:
runs-on: ubuntu-latest
steps:
- name: Reply to PR
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const message = `Hello @${pr.user.login}, thank you for your contribution! :tada:\n` +
`The maintainers will review your PR soon.\n\n` +
`If anyone would like to help test this PR, you can use the command:\n` +
`\`\`\`bash\nnpx kuma-pr ${pr.user.login}:${pr.head.ref}\n\`\`\`\n\n` +
`<sub> For advanced usage, please refer to our [wiki](https://github.com/louislam/uptime-kuma/wiki/Test-Pull-Requests) </sub>`;
await github.rest.issues.createComment({
issue_number: pr.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: message
});

View File

@@ -0,0 +1,34 @@
// Add column last_start_date to maintenance table
exports.up = async function (knex) {
await knex.schema
.alterTable("maintenance", function (table) {
table.datetime("last_start_date");
});
// Perform migration for recurring-interval strategy
const recurringMaintenances = await knex("maintenance").where({
strategy: "recurring-interval",
cron: "* * * * *"
}).select("id", "start_time");
// eslint-disable-next-line camelcase
const maintenanceUpdates = recurringMaintenances.map(async ({ start_time, id }) => {
// eslint-disable-next-line camelcase
const [ hourStr, minuteStr ] = start_time.split(":");
const hour = parseInt(hourStr, 10);
const minute = parseInt(minuteStr, 10);
const cron = `${minute} ${hour} * * *`;
await knex("maintenance")
.where({ id })
.update({ cron });
});
await Promise.all(maintenanceUpdates);
};
exports.down = function (knex) {
return knex.schema.alterTable("maintenance", function (table) {
table.dropColumn("last_start_date");
});
};

View File

@@ -0,0 +1,15 @@
exports.up = function (knex) {
// Add new column monitor.mqtt_websocket_path
return knex.schema
.alterTable("monitor", function (table) {
table.string("mqtt_websocket_path", 255).nullable();
});
};
exports.down = function (knex) {
// Drop column monitor.mqtt_websocket_path
return knex.schema
.alterTable("monitor", function (table) {
table.dropColumn("mqtt_websocket_path");
});
};

View File

@@ -47,9 +47,9 @@ RUN apt update && \
# Install cloudflared
RUN curl https://pkg.cloudflare.com/cloudflare-main.gpg --output /usr/share/keyrings/cloudflare-main.gpg && \
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared bullseye main' | tee /etc/apt/sources.list.d/cloudflared.list && \
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared bookworm main' | tee /etc/apt/sources.list.d/cloudflared.list && \
apt update && \
apt install --yes --no-install-recommends -t stable cloudflared && \
apt install --yes --no-install-recommends cloudflared && \
cloudflared version && \
rm -rf /var/lib/apt/lists/* && \
apt --yes autoremove

2210
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -99,6 +99,7 @@
"http-proxy-agent": "~7.0.2",
"https-proxy-agent": "~7.0.6",
"iconv-lite": "~0.6.3",
"is-url": "^1.2.4",
"isomorphic-ws": "^5.0.0",
"jsesc": "~3.0.2",
"jsonata": "^2.0.3",
@@ -137,6 +138,7 @@
"socket.io": "~4.8.0",
"socket.io-client": "~4.8.0",
"socks-proxy-agent": "~8.0.5",
"sqlstring": "~2.3.3",
"tar": "~6.2.1",
"tcp-ping": "~0.1.1",
"thirty-two": "~1.0.2",

View File

@@ -12,6 +12,7 @@ const { UptimeCalculator } = require("./uptime-calculator");
const dayjs = require("dayjs");
const { SimpleMigrationServer } = require("./utils/simple-migration-server");
const KumaColumnCompiler = require("./utils/knex/lib/dialects/mysql2/schema/mysql2-columncompiler");
const SqlString = require("sqlstring");
/**
* Database & App Data Folder
@@ -256,10 +257,6 @@ class Database {
}
};
} else if (dbConfig.type === "mariadb") {
if (!/^\w+$/.test(dbConfig.dbName)) {
throw Error("Invalid database name. A database name can only consist of letters, numbers and underscores");
}
const connection = await mysql.createConnection({
host: dbConfig.hostname,
port: dbConfig.port,
@@ -267,7 +264,11 @@ class Database {
password: dbConfig.password,
});
await connection.execute("CREATE DATABASE IF NOT EXISTS " + dbConfig.dbName + " CHARACTER SET utf8mb4");
// Set to true, so for example "uptime.kuma", becomes `uptime.kuma`, not `uptime`.`kuma`
// Doc: https://github.com/mysqljs/sqlstring?tab=readme-ov-file#escaping-query-identifiers
const escapedDBName = SqlString.escapeId(dbConfig.dbName, true);
await connection.execute("CREATE DATABASE IF NOT EXISTS " + escapedDBName + " CHARACTER SET utf8mb4");
connection.end();
config = {

View File

@@ -202,7 +202,7 @@ class Maintenance extends BeanModel {
* @returns {void}
*/
static validateCron(cron) {
let job = new Cron(cron, () => {});
let job = new Cron(cron, () => { });
job.stop();
}
@@ -239,6 +239,8 @@ class Maintenance extends BeanModel {
apicache.clear();
});
} else if (this.cron != null) {
let current = dayjs();
// Here should be cron or recurring
try {
this.beanMeta.status = "scheduled";
@@ -258,6 +260,10 @@ class Maintenance extends BeanModel {
this.beanMeta.status = "scheduled";
UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id);
}, duration);
// Set last start date to current time
this.last_start_date = current.toISOString();
R.store(this);
};
// Create Cron
@@ -268,9 +274,25 @@ class Maintenance extends BeanModel {
const startDateTime = startDate.hour(hour).minute(minute);
this.beanMeta.job = new Cron(this.cron, {
timezone: await this.getTimezone(),
interval: this.interval_day * 24 * 60 * 60,
startAt: startDateTime.toISOString(),
}, startEvent);
}, () => {
if (!this.lastStartDate || this.interval_day === 1) {
return startEvent();
}
// If last start date is set, it means the maintenance has been started before
let lastStartDate = dayjs(this.lastStartDate)
.subtract(1.1, "hour"); // Subtract 1.1 hour to avoid issues with timezone differences
// Check if the interval is enough
if (current.diff(lastStartDate, "day") < this.interval_day) {
log.debug("maintenance", "Maintenance id: " + this.id + " is still in the window, skipping start event");
return;
}
log.debug("maintenance", "Maintenance id: " + this.id + " is not in the window, starting event");
return startEvent();
});
} else {
this.beanMeta.job = new Cron(this.cron, {
timezone: await this.getTimezone(),
@@ -279,7 +301,6 @@ class Maintenance extends BeanModel {
// Continue if the maintenance is still in the window
let runningTimeslot = this.getRunningTimeslot();
let current = dayjs();
if (runningTimeslot) {
let duration = dayjs(runningTimeslot.endDate).diff(current, "second") * 1000;
@@ -423,8 +444,11 @@ class Maintenance extends BeanModel {
} else if (!this.strategy.startsWith("recurring-")) {
this.cron = "";
} else if (this.strategy === "recurring-interval") {
// For intervals, the pattern is calculated in the run function as the interval-option is set
this.cron = "* * * * *";
// For intervals, the pattern is used to check if the execution should be started
let array = this.start_time.split(":");
let hour = parseInt(array[0]);
let minute = parseInt(array[1]);
this.cron = `${minute} ${hour} * * *`;
this.duration = this.calcDuration();
log.debug("maintenance", "Cron: " + this.cron);
log.debug("maintenance", "Duration: " + this.duration);

View File

@@ -190,6 +190,7 @@ class Monitor extends BeanModel {
radiusSecret: this.radiusSecret,
mqttUsername: this.mqttUsername,
mqttPassword: this.mqttPassword,
mqttWebsocketPath: this.mqttWebsocketPath,
authWorkstation: this.authWorkstation,
authDomain: this.authDomain,
tlsCa: this.tlsCa,
@@ -1314,7 +1315,7 @@ class Monitor extends BeanModel {
/**
* Send a notification about a monitor
* @param {boolean} isFirstBeat Is this beat the first of this monitor?
* @param {Monitor} monitor The monitor to send a notificaton about
* @param {Monitor} monitor The monitor to send a notification about
* @param {Bean} bean Status information about monitor
* @returns {void}
*/

View File

@@ -15,6 +15,7 @@ class MqttMonitorType extends MonitorType {
username: monitor.mqttUsername,
password: monitor.mqttPassword,
interval: monitor.interval,
websocketPath: monitor.mqttWebsocketPath,
});
if (monitor.mqttCheckType == null || monitor.mqttCheckType === "") {
@@ -52,12 +53,12 @@ class MqttMonitorType extends MonitorType {
* @param {string} hostname Hostname / address of machine to test
* @param {string} topic MQTT topic
* @param {object} options MQTT options. Contains port, username,
* password and interval (interval defaults to 20)
* password, websocketPath and interval (interval defaults to 20)
* @returns {Promise<string>} Received MQTT message
*/
mqttAsync(hostname, topic, options = {}) {
return new Promise((resolve, reject) => {
const { port, username, password, interval = 20 } = options;
const { port, username, password, websocketPath, interval = 20 } = options;
// Adds MQTT protocol to the hostname if not already present
if (!/^(?:http|mqtt|ws)s?:\/\//.test(hostname)) {
@@ -70,7 +71,15 @@ class MqttMonitorType extends MonitorType {
reject(new Error("Timeout, Message not received"));
}, interval * 1000 * 0.8);
const mqttUrl = `${hostname}:${port}`;
// Construct the URL based on protocol
let mqttUrl = `${hostname}:${port}`;
if (hostname.startsWith("ws://") || hostname.startsWith("wss://")) {
if (websocketPath && !websocketPath.startsWith("/")) {
mqttUrl = `${hostname}:${port}/${websocketPath || ""}`;
} else {
mqttUrl = `${hostname}:${port}${websocketPath || ""}`;
}
}
log.debug("mqtt", `MQTT connecting to ${mqttUrl}`);

View File

@@ -14,6 +14,18 @@ class GoogleChat extends NotificationProvider {
try {
// Google Chat message formatting: https://developers.google.com/chat/api/guides/message-formats/basic
if (notification.googleChatUseTemplate && notification.googleChatTemplate) {
// Send message using template
const renderedText = await this.renderTemplate(
notification.googleChatTemplate,
msg,
monitorJSON,
heartbeatJSON
);
const data = { "text": renderedText };
await axios.post(notification.googleChatWebhookURL, data);
return okMsg;
}
let chatHeader = {
title: "Uptime Kuma Alert",
@@ -88,7 +100,6 @@ class GoogleChat extends NotificationProvider {
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}

View File

@@ -15,7 +15,7 @@ class PromoSMS extends NotificationProvider {
notification.promosmsAllowLongSMS = false;
}
//TODO: Add option for enabling special characters. It will decrese message max length from 160 to 70 chars.
//TODO: Add option for enabling special characters. It will decrease message max length from 160 to 70 chars.
//Lets remove non ascii char
let cleanMsg = msg.replace(/[^\x00-\x7F]/g, "");

View File

@@ -2,6 +2,7 @@ const NotificationProvider = require("./notification-provider");
const axios = require("axios");
const { setSettings, setting } = require("../util-server");
const { getMonitorRelativeURL, UP, log } = require("../../src/util");
const isUrl = require("is-url");
class Slack extends NotificationProvider {
name = "slack";
@@ -49,7 +50,7 @@ class Slack extends NotificationProvider {
}
const address = this.extractAddress(monitorJSON);
if (address) {
if (isUrl(address)) {
try {
actions.push({
"type": "button",

View File

@@ -720,6 +720,17 @@ let needSetup = false;
monitor.rabbitmqNodes = JSON.stringify(monitor.rabbitmqNodes);
/*
* List of frontend-only properties that should not be saved to the database.
* Should clean up before saving to the database.
*/
const frontendOnlyProperties = [ "humanReadableInterval" ];
for (const prop of frontendOnlyProperties) {
if (prop in monitor) {
delete monitor[prop];
}
}
bean.import(monitor);
bean.user_id = socket.userID;
@@ -837,6 +848,7 @@ let needSetup = false;
bean.mqttTopic = monitor.mqttTopic;
bean.mqttSuccessMessage = monitor.mqttSuccessMessage;
bean.mqttCheckType = monitor.mqttCheckType;
bean.mqttWebsocketPath = monitor.mqttWebsocketPath;
bean.databaseConnectionString = monitor.databaseConnectionString;
bean.databaseQuery = monitor.databaseQuery;
bean.authMethod = monitor.authMethod;

View File

@@ -208,11 +208,13 @@ class SetupDatabase {
// Test connection
try {
log.info("setup-database", "Testing database connection...");
const connection = await mysql.createConnection({
host: dbConfig.hostname,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.dbName,
});
await connection.execute("SELECT 1");
connection.end();

View File

@@ -27,7 +27,7 @@ module.exports.apiKeySocketHandler = (socket) => {
log.debug("apikeys", "Added API Key");
log.debug("apikeys", key);
// Append key ID and prefix to start of key seperated by _, used to get
// Append key ID and prefix to start of key separated by _, used to get
// correct hash when validating key.
let formattedKey = "uk" + bean.id + "_" + clearKey;
await sendAPIKeyList(socket);

View File

@@ -582,7 +582,7 @@ class UptimeCalculator {
let totalPing = 0;
let endTimestamp;
// Get the eariest timestamp of the required period based on the type
// Get the earliest timestamp of the required period based on the type
switch (type) {
case "day":
endTimestamp = key - 86400 * (num - 1);
@@ -710,7 +710,7 @@ class UptimeCalculator {
let endTimestamp;
// Get the eariest timestamp of the required period based on the type
// Get the earliest timestamp of the required period based on the type
switch (type) {
case "day":
endTimestamp = key - 86400 * (num - 1);

View File

@@ -482,6 +482,7 @@ optgroup {
.info {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover {

View File

@@ -7,11 +7,17 @@
class="beat-hover-area"
:class="{ 'empty': (beat === 0) }"
:style="beatHoverAreaStyle"
:title="getBeatTitle(beat)"
:aria-label="getBeatAriaLabel(beat)"
role="status"
tabindex="0"
@mouseenter="showTooltip(beat, $event)"
@mouseleave="hideTooltip"
@focus="showTooltip(beat, $event)"
@blur="hideTooltip"
>
<div
class="beat"
:class="{ 'empty': (beat === 0), 'down': (beat.status === 0), 'pending': (beat.status === 2), 'maintenance': (beat.status === 3) }"
:class="getBeatClasses(beat)"
:style="beatStyle"
/>
</div>
@@ -24,13 +30,27 @@
<div v-if="$root.styleElapsedTime === 'with-line'" class="connecting-line"></div>
<div>{{ timeSinceLastBeat }}</div>
</div>
<!-- Custom Tooltip -->
<Tooltip
:visible="tooltipVisible"
:content="tooltipContent"
:x="tooltipX"
:y="tooltipY"
:position="tooltipPosition"
/>
</div>
</template>
<script>
import dayjs from "dayjs";
import { DOWN, UP, PENDING, MAINTENANCE } from "../util.ts";
import Tooltip from "./Tooltip.vue";
export default {
components: {
Tooltip,
},
props: {
/** Size of the heartbeat bar */
size: {
@@ -46,6 +66,11 @@ export default {
heartbeatList: {
type: Array,
default: null,
},
/** Heartbeat bar days */
heartbeatBarDays: {
type: Number,
default: 0
}
},
data() {
@@ -56,10 +81,25 @@ export default {
beatHoverAreaPadding: 4,
move: false,
maxBeat: -1,
// Tooltip data
tooltipVisible: false,
tooltipContent: null,
tooltipX: 0,
tooltipY: 0,
tooltipPosition: "below",
tooltipTimeoutId: null,
};
},
computed: {
/**
* Normalized heartbeatBarDays as a number
* @returns {number} Number of days for heartbeat bar
*/
normalizedHeartbeatBarDays() {
return Math.max(0, Math.min(365, Math.floor(this.heartbeatBarDays || 0)));
},
/**
* If heartbeatList is null, get it from $root.heartbeatList
* @returns {object} Heartbeat list
@@ -80,6 +120,12 @@ export default {
if (!this.beatList) {
return 0;
}
// For configured ranges, no padding needed since we show all beats
if (this.normalizedHeartbeatBarDays > 0) {
return 0;
}
let num = this.beatList.length - this.maxBeat;
if (this.move) {
@@ -98,8 +144,20 @@ export default {
return [];
}
// If heartbeat days is configured (not auto), data is already aggregated from server
if (this.normalizedHeartbeatBarDays > 0 && this.beatList.length > 0) {
// Show all beats from server - they are already properly aggregated
return this.beatList;
}
// Original logic for auto mode (heartbeatBarDays = 0)
let placeholders = [];
// Handle case where maxBeat is -1 (no limit)
if (this.maxBeat <= 0) {
return this.beatList;
}
let start = this.beatList.length - this.maxBeat;
if (this.move) {
@@ -172,13 +230,17 @@ export default {
* @returns {string} The time elapsed in minutes or hours.
*/
timeSinceFirstBeat() {
if (this.normalizedHeartbeatBarDays === 1) {
return (this.normalizedHeartbeatBarDays * 24) + "h";
}
if (this.normalizedHeartbeatBarDays >= 2) {
return this.normalizedHeartbeatBarDays + "d";
}
// Need to calculate from actual data
const firstValidBeat = this.shortBeatList.at(this.numPadding);
const minutes = dayjs().diff(dayjs.utc(firstValidBeat?.time), "minutes");
if (minutes > 60) {
return (minutes / 60).toFixed(0) + "h";
} else {
return minutes + "m";
}
return minutes > 60 ? Math.floor(minutes / 60) + "h" : minutes + "m";
},
/**
@@ -197,15 +259,15 @@ export default {
if (seconds < tolerance) {
return this.$t("now");
} else if (seconds < 60 * 60) {
return this.$t("time ago", [ (seconds / 60).toFixed(0) + "m" ]);
return this.$t("time ago", [ (seconds / 60).toFixed(0) + "m" ] );
} else {
return this.$t("time ago", [ (seconds / 60 / 60).toFixed(0) + "h" ]);
return this.$t("time ago", [ (seconds / 60 / 60).toFixed(0) + "h" ] );
}
}
},
watch: {
beatList: {
handler(val, oldVal) {
handler() {
this.move = true;
setTimeout(() => {
@@ -217,6 +279,10 @@ export default {
},
unmounted() {
window.removeEventListener("resize", this.resize);
// Clean up tooltip timeout
if (this.tooltipTimeoutId) {
clearTimeout(this.tooltipTimeoutId);
}
},
beforeMount() {
if (this.heartbeatList === null) {
@@ -256,7 +322,23 @@ export default {
*/
resize() {
if (this.$refs.wrap) {
this.maxBeat = Math.floor(this.$refs.wrap.clientWidth / (this.beatWidth + this.beatHoverAreaPadding * 2));
const newMaxBeat = Math.floor(this.$refs.wrap.clientWidth / (this.beatWidth + this.beatHoverAreaPadding * 2));
// If maxBeat changed and we're in configured days mode, notify parent to reload data
if (newMaxBeat !== this.maxBeat && this.normalizedHeartbeatBarDays > 0) {
this.maxBeat = newMaxBeat;
// Find the closest parent with reloadHeartbeatData method (StatusPage)
let parent = this.$parent;
while (parent && !parent.reloadHeartbeatData) {
parent = parent.$parent;
}
if (parent && parent.reloadHeartbeatData) {
parent.reloadHeartbeatData(newMaxBeat);
}
} else {
this.maxBeat = newMaxBeat;
}
}
},
@@ -267,7 +349,124 @@ export default {
* @returns {string} Beat title
*/
getBeatTitle(beat) {
return `${this.$root.datetime(beat.time)}` + ((beat.msg) ? ` - ${beat.msg}` : "");
if (!beat) {
return "";
}
// Show timestamp for all beats (both individual and aggregated)
return `${this.$root.datetime(beat.time)}${beat.msg ? ` - ${beat.msg}` : ""}`;
},
/**
* Get CSS classes for a beat element based on its status
* @param {object} beat - Beat object containing status information
* @returns {object} Object with CSS class names as keys and boolean values
*/
getBeatClasses(beat) {
if (beat === 0 || beat === null || beat?.status === null) {
return { empty: true };
}
const status = Number(beat.status);
return {
down: status === DOWN,
pending: status === PENDING,
maintenance: status === MAINTENANCE
};
},
/**
* Get the aria-label for accessibility
* @param {object} beat Beat to get aria-label from
* @returns {string} Aria label
*/
getBeatAriaLabel(beat) {
switch (beat?.status) {
case DOWN:
return `Down at ${this.$root.datetime(beat.time)}`;
case UP:
return `Up at ${this.$root.datetime(beat.time)}`;
case PENDING:
return `Pending at ${this.$root.datetime(beat.time)}`;
case MAINTENANCE:
return `Maintenance at ${this.$root.datetime(beat.time)}`;
default:
return "No data";
}
},
/**
* Show custom tooltip
* @param {object} beat Beat data
* @param {Event} event Mouse event
* @returns {void}
*/
showTooltip(beat, event) {
if (beat === 0 || !beat) {
this.hideTooltip();
return;
}
// Clear any existing timeout
if (this.tooltipTimeoutId) {
clearTimeout(this.tooltipTimeoutId);
}
// Small delay for better UX
this.tooltipTimeoutId = setTimeout(() => {
this.tooltipContent = beat;
// Calculate position relative to viewport
const rect = event.target.getBoundingClientRect();
// Position relative to viewport
const x = rect.left + (rect.width / 2);
const y = rect.top;
// Check if tooltip would go off-screen and adjust position
const tooltipHeight = 80; // Approximate tooltip height
const viewportHeight = window.innerHeight;
const spaceAbove = y;
const spaceBelow = viewportHeight - y - rect.height;
if (spaceAbove > tooltipHeight && spaceBelow < tooltipHeight) {
// Show above - arrow points down
this.tooltipPosition = "above";
this.tooltipY = y - 10;
} else {
// Show below - arrow points up
this.tooltipPosition = "below";
this.tooltipY = y + rect.height + 10;
}
// Ensure tooltip doesn't go off the left or right edge
const tooltipWidth = 120; // Approximate tooltip width
let adjustedX = x;
if ((x - tooltipWidth / 2) < 10) {
adjustedX = tooltipWidth / 2 + 10;
} else if ((x + tooltipWidth / 2) > (window.innerWidth - 10)) {
adjustedX = window.innerWidth - tooltipWidth / 2 - 10;
}
this.tooltipX = adjustedX;
this.tooltipVisible = true;
}, 150);
},
/**
* Hide custom tooltip
* @returns {void}
*/
hideTooltip() {
if (this.tooltipTimeoutId) {
clearTimeout(this.tooltipTimeoutId);
this.tooltipTimeoutId = null;
}
this.tooltipVisible = false;
this.tooltipContent = null;
},
},

View File

@@ -182,7 +182,7 @@ export default {
// eslint-disable-next-line eqeqeq
if (newPeriod == "0") {
this.heartbeatList = null;
this.$root.storage().removeItem(`chart-period-${this.monitorId}`);
this.$root.storage()["chart-period"] = newPeriod;
} else {
this.loading = true;
@@ -199,7 +199,7 @@ export default {
this.$root.toastError(res.msg);
} else {
this.chartRawData = res.data;
this.$root.storage()[`chart-period-${this.monitorId}`] = newPeriod;
this.$root.storage()["chart-period"] = newPeriod;
}
this.loading = false;
});
@@ -216,7 +216,7 @@ export default {
},
created() {
// Load chart period from storage if saved
let period = this.$root.storage()[`chart-period-${this.monitorId}`];
let period = this.$root.storage()["chart-period"];
if (period != null) {
// Has this ever been not a string?
if (typeof period !== "string") {
@@ -224,7 +224,7 @@ export default {
}
this.chartPeriodHrs = period;
} else {
this.chartPeriodHrs = "24";
this.chartPeriodHrs = "0";
}
},
beforeUnmount() {

276
src/components/Tooltip.vue Normal file
View File

@@ -0,0 +1,276 @@
<template>
<teleport to="body">
<div
v-if="content"
ref="tooltip"
class="tooltip-wrapper"
:style="tooltipStyle"
:class="{ 'tooltip-above': position === 'above' }"
>
<div class="tooltip-content">
<slot :content="content">
<!-- Default content if no slot provided -->
<div class="tooltip-status" :class="statusClass">
{{ statusText }}
</div>
<div class="tooltip-time">{{ timeText }}</div>
<div v-if="content?.msg" class="tooltip-message">{{ content.msg }}</div>
</slot>
</div>
<div class="tooltip-arrow" :class="{ 'arrow-above': position === 'above' }"></div>
</div>
</teleport>
</template>
<script>
import { DOWN, UP, PENDING, MAINTENANCE } from "../util.ts";
export default {
name: "Tooltip",
props: {
/** Whether tooltip is visible */
visible: {
type: Boolean,
default: false
},
/** Content object to display */
content: {
type: Object,
default: null
},
/** X position (viewport coordinates) */
x: {
type: Number,
default: 0
},
/** Y position (viewport coordinates) */
y: {
type: Number,
default: 0
},
/** Position relative to target element */
position: {
type: String,
default: "below",
validator: (value) => [ "above", "below" ].includes(value)
}
},
computed: {
tooltipStyle() {
return {
left: this.x + "px",
top: this.y + "px",
};
},
statusText() {
if (!this.content || this.content === 0) {
return this.$t("Unknown");
}
switch (this.content.status) {
case DOWN:
return this.$t("Down");
case UP:
return this.$t("Up");
case PENDING:
return this.$t("Pending");
case MAINTENANCE:
return this.$t("Maintenance");
default:
return this.$t("Unknown");
}
},
statusClass() {
if (!this.content || this.content === 0) {
return "status-empty";
}
switch (this.content.status) {
case DOWN:
return "status-down";
case UP:
return "status-up";
case PENDING:
return "status-pending";
case MAINTENANCE:
return "status-maintenance";
default:
return "status-unknown";
}
},
timeText() {
if (!this.content || this.content === 0) {
return "";
}
return this.$root.datetime(this.content.time);
},
}
};
</script>
<style lang="scss" scoped>
@import "../assets/vars.scss";
.tooltip-wrapper {
position: fixed;
z-index: 9999;
pointer-events: none;
transform: translateX(-50%);
.tooltip-content {
background: rgba(17, 24, 39, 0.95);
backdrop-filter: blur(8px);
border: 1px solid rgba(75, 85, 99, 0.3);
border-radius: 8px;
padding: 8px 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.25);
min-width: 120px;
text-align: center;
position: relative;
&::before {
content: "";
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 14px;
height: 2px;
background: rgba(17, 24, 39, 0.95);
top: -1px;
}
.tooltip-status {
font-size: 16px;
font-weight: 600;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
&.status-up {
color: $primary;
}
&.status-down {
color: $danger;
}
&.status-pending {
color: $warning;
}
&.status-maintenance {
color: $maintenance;
}
&.status-empty {
color: $secondary-text;
}
}
.tooltip-time {
color: #d1d5db;
font-size: 13px;
margin-bottom: 2px;
}
.tooltip-message {
color: #f3f4f6;
font-size: 12px;
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid rgba(75, 85, 99, 0.3);
}
}
.tooltip-arrow {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 12px;
height: 6px;
overflow: hidden;
top: -6px;
&::before {
content: "";
position: absolute;
left: 50%;
top: 100%;
transform: translateX(-50%) translateY(-50%) rotate(45deg);
width: 8px;
height: 8px;
background: rgba(17, 24, 39, 0.95);
border: 1px solid rgba(75, 85, 99, 0.3);
border-bottom: none;
border-right: none;
}
&.arrow-above {
top: auto;
bottom: -6px;
&::before {
top: 0%;
transform: translateX(-50%) translateY(-50%) rotate(225deg);
border: 1px solid rgba(75, 85, 99, 0.3);
border-bottom: none;
border-right: none;
}
}
}
// Smooth entrance animation
animation: tooltip-fade-in 0.2s $easing-out;
&.tooltip-above {
transform: translateX(-50%) translateY(-8px);
.tooltip-content::before {
top: auto;
bottom: -1px;
}
}
}
// Dark theme adjustments
.dark .tooltip-wrapper {
.tooltip-content {
background: rgba(31, 41, 55, 0.95);
border-color: rgba(107, 114, 128, 0.3);
&::before {
background: rgba(31, 41, 55, 0.95);
}
}
.tooltip-arrow {
&::before {
background: rgba(31, 41, 55, 0.95);
border-color: rgba(107, 114, 128, 0.3);
}
}
}
@keyframes tooltip-fade-in {
from {
opacity: 0;
transform: translateX(-50%) translateY(4px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
// Accessibility improvements
@media (prefers-reduced-motion: reduce) {
.tooltip-wrapper {
animation: none !important;
}
}
</style>

View File

@@ -10,4 +10,40 @@
</i18n-t>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input id="google-chat-use-template" v-model="$parent.notification.googleChatUseTemplate" type="checkbox" class="form-check-input">
<label for="google-chat-use-template" class="form-check-label"> {{ $t("Template plain text instead of using cards") }} </label>
<i18n-t tag="p" class="form-text" keypath="issueWithGoogleChatOnAndroidHelptext">
<template #issuetackerURL>
<a href="https://issuetracker.google.com/issues/283746283" target="_blank">issuetracker.google.com/issues/283746283</a>
</template>
</i18n-t>
</div>
</div>
<template v-if="$parent.notification.googleChatUseTemplate">
<div class="mb-3">
<TemplatedTextarea id="google-chat-template" v-model="$parent.notification.googleChatTemplate" :required="true" :placeholder="googleChatTemplatePlaceholder" />
</div>
</template>
</template>
<script>
import TemplatedTextarea from "../TemplatedTextarea.vue";
export default {
name: "GoogleChat",
components: {
TemplatedTextarea,
},
computed: {
googleChatTemplatePlaceholder() {
return this.$t("Example:", [
"{{ name }} - {{ msg }}{% if hostnameOrURL %} ({{ hostnameOrURL }}){% endif %}"
]);
}
},
};
</script>

View File

@@ -18,7 +18,7 @@
{{ $t("matrixDesc1") }}
</p>
<i18n-t tag="p" keypath="matrixDesc2" style="margin-top: 8px;">
<code>curl -XPOST -d '{"type": "m.login.password", "identifier": {"user": "botusername", "type": "m.id.user"}, "password": "passwordforuser"}' "https://home.server/_matrix/client/v3/login"</code>.
<code>curl -XPOST --json '{"type": "m.login.password", "identifier": {"user": "botusername", "type": "m.id.user"}, "password": "passwordforuser"}' "https://home.server/_matrix/client/v3/login"</code>.
</i18n-t>
</div>
</template>

View File

@@ -1175,5 +1175,10 @@
"ipFamilyDescriptionAutoSelect": "Използва {happyEyeballs} за определяне на IP семейството.",
"Manual": "Ръковосдство",
"OAuth Audience": "OAuth аудитория",
"Optional: The audience to request the JWT for": "По желание: Аудиторията, за която да се поиска JWT"
"Optional: The audience to request the JWT for": "По желание: Аудиторията, за която да се поиска JWT",
"mqttWebSocketPath": "MQTT Уеб сокет път",
"mqttWebsocketPathInvalid": "Моля, използвайте валиден формат на пътя за Уеб сокет",
"Path": "Път",
"mqttWebsocketPathExplanation": "Уеб сокет път за MQTT през Уеб сокет връзки (напр. /mqtt)",
"mqttHostnameTip": "Моля, използвайте този формат {hostnameFormat}"
}

View File

@@ -400,8 +400,8 @@
"smseagleRecipient": "Příjemce(i) (více záznamů oddělte čárkou)",
"smseagleToken": "API přístupový token",
"smseagleUrl": "URL vašeho SMSEagle zařízení",
"smseagleEncoding": "Odeslat v Unicode",
"smseaglePriority": "Priorita zprávy (0-9, výchozí = 0)",
"smseagleEncoding": "Odeslat v Unicode (standardně=GSM-7)",
"smseaglePriority": "Priorita zprávy (0-9, nejvyšší priorita = 9)",
"stackfield": "Stackfield",
"Customize": "Přizpůsobit",
"Custom Footer": "Vlastní patička",
@@ -828,10 +828,10 @@
"showCertificateExpiry": "Zobrazit vypršení platnosti certifikátu",
"pushDeerServerDescription": "Chcete-li používat oficiální server, ponechte prázdné",
"noOrBadCertificate": "Žádný/Vadný certifikát",
"nostrRelays": "Relé Nostr",
"nostrRelays": "Nostr relay",
"FlashDuty Severity": "Závažnost",
"PushDeer Server": "Server PushDeer",
"wayToGetFlashDutyKey": "Můžete přejít na stránku \"Kanál -> (Vyberte kanál) -> Integrace -> Přidat novou integraci\", přidat \"Uptime Kuma\" a získat push adresu, zkopírovat integrační klíč v adrese. Další informace naleznete na adrese",
"wayToGetFlashDutyKey": "Pro integrování Uptime Kuma s Flashduty: přejděte do sekce Kanály > Vyberte kanál > Integrace > Přidat novou integraci\", vyberte možnost Uptime Kuma a zkopírujte Push adresu.",
"Request Timeout": "Časový limit požadavku",
"timeoutAfter": "Vypršení časového limitu po {0} sekundách",
"styleElapsedTime": "Čas uplynulý pod heartbeat ukazatelem",
@@ -1091,5 +1091,87 @@
"rabbitmqHelpText": "Abyste mohli používat tento monitor, musíte v nastavení RabbitMQ povolit modul pro správu. Další informace naleznete na adrese {rabitmq_documentation}.",
"SendGrid API Key": "SendGrid API klíč",
"Separate multiple email addresses with commas": "Více e-mailových adres oddělte čárkami",
"Clear": "Odstranění"
"Clear": "Odstranění",
"Ip Family": "Rodina IP protokolů",
"ipFamilyDescriptionAutoSelect": "Pro určení rodiny IP protokolů se využívá {happyEyeballs}.",
"Clear Form": "Vymazat formulář",
"Happy Eyeballs algorithm": "Happy Eyeballs algoritmus",
"pause": "Pauza",
"Manual": "Ručně",
"Plain Text": "Prostý text",
"pingGlobalTimeoutLabel": "Globální časový limit",
"pingGlobalTimeoutDescription": "Celková doba v sekundách před zastavením pingu, bez ohledu na počet odeslaných paketů",
"pingPerRequestTimeoutLabel": "Časový limit pingu",
"pingIntervalAdjustedInfo": "Interval upravený na základě počtu paketů, globálního časového limitu a časového limitu pro ping",
"Custom URL": "Vlastní URL",
"customUrlDescription": "Použije se jako hypertextový odkaz místo adresy monitoru.",
"OneChatAccessToken": "OneChat Přístupový token",
"OneChatUserIdOrGroupId": "OneChat ID uživatele nebo ID skupiny",
"OneChatBotId": "OneChat ID bota",
"wahaSession": "Relace",
"wahaChatId": "ID chatu (telefonní číslo / ID kontaktu / ID skupiny)",
"wayToGetWahaApiUrl": "URL vaší WAHA instance.",
"wayToGetWahaApiKey": "API klíč je hodnota proměnné prostředí WHATSAPP_API_KEY, kterou jste použili při spuštění WAHA.",
"Message Template": "Šablony zprávy",
"Template Format": "Formát zprávy",
"smsplanetApiDocs": "Detailní informace týkající se získání API tokenů naleznete v {the_smsplanet_documentation}.",
"the smsplanet documentation": "dokumentaci smsplanet",
"Phone numbers": "Telefonní čísla",
"Sender name": "Jméno odesílatele",
"smsplanetNeedToApproveName": "Je vyžadováno schválení v klientském portále",
"Disable URL in Notification": "Zakázat URL v oznámeních",
"Add Another Tag": "Přidat další štítek",
"Staged Tags for Batch Add": "Připravené štítky pro hromadné přidání",
"ntfyPriorityDown": "Priorita pro DOWN-události",
"pingNumericLabel": "Číselný výstup",
"Font Twemoji by Twitter licensed under": "Písmo Twemoji od Twitteru je pod licencí",
"smsplanetApiToken": "Token pro SMSPlanet API",
"tagAlreadyStaged": "Tento štítek (název a hodnota) je již pro tuto dávku připraven.",
"telegramUseTemplateDescription": "Po aktivování této možnosti bude při odeslání zprávy použita vlastní šablona.",
"telegramServerUrlDescription": "Pro zrušení omezení api botů Telegramu nebo získání přístupu v blokovaných oblastech (Čína, Írán, atp.). Pro více informací klikněte na {0}. Výchozí nastavení: {1}",
"Use HTML for custom E-mail body": "Použít HTML v těle vlastního e-mailu",
"ntfyPriorityHelptextPriorityHigherThanDown": "Pravidelná priorita by měla být vyšší než priorita {0}. Priorita {1} je vyšší než {0} s prioritou {2}",
"OAuth Audience": "OAuth Audience",
"Optional: The audience to request the JWT for": "Volitelné: Audience, pro které se má vyžádat JWT",
"pingCountDescription": "Počet paketů, které se před zastavením odešlou",
"pingNumericDescription": "Pokud je tato možnost aktivní, místo symbolických názvů hostitelů se zobrazí IP adresy",
"pingPerRequestTimeoutDescription": "Jedná se o maximální dobu čekání (v sekundách) před tím, než bude konkrétní ping paket považován za ztracený",
"smtpHelpText": "Pomocí možnosti 'SMTPS' se ověří funkčnost protokolu SMTP/TLS; výběrem Ignorovat TLS se naváže něšifrované spojení; po vybrání 'STARTTLS se odešle příkaz STARTTLS a dojde k ověření certifikátu serveru. Ani jeden z těchto režimů neinicializuje odeslání e-mailu.",
"wayToGetWahaSession": "Z této relace WAHA odesílá oznámení do ID chatu. Najdete ho v nástěnce WAHA.",
"wayToWriteWahaChatId": "Telefonní číslo s mezinárodní předvolbou, ale bez znaménka plus na začátku ({0}), ID kontaktu ({1}) nebo ID skupiny ({2}). Oznámení jsou z relace WAHA odesílána na toto ID chatu.",
"telegramServerUrl": "(Volitelné) Adresa serveru",
"YZJ Robot Token": "YZJ Robot token",
"YZJ Webhook URL": "YZJ URL webhooku",
"Add Tags": "Přidat štítky",
"tagAlreadyOnMonitor": "Dohled již má přiřazen tento štítek (název a hodnota) nebo se čeká na jeho přidání.",
"tagNameExists": "Systémový štítek s tímto názvem již existuje. Vyberte jej ze seznamu nebo použijte jiný název.",
"telegramUseTemplate": "Použít vlastní šablonu zprávy",
"telegramTemplateFormatDescription": "Telegram umožňuje ve zprávách používat různé značkovací jazyky, více informací naleznete v Telegram {0}.",
"smseagleDocs": "Zkontrolujte dokumentaci nebo dostupnost APIv2: {0}",
"smseagleComma": "Více záznamů oddělte čárkou",
"SpugPush Template Code": "Kód šablony",
"FlashDuty Push URL": "Push URL",
"FlashDuty Push URL Placeholder": "Zkopírovat ze stránky integrace upozornění",
"pingCountLabel": "Max. počet paketů",
"templateServiceName": "název služby",
"templateHostnameOrURL": "název hostitele nebo URL",
"templateStatus": "stav",
"defaultFriendlyName": "Nový dohled",
"smseagleContactV2": "ID kontaktu z telefonního seznamu",
"smseagleGroupV2": "ID skupin(y) telefonního seznamu",
"smseagleMsgType": "Typ zprávy",
"smseagleMsgSms": "SMS zprávy (výchozí)",
"smseagleMsgRing": "Vyzvánění",
"smseagleMsgTts": "Hovor s převodem textu na řeč",
"smseagleMsgTtsAdvanced": "Rozšířený hovor s převodem textu na řeč",
"smseagleDuration": "Doba (v sekundách)",
"smseagleTtsModel": "ID modelu převodu textu na řeč",
"smseagleApiType": "Verze API",
"smseagleApiv1": "APIv1 (pro stávající projekty a zpětnou kompatibilitu)",
"smseagleApiv2": "APIv2 (doporučeno pro nové integrace)",
"Path": "Cesta",
"mqttWebsocketPathInvalid": "Použijte, prosím platný formát WebSocket cesty",
"mqttHostnameTip": "Použijte, prosím tento formát {hostnameFormat}",
"mqttWebSocketPath": "MQTT WebSocket cesta",
"mqttWebsocketPathExplanation": "WebSocket cesta pro MQTT prostřednictvím WebSocket spojení (např. /mqtt)"
}

View File

@@ -1172,5 +1172,10 @@
"pause": "Pause",
"Staged Tags for Batch Add": "Bereitgestellte Tags für Batch-Hinzufügen",
"OAuth Audience": "OAuth Zielgruppe",
"Optional: The audience to request the JWT for": "Optional: Die Zielgruppe, für die das JWT angefordert werden soll"
"Optional: The audience to request the JWT for": "Optional: Die Zielgruppe, für die das JWT angefordert werden soll",
"Path": "Pfad",
"mqttWebSocketPath": "MQTT WebSocket Pfad",
"mqttWebsocketPathExplanation": "WebSocket-Pfad für MQTT über WebSocket-Verbindungen (z. B. /mqtt)",
"mqttWebsocketPathInvalid": "Verwende ein gültiges WebSocket-Pfadformat",
"mqttHostnameTip": "Verwende dieses Format {hostnameFormat}"
}

View File

@@ -1175,5 +1175,10 @@
"Staged Tags for Batch Add": "Bereitgestellte Tags für Batch-Hinzufügen",
"pause": "Pause",
"OAuth Audience": "OAuth Zielgruppe",
"Optional: The audience to request the JWT for": "Optional: Die Zielgruppe, für die das JWT angefordert werden soll"
"Optional: The audience to request the JWT for": "Optional: Die Zielgruppe, für die das JWT angefordert werden soll",
"mqttWebsocketPathInvalid": "Verwende ein gültiges WebSocket-Pfadformat",
"Path": "Pfad",
"mqttWebSocketPath": "MQTT WebSocket Pfad",
"mqttWebsocketPathExplanation": "WebSocket-Pfad für MQTT über WebSocket-Verbindungen (z. B. /mqtt)",
"mqttHostnameTip": "Verwende dieses Format {hostnameFormat}"
}

View File

@@ -71,6 +71,7 @@
"locally configured mail transfer agent": "locally configured mail transfer agent",
"Either enter the hostname of the server you want to connect to or localhost if you intend to use a locally configured mail transfer agent": "Either enter the hostname of the server you want to connect to or {localhost} if you intend to use a {local_mta}",
"Port": "Port",
"Path": "Path",
"Heartbeat Interval": "Heartbeat Interval",
"Request Timeout": "Request Timeout",
"timeoutAfter": "Timeout after {0} seconds",
@@ -266,6 +267,10 @@
"Current User": "Current User",
"topic": "Topic",
"topicExplanation": "MQTT topic to monitor",
"mqttWebSocketPath": "MQTT WebSocket Path",
"mqttWebsocketPathExplanation": "WebSocket path for MQTT over WebSocket connections (e.g., /mqtt)",
"mqttWebsocketPathInvalid": "Please use a valid WebSocket Path format",
"mqttHostnameTip": "Please use this format {hostnameFormat}",
"successKeyword": "Success Keyword",
"successKeywordExplanation": "MQTT Keyword that will be considered as success",
"recent": "Recent",
@@ -587,6 +592,11 @@
"rrtypeDescription": "Select the RR type you want to monitor",
"pauseMonitorMsg": "Are you sure want to pause?",
"enableDefaultNotificationDescription": "This notification will be enabled by default for new monitors. You can still disable the notification separately for each monitor.",
"Clear All Events": "Clear All Events",
"clearAllEventsMsg": "Are you sure want to delete all events?",
"Events cleared successfully": "Events cleared successfully.",
"No monitors found": "No monitors found.",
"Could not clear events": "Could not clear {failed}/{total} events",
"clearEventsMsg": "Are you sure want to delete all events for this monitor?",
"clearHeartbeatsMsg": "Are you sure want to delete all heartbeats for this monitor?",
"confirmClearStatisticsMsg": "Are you sure you want to delete ALL statistics?",
@@ -640,6 +650,8 @@
"pushyToken": "Device token",
"apprise": "Apprise (Support 50+ Notification services)",
"GoogleChat": "Google Chat (Google Workspace only)",
"Template plain text instead of using cards": "Template plain text instead of using cards",
"issueWithGoogleChatOnAndroidHelptext": "This also allows to get around bugs upstream like {issuetackerURL}",
"wayToGetKookBotToken": "Create application and get your bot token at {0}",
"wayToGetKookGuildID": "Switch on 'Developer Mode' in Kook setting, and right click the guild to get its ID",
"Guild ID": "Guild ID",

View File

@@ -1175,5 +1175,10 @@
"Ip Family": "Famille d'adresses IP",
"ipFamilyDescriptionAutoSelect": "Utilise le {happyEyeballs} pour déterminer la famille d'adresses IP.",
"OAuth Audience": "Audience OAuth",
"Optional: The audience to request the JWT for": "Optionnel : Le public pour lequel demander le JWT"
"Optional: The audience to request the JWT for": "Optionnel : Le public pour lequel demander le JWT",
"Path": "Chemin",
"mqttWebSocketPath": "Chemin WebSocket MQTT",
"mqttWebsocketPathExplanation": "Chemin WebSocket pour MQTT sur les connexions WebSocket (par exemple, /mqtt)",
"mqttWebsocketPathInvalid": "Veuillez utiliser un format de chemin WebSocket valide",
"mqttHostnameTip": "Veuillez utiliser ce format {hostnameFormat}"
}

View File

@@ -728,7 +728,7 @@
"smseagleRecipient": "Faighteoir(í) (ní mór an iliomad a bheith scartha le camóg)",
"smseagleToken": "Comhartha rochtana API",
"smseagleUrl": "URL do ghléis SMSEagle",
"smseaglePriority": "Tosaíocht na teachtaireachta (0-9, réamhshocraithe = 0)",
"smseaglePriority": "Tosaíocht teachtaireachta (0-9, an tosaíocht is airde = 9)",
"Recipient Number": "Uimhir Faighteoir",
"From Name/Number": "Ó Ainm/Uimhir",
"Octopush API Version": "Leagan API Octopush",
@@ -859,7 +859,7 @@
"Browser Screenshot": "Scáileán Brabhsálaí",
"What is a Remote Browser?": "Cad is Brabhsálaí Cianda ann?",
"serwersmsSenderName": "Ainm Seoltóra SMS (cláraithe trí thairseach custaiméirí)",
"smseagleEncoding": "Seol mar Unicode",
"smseagleEncoding": "Seol mar Unicode (réamhshocrú=GSM-7)",
"Leave blank to use a shared sender number.": "Fág bán chun uimhir seoltóra roinnte a úsáid.",
"onebotGroupMessage": "Grúpa",
"onebotUserOrGroupId": "Aitheantas Grúpa/Úsáideora",
@@ -879,7 +879,7 @@
"monitorToastMessagesDescription": "Imíonn fógraí tósta le haghaidh monatóirí tar éis am tugtha i soicindí. Díchumasaítear am istigh le socrú go -1. Díchumasaigh Socrú go 0 fógraí tósta.",
"Enable Kafka Producer Auto Topic Creation": "Cumasaigh Cruthú Uath-Ábhair Táirgeora Kafka",
"noGroupMonitorMsg": "Níl sé ar fáil. Cruthaigh Monatóir Grúpa ar dtús.",
"wayToGetFlashDutyKey": "Is féidir leat dul go Cainéal -> (Roghnaigh Cainéal) -> Comhtháthaithe -> Cuir leathanach comhtháthú nua leis, cuir 'Uptime Kuma' leis chun seoladh brúigh a fháil, cóipeáil an Eochair Chomhtháthaithe sa seoladh. Le haghaidh tuilleadh eolais, tabhair cuairt le do thoil",
"wayToGetFlashDutyKey": "Chun Uptime Kuma a chomhtháthú le Flashduty: Téigh go Cainéil > Roghnaigh cainéal > Comhtháthúcháin > Cuir comhtháthú nua leis, roghnaigh Uptime Kuma, agus cóipeáil an URL Push.",
"gamedigGuessPortDescription": "Féadfaidh an calafort a úsáideann Prótacal Iarratas Freastalaí Comhla a bheith difriúil ó phort an chliaint. Bain triail as seo mura bhfuil an monatóir in ann ceangal le do fhreastalaí.",
"successBackupRestored": "Tá an cúltaca athchóirithe go rathúil.",
"Host URL": "URL Óstach",
@@ -1053,5 +1053,82 @@
"Clear": "Glan",
"Elevator": "Ardaitheoir",
"Guitar": "Giotár",
"Scifi": "Ficsean eolaíochta"
"Scifi": "Ficsean eolaíochta",
"ipFamilyDescriptionAutoSelect": "Úsáideann sé {happyEyeballs} chun an teaghlach IP a chinneadh.",
"Happy Eyeballs algorithm": "Algartam Súile Sona",
"Ip Family": "Teaghlach IP",
"Manual": "Lámhleabhar",
"OAuth Audience": "Lucht Féachana OAuth",
"pingGlobalTimeoutLabel": "Am Teorann Domhanda",
"pingPerRequestTimeoutLabel": "Am Teorann In aghaidh an Phing",
"pingPerRequestTimeoutDescription": "Seo an t-am feithimh uasta (i soicindí) sula measfar go bhfuil paicéad ping amháin caillte",
"pingIntervalAdjustedInfo": "Eatramh coigeartaithe bunaithe ar chomhaireamh paicéad, am scoir domhanda agus am scoir in aghaidh an phing",
"Custom URL": "URL Saincheaptha",
"customUrlDescription": "Úsáidfear é mar an URL inchliceáilte in ionad URL an mhonatóra.",
"OneChatBotId": "Aitheantas Bot OneChat",
"Plain Text": "Téacs Gnáth",
"Disable URL in Notification": "Díchumasaigh URL san Fhógra",
"Add Another Tag": "Cuir Clib Eile leis",
"Staged Tags for Batch Add": "Clibeanna Céimnithe le haghaidh Cuir Baisc leis",
"Clear Form": "Foirm Glan",
"pause": "Sos",
"pingCountDescription": "Líon na bpacáistí le seoladh sula stopann tú",
"smsplanetApiDocs": "Is féidir faisnéis mhionsonraithe maidir le comharthaí API a fháil a fháil i {the_smsplanet_documentation}.",
"the smsplanet documentation": "an doiciméadú smsplanet",
"Phone numbers": "Uimhreacha gutháin",
"Sender name": "Ainm an tseoltóra",
"smsplanetApiToken": "Comhartha don SMSPlanet API",
"defaultFriendlyName": "Monatóir Nua",
"telegramUseTemplate": "Úsáid teimpléad teachtaireachta saincheaptha",
"telegramUseTemplateDescription": "Má tá sé cumasaithe, seolfar an teachtaireacht ag baint úsáide as teimpléad saincheaptha.",
"telegramServerUrlDescription": "Chun teorainneacha bot api Telegram a ardú nó rochtain a fháil i gceantair atá blocáilte (an tSín, an Iaráin, srl.). Le haghaidh tuilleadh eolais cliceáil {0}. Réamhshocrú: {1}",
"Use HTML for custom E-mail body": "Úsáid HTML le haghaidh corp saincheaptha ríomhphoist",
"smseagleGroupV2": "Aitheantóirí grúpa leabhar teileafóin",
"smseagleMsgType": "Cineál teachtaireachta",
"smseagleMsgTts": "Glao téacs-go-hurlabhra",
"smseagleApiv1": "APIv1 (do thionscadail atá ann cheana féin agus comhoiriúnacht siar)",
"Optional: The audience to request the JWT for": "Roghnach: An lucht féachana ar a n-iarrfar an JWT",
"pingCountLabel": "Uasmhéid Pacáistí",
"pingNumericDescription": "Más seiceáilte é, aschuirfear seoltaí IP in ionad ainmneacha óstach siombalacha",
"pingGlobalTimeoutDescription": "Am iomlán i soicindí sula stopann an ping, beag beann ar na paicéid a seoladh",
"smtpHelpText": "Déanann 'SMTPS' tástáil an bhfuil SMTP/TLS ag obair; ceanglaíonn 'Neamhaird a dhéanamh de TLS' thar théacs simplí; ceanglaíonn 'STARTTLS', eisíonn sé ordú STARTTLS agus fíoraíonn sé teastas an fhreastalaí. Ní sheolann aon cheann díobh seo ríomhphost.",
"OneChatAccessToken": "Comhartha Rochtana OneChat",
"OneChatUserIdOrGroupId": "Aitheantas Úsáideora OneChat nó Aitheantas Grúpa",
"wayToGetWahaSession": "Is é Eochair API luach athróg comhshaoil WHATSAPP_API_KEY a dúsáid tú chun WAHA a rith.",
"wayToWriteWahaChatId": "An uimhir theileafóin leis an réimír idirnáisiúnta, ach gan an comhartha móide ag an tús ({0}), an ID Teagmhála ({1}) ná an ID Grúpa ({2}). Seoltar fógraí chuig an ID Comhrá seo ó Sheisiún WAHA.",
"Font Twemoji by Twitter licensed under": "Cló Twemoji le Twitter ceadúnaithe faoi",
"smsplanetNeedToApproveName": "Ní mór é a cheadú sa phainéal cliant",
"wahaSession": "Seisiún",
"wahaChatId": "ID Comhrá (Uimhir Theileafóin / ID Teagmhála / ID Grúpa)",
"wayToGetWahaApiUrl": "URL dÁisnéis WAHA.",
"wayToGetWahaApiKey": "Is é Eochair API luach athróg comhshaoil WHATSAPP_API_KEY a d'úsáid tú chun WAHA a rith.",
"ntfyPriorityHelptextPriorityHigherThanDown": "Ba chóir go mbeadh an tosaíocht rialta níos airde ná tosaíocht {0}. Tá tosaíocht {1} níos airde ná tosaíocht {0} {2}",
"ntfyPriorityDown": "Tosaíocht do imeachtaí SÍOS",
"Message Template": "Teimpléad Teachtaireachta",
"Template Format": "Formáid Teimpléid",
"YZJ Robot Token": "Comhartha robot YZJ",
"YZJ Webhook URL": "YZJ Webhook URL",
"Add Tags": "Cuir Clibeanna leis",
"tagAlreadyOnMonitor": "Tá an clib seo (ainm agus luach) ar an monatóir cheana féin nó á cur leis ar feitheamh.",
"tagAlreadyStaged": "Tá an clib seo (ainm agus luach) curtha i láthair cheana féin don bhaisc seo.",
"tagNameExists": "Tá clib chórais leis an ainm seo ann cheana féin. Roghnaigh é ón liosta nó bain úsáid as ainm eile.",
"templateStatus": "stádas",
"telegramTemplateFormatDescription": "Ceadaíonn Telegram teangacha marcála éagsúla a úsáid le haghaidh teachtaireachtaí, féach Telegram {0} le haghaidh sonraí sonracha.",
"telegramServerUrl": "(Roghnach) URL an Fhreastalaí",
"smseagleContactV2": "Aitheantóirí teagmhála an leabhair teileafóin",
"smseagleMsgSms": "Teachtaireacht SMS (réamhshocraithe)",
"smseagleMsgRing": "Glaoigh glaoch",
"smseagleMsgTtsAdvanced": "Glao ardleibhéil téacs-go-hurlabhra",
"smseagleDuration": "Fad (i soicindí)",
"smseagleTtsModel": "Aitheantas samhail téacs-go-hurlabhra",
"smseagleApiType": "Leagan API",
"smseagleApiv2": "APIv2 (molta le haghaidh comhtháthúcháin nua)",
"smseagleDocs": "Seiceáil an doiciméadacht nó infhaighteacht APIv2: {0}",
"smseagleComma": "Ní mór ilchodanna a dheighilt le camóg",
"SpugPush Template Code": "Cód Teimpléid",
"FlashDuty Push URL": "Brúigh URL",
"FlashDuty Push URL Placeholder": "Cóipeáil ón leathanach comhtháthaithe foláirimh",
"pingNumericLabel": "Aschur Uimhriúil",
"templateServiceName": "ainm na seirbhíse",
"templateHostnameOrURL": "ainm óstach nó URL"
}

View File

@@ -105,7 +105,7 @@
"disableauth.message2": "{intendThirdPartyAuth} prima di Uptime Kuma, come ad esempio Cloudflare Access.",
"where you intend to implement third-party authentication": "Questa opzione è per chi possiede un sistema di autenticazione gestito da terze parti",
"Please use this option carefully!": "Utilizzare con attenzione!",
"Logout": "Esci",
"Logout": "Disconnettiti",
"Leave": "Annulla",
"I understand, please disable": "Lo capisco, disabilitare l'autenticazione",
"Confirm": "Conferma",
@@ -153,7 +153,7 @@
"Setup 2FA": "Configura 2FA",
"Enable 2FA": "Abilita 2FA",
"Disable 2FA": "Disabilita 2FA",
"2FA Settings": "Gestisci l'autenticazione a due fattori",
"2FA Settings": "Gestisci 2FA",
"Two Factor Authentication": "Autenticazione a due fattori (2FA)",
"Active": "Attivata",
"Inactive": "Disattivata",
@@ -779,5 +779,6 @@
"Guild ID": "Guild ID",
"Free Mobile API Key": "Chiave API mobile gratuita",
"telegramUseTemplate": "Utilizza un template di messaggio personalizzato",
"telegramTemplateFormatDescription": "Telegram permette l'utilizzo di diversi linguaggi di markup, vedi Telegram {0} per maggiori dettagli."
"telegramTemplateFormatDescription": "Telegram permette l'utilizzo di diversi linguaggi di markup, vedi Telegram {0} per maggiori dettagli.",
"Add Tags": "Aggiungi Etichette"
}

View File

@@ -749,7 +749,7 @@
"monitorToastMessagesDescription": "モニターのトースト通知は、指定された秒数後に消えます。-1に設定するとタイムアウトが無効になり、0に設定するとトースト通知が無効になります。",
"Pick a SASL Mechanism...": "SASLメカニズムを選択してください",
"noGroupMonitorMsg": "利用できません。先にグループモニターを作成してください。",
"wayToGetFlashDutyKey": "チャンネル -> (チャンネルを選択) -> 統合 -> 新しい統合を追加 のページに移動し、「Uptime Kuma」を追加してプッシュアドレスを取得し、アドレス内の統合キーをコピーしてください。詳細はこちら:",
"wayToGetFlashDutyKey": "UptimeKumaとFlashdutyを統合するには Channels > Select a channel > Integrations > Add a new integrationでUptime Kumaを選択し、Push URLをコピーしてください。",
"cacheBusterParamDescription": "キャッシュをスキップするためにランダム生成したパラメータ",
"gamedigGuessPortDescription": "Valve Server Query Protocolで使用されるポートはクライアントポートとは異なる場合があります。モニターがサーバーに接続できない場合は、この設定を試してください。",
"receiverInfoSevenIO": "受信側番号がドイツの番号ではない場合、番号の前に国コードを追加する必要がありますアメリカの国コード1の場合は、017612121212の代わりに117612121212を使用します。",
@@ -868,8 +868,8 @@
"smseagleRecipientType": "受信者タイプ",
"smseagleToken": "APIアクセストークン",
"smseagleUrl": "SMSEagleデバイスURL",
"smseagleEncoding": "Unicodeで送信",
"smseaglePriority": "メッセージ優先度 (0-9, default = 0)",
"smseagleEncoding": "Unicodeで送信 (default=GSM-7)",
"smseaglePriority": "メッセージ優先度 (0-9, 最大優先度 = 9)",
"smspartnerApiurl": "APIキーはダッシュボードから確認できます: {0}",
"smspartnerPhoneNumber": "電話番号",
"smspartnerSenderName": "SMS送信者名",
@@ -1091,5 +1091,56 @@
"Sender name": "送信者名",
"smsplanetNeedToApproveName": "クライアントパネルでの承認が必要",
"smsplanetApiToken": "SMSPlanet APIのトークン",
"smsplanetApiDocs": "APIトークンの取得に関する詳細な情報は、{the_smsplanet_documentation}にあります。"
"smsplanetApiDocs": "APIトークンの取得に関する詳細な情報は、{the_smsplanet_documentation}にあります。",
"Happy Eyeballs algorithm": "Happy Eyeballs アルゴリズム",
"Ip Family": "IPファミリー",
"ipFamilyDescriptionAutoSelect": "IPファミリーの決定に {happyEyeballs} を使用する。",
"Manual": "マニュアル",
"pingNumericLabel": "数値出力",
"pingGlobalTimeoutLabel": "グローバルタイムアウト",
"pingGlobalTimeoutDescription": "送信されたパケットに関係なく、pingが停止するまでの合計時間",
"pingPerRequestTimeoutLabel": "Pingごとのタイムアウト",
"pingIntervalAdjustedInfo": "パケット数、グローバルタイムアウト、Pingごとのタイムアウトに基づいて間隔を調整",
"OneChatUserIdOrGroupId": "OneChat ユーザーIDまたはグループID",
"OneChatBotId": "OneChat ボットID",
"Add Another Tag": "その他のタグを追加",
"Staged Tags for Batch Add": "一括追加用ステージタグ",
"Clear Form": "フォームをクリア",
"pause": "一時停止",
"tagNameExists": "この名前のシステムタグは既に存在します。リストから選択するか、別の名前を使用してください。",
"smseagleDocs": "ドキュメントまたはAPIv2の可用性をチェックする: {0}",
"OAuth Audience": "OAuth オーディエンス",
"Optional: The audience to request the JWT for": "オプション: JWTを要求するオーディエンス",
"pingCountDescription": "停止前に送信するパケット数",
"pingNumericDescription": "チェックした場合、シンボリックホスト名の代わりにIPアドレスが出力されます",
"pingPerRequestTimeoutDescription": "これは、1つのpingパケットが失われたとみなすまでの最大待機時間です",
"Disable URL in Notification": "通知のURLを無効にする",
"defaultFriendlyName": "新しいモニター",
"smseagleGroupV2": "電話帳グループID",
"smseagleContactV2": "電話帳連絡ID",
"ntfyPriorityHelptextPriorityHigherThanDown": "通常の優先度は {0} 優先度より高い必要があります。優先度 {1} は優先度 {0} 優先度 {2} よりも高い",
"ntfyPriorityDown": "ダウンイベントの優先順位",
"pingCountLabel": "最大パケット",
"Add Tags": "タグを追加",
"tagAlreadyOnMonitor": "このタグ(名前と値)はモニター上に既にあるか、追加待ちです。",
"tagAlreadyStaged": "このタグ(名前と値)は、このバッチに対して既にステージングされています。",
"Use HTML for custom E-mail body": "カスタムメール本文にHTMLを使用する",
"smseagleMsgType": "メッセージタイプ",
"smseagleMsgSms": "SMS メッセージ (デフォルト)",
"smseagleMsgRing": "呼び出し音",
"smseagleMsgTts": "音声合成通話",
"smseagleMsgTtsAdvanced": "音声合成高度通話",
"smseagleDuration": "継続時間(秒)",
"smseagleTtsModel": "音声合成モデルID",
"smseagleApiType": "API バージョン",
"smseagleApiv1": "APIv1 (既存プロジェクトおよび下位互換性用)",
"smseagleApiv2": "APIv2 (新規統合に推奨)",
"smseagleComma": "複数指定する場合はコンマで区切ってください",
"SpugPush Template Code": "テンプレートコード",
"FlashDuty Push URL": "Push URL",
"FlashDuty Push URL Placeholder": "アラート統合ページからコピー",
"smtpHelpText": "SMTPSは『SMTP/TLSが機能しているかテスト』Ignore TLSは『プレーンテキストで接続』STARTTLSは『接続し、STARTTLSコマンドを発行し、サーバー証明書を検証』いずれもメールを送信しない。",
"Custom URL": "カスタムURL",
"customUrlDescription": "モニターのURLの代わりにクリック可能なURLとして使用されます。",
"OneChatAccessToken": "OneChat アクセストークン"
}

View File

@@ -1115,5 +1115,8 @@
"Sender name": "Naam afzender",
"smsplanetNeedToApproveName": "Moet worden goedgekeurd in het clientpaneel",
"smsplanetApiToken": "Token voor de SMSPlanet API",
"smsplanetApiDocs": "Gedetailleerde informatie over het verkrijgen van API-tokens vindt u op {the_smsplanet_documentation}."
"smsplanetApiDocs": "Gedetailleerde informatie over het verkrijgen van API-tokens vindt u op {the_smsplanet_documentation}.",
"defaultFriendlyName": "Nieuwe monitor",
"Add Tags": "Labels toevoegen",
"tagAlreadyOnMonitor": "Dit label (naam en waarde) is al op de monitor gekoppeld of in afwachting van koppelen."
}

View File

@@ -1139,5 +1139,10 @@
"Ip Family": "Família IP",
"ipFamilyDescriptionAutoSelect": "Usa {happyEyeballs} para determinar a família IP.",
"Optional: The audience to request the JWT for": "Opcional: O público deve solicitar o JWT para",
"OAuth Audience": "Público OAuth"
"OAuth Audience": "Público OAuth",
"mqttWebSocketPath": "Caminho MQTT WebSocket",
"mqttWebsocketPathExplanation": "Caminho do WebSocket para conexões MQTT sobre WebSocket (por exemplo, /mqtt)",
"Path": "Caminho",
"mqttWebsocketPathInvalid": "Use um formato de caminho WebSocket válido",
"mqttHostnameTip": "Por favor, use este formato {hostnameFormat}"
}

View File

@@ -192,8 +192,8 @@
"statusPageNothing": "Não tem nada aqui, adicione um grupo os monitor.",
"statusPageRefreshIn": "Atualize em: {0}",
"No Services": "Sem serviços",
"Partially Degraded Service": "Serviço parcialmente degradado",
"Degraded Service": "Serviços degradados",
"Partially Degraded Service": "Algumas funcionalidades estão indisponíveis",
"Degraded Service": "Serviço indisponível",
"Edit Status Page": "Editar página de status",
"Go to Dashboard": "Ir para o painel de controle",
"Status Page": "Status Page",

View File

@@ -699,7 +699,7 @@
"Clone": "Клонировать",
"cloneOf": "Копия {0}",
"notificationRegional": "Региональный",
"Add New Tag": "Добавить тег",
"Add New Tag": "Добавить новый тег",
"Body Encoding": "Тип содержимого запроса.(JSON or XML)",
"Strategy": "Стратегия",
"Free Mobile User Identifier": "Бесплатный мобильный идентификатор пользователя",
@@ -1132,5 +1132,7 @@
"the smsplanet documentation": "документация SMSPlanet",
"Phone numbers": "Номера телефонов",
"Sender name": "Имя отправителя",
"smsplanetNeedToApproveName": "Требуется одобрение в панели клиента"
"smsplanetNeedToApproveName": "Требуется одобрение в панели клиента",
"Add Tags": "Добавить тег",
"tagNameExists": "Тег с таким именем уже существует. Выберите его из списка или используйте другое имя."
}

View File

@@ -41,7 +41,7 @@
"Check Update On GitHub": "GitHub'da Güncellemeyi Kontrol Edin",
"List": "Liste",
"Add": "Ekle",
"Add New Monitor": "Yeni Servis Ekle",
"Add New Monitor": "Yeni Monitör Ekle",
"Quick Stats": "Servis istatistikleri",
"Up": "Normal",
"Down": "Hatalı",
@@ -96,15 +96,15 @@
"Timezone": "Zaman Dilimi",
"Search Engine Visibility": "Arama Motoru Görünürlüğü",
"Allow indexing": "İndekslemeye izin ver",
"Discourage search engines from indexing site": "İndekslemeyi reddet",
"Discourage search engines from indexing site": "Arama motorlarının siteyi indekslemesini engelleyin",
"Change Password": "Şifre Değiştir",
"Current Password": "Şuan ki Şifre",
"Current Password": "Mevcut Şifre",
"New Password": "Yeni Şifre",
"Repeat New Password": "Yeni Şifreyi Tekrar Girin",
"Update Password": "Şifreyi Değiştir",
"Disable Auth": "Şifreli girişi iptal et",
"Enable Auth": "Şifreli girişi aktif et",
"disableauth.message1": "{disableAuth}emin misiniz?",
"disableauth.message1": "{disableAuth} emin misiniz?",
"disable authentication": "Şifreli girişi devre dışı bırakmak istediğinizden",
"disableauth.message2": "Bu, Uptime Kuma'nın önünde Cloudflare Access gibi {intendThirdPartyAuth} kişiler içindir.",
"where you intend to implement third-party authentication": "üçüncü taraf yetkilendirmesi olan",
@@ -293,7 +293,7 @@
"matrixHomeserverURL": "Homeserver URL (http(s):// ve isteğe bağlı olarak bağlantı noktası ile)",
"Internal Room Id": "Internal Room ID",
"matrixDesc1": "Internal Room ID'sini, Matrix istemcinizdeki oda ayarlarının gelişmiş bölümüne bakarak bulabilirsiniz. !QMdRCpUIfLwsfjxye6:home.server gibi görünmelidir.",
"matrixDesc2": "Hesabınıza ve katıldığınız tüm odalara tam erişime izin vereceğinden, yeni bir kullanıcı oluşturmanız ve kendi Matrix kullanıcınızın erişim belirtecini kullanmamanız şiddetle tavsiye edilir. Bunun yerine, yeni bir kullanıcı oluşturun ve onu yalnızca bildirimi almak istediğiniz odaya davet edin. {0} komutunu çalıştırarak erişim tokenini alabilirsiniz.",
"matrixDesc2": "Hesabınıza ve katıldığınız tüm odalara tam erişime izin vereceğinden, yeni bir kullanıcı oluşturmanız ve kendi Matrix kullanıcınızın erişim belirtecini kullanmamanız şiddetle tavsiye edilir. Bunun yerine, yeni bir kullanıcı oluşturun ve onu yalnızca bildirimi almak istediğiniz odaya davet edin. {0} komutunu çalıştırarak erişim tokenini alabilirsiniz",
"Method": "Yöntem",
"Body": "Gövde",
"Headers": "Başlıklar",

View File

@@ -1181,5 +1181,10 @@
"Ip Family": "Сімейство IP",
"ipFamilyDescriptionAutoSelect": "Використовує {happyEyeballs} для визначення сімейства IP.",
"OAuth Audience": "Аудиторія OAuth",
"Optional: The audience to request the JWT for": "Необов'язково: Аудиторія, для якої необхідно подати запит на JWT"
"Optional: The audience to request the JWT for": "Необов'язково: Аудиторія, для якої необхідно подати запит на JWT",
"mqttWebsocketPathExplanation": "Шлях WebSocket для з'єднань MQTT через WebSocket (наприклад, /mqtt)",
"Path": "Шлях",
"mqttWebSocketPath": "Шлях до MQTT WebSocket",
"mqttWebsocketPathInvalid": "Будь ласка, використовуйте дійсний формат шляху WebSocket",
"mqttHostnameTip": "Будь ласка, використовуйте цей формат {hostnameFormat}"
}

View File

@@ -1,5 +1,5 @@
import { currentLocale } from "../i18n";
import { setPageLocale } from "../util-frontend";
import { setPageLocale, relativeTimeFormatter } from "../util-frontend";
const langModules = import.meta.glob("../lang/*.json");
export default {
@@ -28,11 +28,13 @@ export default {
* @returns {Promise<void>}
*/
async changeLang(lang) {
let message = (await langModules["../lang/" + lang + ".json"]()).default;
let message = (await langModules["../lang/" + lang + ".json"]())
.default;
this.$i18n.setLocaleMessage(lang, message);
this.$i18n.locale = lang;
localStorage.locale = lang;
setPageLocale();
}
}
relativeTimeFormatter.updateLocale(lang);
},
},
};

View File

@@ -46,6 +46,15 @@
</div>
<div class="shadow-box table-shadow-box" style="overflow-x: hidden;">
<div class="mb-3 text-end">
<button
class="btn btn-sm btn-outline-danger"
:disabled="clearingAllEvents"
@click="clearAllEventsDialog"
>
{{ $t("Clear All Events") }}
</button>
</div>
<table class="table table-borderless table-hover">
<thead>
<tr>
@@ -82,6 +91,15 @@
</div>
</div>
</transition>
<Confirm
ref="confirmClearEvents"
btn-style="btn-danger"
:yes-text="$t('Yes')"
:no-text="$t('No')"
@yes="clearAllEvents"
>
{{ $t("clearAllEventsMsg") }}
</Confirm>
<router-view ref="child" />
</template>
@@ -89,12 +107,14 @@
import Status from "../components/Status.vue";
import Datetime from "../components/Datetime.vue";
import Pagination from "v-pagination-3";
import Confirm from "../components/Confirm.vue";
export default {
components: {
Datetime,
Status,
Pagination,
Confirm,
},
props: {
calculatedHeight: {
@@ -113,6 +133,7 @@ export default {
},
importantHeartBeatListLength: 0,
displayedRecords: [],
clearingAllEvents: false,
};
},
watch: {
@@ -203,6 +224,43 @@ export default {
}
},
clearAllEventsDialog() {
this.$refs.confirmClearEvents.show();
},
clearAllEvents() {
this.clearingAllEvents = true;
const monitorIDs = Object.keys(this.$root.monitorList);
let failed = 0;
const total = monitorIDs.length;
if (total === 0) {
this.clearingAllEvents = false;
this.$root.toastError(this.$t("No monitors found"));
return;
}
monitorIDs.forEach((monitorID) => {
this.$root.getSocket().emit("clearEvents", monitorID, (res) => {
if (!res || !res.ok) {
failed++;
}
});
});
this.clearingAllEvents = false;
this.page = 1;
this.getImportantHeartbeatListLength();
if (failed === 0) {
this.$root.toastSuccess(this.$t("Events cleared successfully"));
} else {
this.$root.toastError(
this.$t("Could not clear events", {
failed,
total,
})
);
}
},
},
};
</script>
@@ -246,3 +304,4 @@ table {
}
}
</style>

View File

@@ -1,7 +1,9 @@
<template>
<transition name="slide-fade" appear>
<div v-if="monitor">
<router-link v-if="group !== ''" :to="monitorURL(monitor.parent)"> {{ group }}</router-link>
<router-link v-if="group !== ''" :to="monitorURL(monitor.parent)">
{{ group }}
</router-link>
<h1>
{{ monitor.name }}
<div class="monitor-id">
@@ -13,61 +15,124 @@
<p v-if="monitor.description" v-html="descriptionHTML"></p>
<div class="d-flex">
<div class="tags">
<Tag v-for="tag in monitor.tags" :key="tag.id" :item="tag" :size="'sm'" />
<Tag
v-for="tag in monitor.tags"
:key="tag.id"
:item="tag"
:size="'sm'"
/>
</div>
</div>
<p class="url">
<a v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || monitor.type === 'mp-health' || monitor.type === 'real-browser' " :href="monitor.url" target="_blank" rel="noopener noreferrer">{{ filterPassword(monitor.url) }}</a>
<a
v-if="
monitor.type === 'http' ||
monitor.type === 'keyword' ||
monitor.type === 'json-query' ||
monitor.type === 'mp-health' ||
monitor.type === 'real-browser'
"
:href="monitor.url"
target="_blank"
rel="noopener noreferrer"
>{{ filterPassword(monitor.url) }}</a>
<span v-if="monitor.type === 'port'">TCP Port {{ monitor.hostname }}:{{ monitor.port }}</span>
<span v-if="monitor.type === 'ping'">Ping: {{ monitor.hostname }}</span>
<span v-if="monitor.type === 'keyword'">
<br>
<br />
<span>{{ $t("Keyword") }}: </span>
<span class="keyword">{{ monitor.keyword }}</span>
<span v-if="monitor.invertKeyword" alt="Inverted keyword" class="keyword-inverted"> </span>
<span
v-if="monitor.invertKeyword"
alt="Inverted keyword"
class="keyword-inverted"
>
↧</span>
</span>
<span v-if="monitor.type === 'json-query'">
<br>
<span>{{ $t("Json Query") }}:</span> <span class="keyword">{{ monitor.jsonPath }}</span>
<br>
<span>{{ $t("Expected Value") }}:</span> <span class="keyword">{{ monitor.expectedValue }}</span>
<br />
<span>{{ $t("Json Query") }}:</span>
<span class="keyword">{{ monitor.jsonPath }}</span>
<br />
<span>{{ $t("Expected Value") }}:</span>
<span class="keyword">{{ monitor.expectedValue }}</span>
</span>
<span v-if="monitor.type === 'dns'">[{{ monitor.dns_resolve_type }}] {{ monitor.hostname }}
<br>
<span>{{ $t("Last Result") }}:</span> <span class="keyword">{{ monitor.dns_last_result }}</span>
<br />
<span>{{ $t("Last Result") }}:</span>
<span class="keyword">{{ monitor.dns_last_result }}</span>
</span>
<span v-if="monitor.type === 'docker'">Docker container: {{ monitor.docker_container }}</span>
<span v-if="monitor.type === 'gamedig'">Gamedig - {{ monitor.game }}: {{ monitor.hostname }}:{{ monitor.port }}</span>
<span v-if="monitor.type === 'gamedig'">Gamedig - {{ monitor.game }}: {{ monitor.hostname }}:{{
monitor.port
}}</span>
<span v-if="monitor.type === 'grpc-keyword'">gRPC - {{ filterPassword(monitor.grpcUrl) }}
<br>
<span>{{ $t("Keyword") }}:</span> <span class="keyword">{{ monitor.keyword }}</span>
<br />
<span>{{ $t("Keyword") }}:</span>
<span class="keyword">{{ monitor.keyword }}</span>
</span>
<span v-if="monitor.type === 'mongodb'">{{ filterPassword(monitor.databaseConnectionString) }}</span>
<span v-if="monitor.type === 'mqtt'">MQTT: {{ monitor.hostname }}:{{ monitor.port }}/{{ monitor.mqttTopic }}</span>
<span v-if="monitor.type === 'mysql'">{{ filterPassword(monitor.databaseConnectionString) }}</span>
<span v-if="monitor.type === 'postgres'">{{ filterPassword(monitor.databaseConnectionString) }}</span>
<span v-if="monitor.type === 'push'">Push: <a :href="pushURL" target="_blank" rel="noopener noreferrer">{{ pushURL }}</a></span>
<span v-if="monitor.type === 'mongodb'">{{
filterPassword(monitor.databaseConnectionString)
}}</span>
<span v-if="monitor.type === 'mqtt'">MQTT: {{ monitor.hostname }}:{{ monitor.port }}/{{
monitor.mqttTopic
}}</span>
<span v-if="monitor.type === 'mysql'">{{
filterPassword(monitor.databaseConnectionString)
}}</span>
<span v-if="monitor.type === 'postgres'">{{
filterPassword(monitor.databaseConnectionString)
}}</span>
<span v-if="monitor.type === 'push'">Push:
<a
:href="pushURL"
target="_blank"
rel="noopener noreferrer"
>{{ pushURL }}</a></span>
<span v-if="monitor.type === 'radius'">Radius: {{ filterPassword(monitor.hostname) }}</span>
<span v-if="monitor.type === 'redis'">{{ filterPassword(monitor.databaseConnectionString) }}</span>
<span v-if="monitor.type === 'sqlserver'">SQL Server: {{ filterPassword(monitor.databaseConnectionString) }}</span>
<span v-if="monitor.type === 'steam'">Steam Game Server: {{ monitor.hostname }}:{{ monitor.port }}</span>
<span v-if="monitor.type === 'redis'">{{
filterPassword(monitor.databaseConnectionString)
}}</span>
<span v-if="monitor.type === 'sqlserver'">SQL Server:
{{ filterPassword(monitor.databaseConnectionString) }}</span>
<span v-if="monitor.type === 'steam'">Steam Game Server: {{ monitor.hostname }}:{{
monitor.port
}}</span>
</p>
<div class="functions">
<div class="btn-group" role="group">
<button v-if="monitor.active" class="btn btn-normal" @click="pauseDialog">
<button
v-if="monitor.active"
class="btn btn-normal"
@click="pauseDialog"
>
<font-awesome-icon icon="pause" /> {{ $t("Pause") }}
</button>
<button v-if="! monitor.active" class="btn btn-primary" :disabled="monitor.forceInactive" @click="resumeMonitor">
<button
v-if="!monitor.active"
class="btn btn-primary"
:disabled="monitor.forceInactive"
@click="resumeMonitor"
>
<font-awesome-icon icon="play" /> {{ $t("Resume") }}
</button>
<router-link :to=" '/edit/' + monitor.id " class="btn btn-normal">
<router-link
:to="'/edit/' + monitor.id"
class="btn btn-normal"
>
<font-awesome-icon icon="edit" /> {{ $t("Edit") }}
</router-link>
<router-link :to=" '/clone/' + monitor.id " class="btn btn-normal">
<router-link
:to="'/clone/' + monitor.id"
class="btn btn-normal"
>
<font-awesome-icon icon="clone" /> {{ $t("Clone") }}
</router-link>
<button class="btn btn-normal text-danger" @click="deleteDialog">
<button
class="btn btn-normal text-danger"
@click="deleteDialog"
>
<font-awesome-icon icon="trash" /> {{ $t("Delete") }}
</button>
</div>
@@ -77,29 +142,53 @@
<div class="row">
<div class="col-md-8">
<HeartbeatBar :monitor-id="monitor.id" />
<span class="word">{{ $t("checkEverySecond", [ monitor.interval ]) }}</span>
<span class="word">{{
$t("checkEverySecond", [monitor.interval])
}}
({{
secondsToHumanReadableFormat(monitor.interval)
}})</span>
</div>
<div class="col-md-4 text-center">
<span class="badge rounded-pill" :class=" 'bg-' + status.color " style="font-size: 30px;" data-testid="monitor-status">{{ status.text }}</span>
<span
class="badge rounded-pill"
:class="'bg-' + status.color"
style="font-size: 30px;"
data-testid="monitor-status"
>{{ status.text }}</span>
</div>
</div>
</div>
<!-- Push Examples -->
<div v-if="monitor.type === 'push'" class="shadow-box big-padding">
<a href="#" @click="pushMonitor.showPushExamples = !pushMonitor.showPushExamples">{{ $t("pushViewCode") }}</a>
<a
href="#"
@click="
pushMonitor.showPushExamples =
!pushMonitor.showPushExamples
"
>{{ $t("pushViewCode") }}</a>
<transition name="slide-fade" appear>
<div v-if="pushMonitor.showPushExamples" class="mt-3">
<select id="push-current-example" v-model="pushMonitor.currentExample" class="form-select">
<select
id="push-current-example"
v-model="pushMonitor.currentExample"
class="form-select"
>
<optgroup :label="$t('programmingLanguages')">
<option value="csharp">C#</option>
<option value="go">Go</option>
<option value="java">Java</option>
<option value="javascript-fetch">JavaScript (fetch)</option>
<option value="javascript-fetch">
JavaScript (fetch)
</option>
<option value="php">PHP</option>
<option value="python">Python</option>
<option value="typescript-fetch">TypeScript (fetch)</option>
<option value="typescript-fetch">
TypeScript (fetch)
</option>
</optgroup>
<optgroup :label="$t('pushOthers')">
<option value="bash-curl">Bash (curl)</option>
@@ -108,7 +197,13 @@
</optgroup>
</select>
<prism-editor v-model="pushMonitor.code" class="css-editor mt-3" :highlight="pushExampleHighlighter" line-numbers readonly></prism-editor>
<prism-editor
v-model="pushMonitor.code"
class="css-editor mt-3"
:highlight="pushExampleHighlighter"
line-numbers
readonly
></prism-editor>
</div>
</transition>
</div>
@@ -116,55 +211,98 @@
<!-- Stats -->
<div class="shadow-box big-padding text-center stats">
<div class="row">
<div v-if="monitor.type !== 'group'" class="col-12 col-sm col row d-flex align-items-center d-sm-block">
<div
v-if="monitor.type !== 'group'"
class="col-12 col-sm col row d-flex align-items-center d-sm-block"
>
<h4 class="col-4 col-sm-12">{{ pingTitle() }}</h4>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">({{ $t("Current") }})</p>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">
({{ $t("Current") }})
</p>
<span class="col-4 col-sm-12 num">
<a href="#" @click.prevent="showPingChartBox = !showPingChartBox">
<a
href="#"
@click.prevent="
showPingChartBox = !showPingChartBox
"
>
<CountUp :value="ping" />
</a>
</span>
</div>
<div v-if="monitor.type !== 'group'" class="col-12 col-sm col row d-flex align-items-center d-sm-block">
<div
v-if="monitor.type !== 'group'"
class="col-12 col-sm col row d-flex align-items-center d-sm-block"
>
<h4 class="col-4 col-sm-12">{{ pingTitle(true) }}</h4>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">(24{{ $t("-hour") }})</p>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">
(24{{ $t("-hour") }})
</p>
<span class="col-4 col-sm-12 num">
<CountUp :value="avgPing" />
</span>
</div>
<!-- Uptime (24-hour) -->
<div class="col-12 col-sm col row d-flex align-items-center d-sm-block">
<div
class="col-12 col-sm col row d-flex align-items-center d-sm-block"
>
<h4 class="col-4 col-sm-12">{{ $t("Uptime") }}</h4>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">(24{{ $t("-hour") }})</p>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">
(24{{ $t("-hour") }})
</p>
<span class="col-4 col-sm-12 num">
<Uptime :monitor="monitor" type="24" />
</span>
</div>
<!-- Uptime (30-day) -->
<div class="col-12 col-sm col row d-flex align-items-center d-sm-block">
<div
class="col-12 col-sm col row d-flex align-items-center d-sm-block"
>
<h4 class="col-4 col-sm-12">{{ $t("Uptime") }}</h4>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">(30{{ $t("-day") }})</p>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">
(30{{ $t("-day") }})
</p>
<span class="col-4 col-sm-12 num">
<Uptime :monitor="monitor" type="720" />
</span>
</div>
<!-- Uptime (1-year) -->
<div class="col-12 col-sm col row d-flex align-items-center d-sm-block">
<div
class="col-12 col-sm col row d-flex align-items-center d-sm-block"
>
<h4 class="col-4 col-sm-12">{{ $t("Uptime") }}</h4>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">(1{{ $t("-year") }})</p>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">
(1{{ $t("-year") }})
</p>
<span class="col-4 col-sm-12 num">
<Uptime :monitor="monitor" type="1y" />
</span>
</div>
<div v-if="tlsInfo" class="col-12 col-sm col row d-flex align-items-center d-sm-block">
<div
v-if="tlsInfo"
class="col-12 col-sm col row d-flex align-items-center d-sm-block"
>
<h4 class="col-4 col-sm-12">{{ $t("Cert Exp.") }}</h4>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">(<Datetime :value="tlsInfo.certInfo.validTo" date-only />)</p>
<p class="col-4 col-sm-12 mb-0 mb-sm-2">
(<Datetime
:value="tlsInfo.certInfo.validTo"
date-only
/>)
</p>
<span class="col-4 col-sm-12 num">
<a href="#" @click.prevent="toggleCertInfoBox = !toggleCertInfoBox">{{ tlsInfo.certInfo.daysRemaining }} {{ $tc("day", tlsInfo.certInfo.daysRemaining) }}</a>
<a
href="#"
@click.prevent="
toggleCertInfoBox = !toggleCertInfoBox
"
>{{ tlsInfo.certInfo.daysRemaining }}
{{
$tc("day", tlsInfo.certInfo.daysRemaining)
}}</a>
</span>
</div>
</div>
@@ -172,17 +310,26 @@
<!-- Cert Info Box -->
<transition name="slide-fade" appear>
<div v-if="showCertInfoBox" class="shadow-box big-padding text-center">
<div
v-if="showCertInfoBox"
class="shadow-box big-padding text-center"
>
<div class="row">
<div class="col">
<certificate-info :certInfo="tlsInfo.certInfo" :valid="tlsInfo.valid" />
<certificate-info
:certInfo="tlsInfo.certInfo"
:valid="tlsInfo.valid"
/>
</div>
</div>
</div>
</transition>
<!-- Ping Chart -->
<div v-if="showPingChartBox" class="shadow-box big-padding text-center ping-chart-wrapper">
<div
v-if="showPingChartBox"
class="shadow-box big-padding text-center ping-chart-wrapper"
>
<div class="row">
<div class="col">
<PingChart :monitor-id="monitor.id" />
@@ -194,25 +341,46 @@
<div v-if="monitor.type === 'real-browser'" class="shadow-box">
<div class="row">
<div class="col-md-6 zoom-cursor">
<img :src="screenshotURL" style="width: 100%;" alt="screenshot of the website" @click="showScreenshotDialog">
<img
:src="screenshotURL"
style="width: 100%;"
alt="screenshot of the website"
@click="showScreenshotDialog"
/>
</div>
<ScreenshotDialog ref="screenshotDialog" :imageURL="screenshotURL" />
<ScreenshotDialog
ref="screenshotDialog"
:imageURL="screenshotURL"
/>
</div>
</div>
<div class="shadow-box table-shadow-box">
<div class="dropdown dropdown-clear-data">
<button class="btn btn-sm btn-outline-danger dropdown-toggle" type="button" data-bs-toggle="dropdown">
<font-awesome-icon icon="trash" /> {{ $t("Clear Data") }}
<button
class="btn btn-sm btn-outline-danger dropdown-toggle"
type="button"
data-bs-toggle="dropdown"
>
<font-awesome-icon icon="trash" />
{{ $t("Clear Data") }}
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button type="button" class="dropdown-item" @click="clearEventsDialog">
<button
type="button"
class="dropdown-item"
@click="clearEventsDialog"
>
{{ $t("Events") }}
</button>
</li>
<li>
<button type="button" class="dropdown-item" @click="clearHeartbeatsDialog">
<button
type="button"
class="dropdown-item"
@click="clearHeartbeatsDialog"
>
{{ $t("Heartbeats") }}
</button>
</li>
@@ -227,9 +395,15 @@
</tr>
</thead>
<tbody>
<tr v-for="(beat, index) in displayedRecords" :key="index" style="padding: 10px;">
<tr
v-for="(beat, index) in displayedRecords"
:key="index"
style="padding: 10px;"
>
<td><Status :status="beat.status" /></td>
<td :class="{ 'border-0':! beat.msg}"><Datetime :value="beat.time" /></td>
<td :class="{ 'border-0': !beat.msg }">
<Datetime :value="beat.time" />
</td>
<td class="border-0">{{ beat.msg }}</td>
</tr>
@@ -251,19 +425,42 @@
</div>
</div>
<Confirm ref="confirmPause" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="pauseMonitor">
<Confirm
ref="confirmPause"
:yes-text="$t('Yes')"
:no-text="$t('No')"
@yes="pauseMonitor"
>
{{ $t("pauseMonitorMsg") }}
</Confirm>
<Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteMonitor">
<Confirm
ref="confirmDelete"
btn-style="btn-danger"
:yes-text="$t('Yes')"
:no-text="$t('No')"
@yes="deleteMonitor"
>
{{ $t("deleteMonitorMsg") }}
</Confirm>
<Confirm ref="confirmClearEvents" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="clearEvents">
<Confirm
ref="confirmClearEvents"
btn-style="btn-danger"
:yes-text="$t('Yes')"
:no-text="$t('No')"
@yes="clearEvents"
>
{{ $t("clearEventsMsg") }}
</Confirm>
<Confirm ref="confirmClearHeartbeats" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="clearHeartbeats">
<Confirm
ref="confirmClearHeartbeats"
btn-style="btn-danger"
:yes-text="$t('Yes')"
:no-text="$t('No')"
@yes="clearHeartbeats"
>
{{ $t("clearHeartbeatsMsg") }}
</Confirm>
</div>
@@ -281,14 +478,16 @@ import Datetime from "../components/Datetime.vue";
import CountUp from "../components/CountUp.vue";
import Uptime from "../components/Uptime.vue";
import Pagination from "v-pagination-3";
const PingChart = defineAsyncComponent(() => import("../components/PingChart.vue"));
const PingChart = defineAsyncComponent(() =>
import("../components/PingChart.vue")
);
import Tag from "../components/Tag.vue";
import CertificateInfo from "../components/CertificateInfo.vue";
import { getMonitorRelativeURL } from "../util.ts";
import { URL } from "whatwg-url";
import DOMPurify from "dompurify";
import { marked } from "marked";
import { getResBaseURL } from "../util-frontend";
import { getResBaseURL, relativeTimeFormatter } from "../util-frontend";
import { highlight, languages } from "prismjs/components/prism-core";
import "prismjs/components/prism-clike";
import "prismjs/components/prism-javascript";
@@ -310,7 +509,7 @@ export default {
Tag,
CertificateInfo,
PrismEditor,
ScreenshotDialog
ScreenshotDialog,
},
data() {
return {
@@ -344,7 +543,10 @@ export default {
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.cacheTime = Date.now();
if (this.monitor.id in this.$root.lastHeartbeatList && this.$root.lastHeartbeatList[this.monitor.id]) {
if (
this.monitor.id in this.$root.lastHeartbeatList &&
this.$root.lastHeartbeatList[this.monitor.id]
) {
return this.$root.lastHeartbeatList[this.monitor.id];
}
@@ -362,7 +564,10 @@ export default {
},
avgPing() {
if (this.$root.avgPingList[this.monitor.id] || this.$root.avgPingList[this.monitor.id] === 0) {
if (
this.$root.avgPingList[this.monitor.id] ||
this.$root.avgPingList[this.monitor.id] === 0
) {
return this.$root.avgPingList[this.monitor.id];
}
@@ -374,14 +579,17 @@ export default {
return this.$root.statusList[this.monitor.id];
}
return { };
return {};
},
tlsInfo() {
// Add: this.$root.tlsInfoList[this.monitor.id].certInfo
// Fix: TypeError: Cannot read properties of undefined (reading 'validTo')
// Reason: TLS Info object format is changed in 1.8.0, if for some reason, it cannot connect to the site after update to 1.8.0, the object is still in the old format.
if (this.$root.tlsInfoList[this.monitor.id] && this.$root.tlsInfoList[this.monitor.id].certInfo) {
if (
this.$root.tlsInfoList[this.monitor.id] &&
this.$root.tlsInfoList[this.monitor.id].certInfo
) {
return this.$root.tlsInfoList[this.monitor.id];
}
@@ -397,11 +605,21 @@ export default {
},
pushURL() {
return this.$root.baseURL + "/api/push/" + this.monitor.pushToken + "?status=up&msg=OK&ping=";
return (
this.$root.baseURL +
"/api/push/" +
this.monitor.pushToken +
"?status=up&msg=OK&ping="
);
},
screenshotURL() {
return getResBaseURL() + this.monitor.screenshot + "?time=" + this.cacheTime;
return (
getResBaseURL() +
this.monitor.screenshot +
"?time=" +
this.cacheTime
);
},
descriptionHTML() {
@@ -410,7 +628,7 @@ export default {
} else {
return "";
}
}
},
},
watch: {
@@ -434,7 +652,10 @@ export default {
mounted() {
this.getImportantHeartbeatListLength();
this.$root.emitter.on("newImportantHeartbeat", this.onNewImportantHeartbeat);
this.$root.emitter.on(
"newImportantHeartbeat",
this.onNewImportantHeartbeat
);
if (this.monitor && this.monitor.type === "push") {
if (this.lastHeartBeat.status === -1) {
@@ -445,7 +666,10 @@ export default {
},
beforeUnmount() {
this.$root.emitter.off("newImportantHeartbeat", this.onNewImportantHeartbeat);
this.$root.emitter.off(
"newImportantHeartbeat",
this.onNewImportantHeartbeat
);
},
methods: {
@@ -472,9 +696,11 @@ export default {
* @returns {void}
*/
resumeMonitor() {
this.$root.getSocket().emit("resumeMonitor", this.monitor.id, (res) => {
this.$root.toastRes(res);
});
this.$root
.getSocket()
.emit("resumeMonitor", this.monitor.id, (res) => {
this.$root.toastRes(res);
});
},
/**
@@ -482,9 +708,11 @@ export default {
* @returns {void}
*/
pauseMonitor() {
this.$root.getSocket().emit("pauseMonitor", this.monitor.id, (res) => {
this.$root.toastRes(res);
});
this.$root
.getSocket()
.emit("pauseMonitor", this.monitor.id, (res) => {
this.$root.toastRes(res);
});
},
/**
@@ -552,7 +780,7 @@ export default {
*/
clearHeartbeats() {
this.$root.clearHeartbeats(this.monitor.id, (res) => {
if (! res.ok) {
if (!res.ok) {
toast.error(res.msg);
}
});
@@ -569,7 +797,11 @@ export default {
translationPrefix = "Avg. ";
}
if (this.monitor.type === "http" || this.monitor.type === "keyword" || this.monitor.type === "json-query") {
if (
this.monitor.type === "http" ||
this.monitor.type === "keyword" ||
this.monitor.type === "json-query"
) {
return this.$t(translationPrefix + "Response");
}
@@ -599,7 +831,10 @@ export default {
return parsedUrl.toString();
} catch (e) {
// Handle SQL Server
return urlString.replaceAll(/Password=(.+);/ig, "Password=******;");
return urlString.replaceAll(
/Password=(.+);/gi,
"Password=******;"
);
}
},
@@ -609,12 +844,18 @@ export default {
*/
getImportantHeartbeatListLength() {
if (this.monitor) {
this.$root.getSocket().emit("monitorImportantHeartbeatListCount", this.monitor.id, (res) => {
if (res.ok) {
this.importantHeartBeatListLength = res.count;
this.getImportantHeartbeatListPaged();
}
});
this.$root
.getSocket()
.emit(
"monitorImportantHeartbeatListCount",
this.monitor.id,
(res) => {
if (res.ok) {
this.importantHeartBeatListLength = res.count;
this.getImportantHeartbeatListPaged();
}
}
);
}
},
@@ -625,11 +866,19 @@ export default {
getImportantHeartbeatListPaged() {
if (this.monitor) {
const offset = (this.page - 1) * this.perPage;
this.$root.getSocket().emit("monitorImportantHeartbeatListPaged", this.monitor.id, offset, this.perPage, (res) => {
if (res.ok) {
this.displayedRecords = res.data;
}
});
this.$root
.getSocket()
.emit(
"monitorImportantHeartbeatListPaged",
this.monitor.id,
offset,
this.perPage,
(res) => {
if (res.ok) {
this.displayedRecords = res.data;
}
}
);
}
},
@@ -661,13 +910,26 @@ export default {
loadPushExample() {
this.pushMonitor.code = "";
this.$root.getSocket().emit("getPushExample", this.pushMonitor.currentExample, (res) => {
let code = res.code
.replace("60", this.monitor.interval)
.replace("https://example.com/api/push/key?status=up&msg=OK&ping=", this.pushURL);
this.pushMonitor.code = code;
});
}
this.$root
.getSocket()
.emit(
"getPushExample",
this.pushMonitor.currentExample,
(res) => {
let code = res.code
.replace("60", this.monitor.interval)
.replace(
"https://example.com/api/push/key?status=up&msg=OK&ping=",
this.pushURL
);
this.pushMonitor.code = code;
}
);
},
secondsToHumanReadableFormat(seconds) {
return relativeTimeFormatter.secondsToHumanReadableFormat(seconds);
},
},
};
</script>

View File

@@ -311,6 +311,13 @@
required
data-testid="hostname-input"
>
<div v-if="monitor.type === 'mqtt'" class="form-text">
<i18n-t tag="p" keypath="mqttHostnameTip">
<template #hostnameFormat>
<code>[mqtt,ws,wss]://hostname</code>
</template>
</i18n-t>
</div>
</div>
<!-- Port -->
@@ -483,6 +490,21 @@
</div>
</div>
<div class="my-3">
<label for="mqttWebsocketPath" class="form-label">{{ $t("mqttWebSocketPath") }}</label>
<input
v-if="/wss?:\/\/.+/.test(monitor.hostname)"
id="mqttWebsocketPath"
v-model="monitor.mqttWebsocketPath"
type="text"
class="form-control"
>
<input v-else type="text" class="form-control" disabled>
<div class="form-text">
{{ $t("mqttWebsocketPathExplanation") }}
</div>
</div>
<div class="my-3">
<label for="mqttCheckType" class="form-label">MQTT {{ $t("Check Type") }}</label>
<select id="mqttCheckType" v-model="monitor.mqttCheckType" class="form-select" required>
@@ -607,6 +629,9 @@
<div class="my-3">
<label for="interval" class="form-label">{{ $t("Heartbeat Interval") }} ({{ $t("checkEverySecond", [ monitor.interval ]) }})</label>
<input id="interval" v-model="monitor.interval" type="number" class="form-control" required :min="minInterval" step="1" :max="maxInterval" @blur="finishUpdateInterval">
<div class="form-text">
{{ monitor.humanReadableInterval }}
</div>
</div>
<div class="my-3">
@@ -1148,7 +1173,7 @@ import {
MIN_INTERVAL_SECOND,
sleep,
} from "../util.ts";
import { hostNameRegexPattern } from "../util-frontend";
import { hostNameRegexPattern, relativeTimeFormatter } from "../util-frontend";
import HiddenInput from "../components/HiddenInput.vue";
import EditMonitorConditions from "../components/EditMonitorConditions.vue";
@@ -1164,6 +1189,7 @@ const monitorDefaults = {
method: "GET",
ipFamily: null,
interval: 60,
humanReadableInterval: relativeTimeFormatter.secondsToHumanReadableFormat(60),
retryInterval: 60,
resendInterval: 0,
maxretries: 0,
@@ -1181,6 +1207,7 @@ const monitorDefaults = {
mqttUsername: "",
mqttPassword: "",
mqttTopic: "",
mqttWebsocketPath: "",
mqttSuccessMessage: "",
mqttCheckType: "keyword",
authMethod: null,
@@ -1545,6 +1572,8 @@ message HealthCheckResponse {
if (this.monitor.retryInterval === oldValue) {
this.monitor.retryInterval = value;
}
// Converting monitor.interval to human readable format.
this.monitor.humanReadableInterval = relativeTimeFormatter.secondsToHumanReadableFormat(value);
},
"monitor.timeout"(value, oldValue) {
@@ -1845,6 +1874,16 @@ message HealthCheckResponse {
return false;
}
}
// Validate MQTT WebSocket Path pattern if present
if (this.monitor.type === "mqtt" && this.monitor.mqttWebsocketPath) {
const pattern = /^\/[A-Za-z0-9-_&()*+]*$/;
if (!pattern.test(this.monitor.mqttWebsocketPath)) {
toast.error(this.$t("mqttWebsocketPathInvalid"));
return false;
}
}
return true;
},

View File

@@ -22,6 +22,12 @@
<div class="title">{{ statusPage.title }}</div>
<div class="slug">/status/{{ statusPage.slug }}</div>
</div>
<div class="actions">
<button class="btn btn-danger delete-status-page" @click.stop.prevent="deleteDialog(statusPage.slug)">
<font-awesome-icon icon="trash" />
<span>{{ $t("Delete") }}</span>
</button>
</div>
</a>
</template>
<div v-else class="d-flex align-items-center justify-content-center my-3 spinner">
@@ -30,18 +36,22 @@
</div>
</div>
</transition>
<Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteStatusPage">
{{ $t("deleteStatusPageMsg") }}
</Confirm>
</template>
<script>
import Confirm from "../components/Confirm.vue";
import { getResBaseURL } from "../util-frontend";
export default {
components: {
Confirm
},
data() {
return {
selectedStatusSlug: ""
};
},
computed: {
@@ -62,6 +72,20 @@ export default {
} else {
return getResBaseURL() + icon;
}
},
deleteDialog(slug) {
this.$data.selectedStatusSlug = slug;
this.$refs.confirmDelete.show();
},
deleteStatusPage() {
this.$root.getSocket().emit("deleteStatusPage", this.$data.selectedStatusSlug, (res) => {
if (res.ok) {
this.$root.toastSuccess(this.$t("successDeleted"));
window.location.reload();
} else {
this.$root.toastError(res.msg);
}
});
}
},
};
@@ -81,6 +105,10 @@ export default {
&:hover {
background-color: $highlight-white;
& .actions {
visibility: visible;
}
}
&.active {
@@ -98,6 +126,8 @@ export default {
}
.info {
flex: 1 1 auto;
.title {
font-weight: bold;
font-size: 20px;
@@ -107,6 +137,19 @@ export default {
font-size: 14px;
}
}
.actions {
visibility: hidden;
display: flex;
align-items: center;
.delete-status-page {
flex: 1 1 auto;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
}
}
.dark {
@@ -120,4 +163,20 @@ export default {
}
}
}
@media (max-width: 770px) {
.item {
.actions {
visibility: visible;
.btn {
padding: 10px;
}
span {
display: none;
}
}
}
}
</style>

View File

@@ -720,7 +720,7 @@ export default {
// Configure auto-refresh loop
feedInterval = setInterval(() => {
this.updateHeartbeatList();
}, (this.config.autoRefreshInterval + 10) * 1000);
}, Math.max(5, this.config.autoRefreshInterval) * 1000);
this.updateUpdateTimer();
}).catch( function (error) {
@@ -806,7 +806,15 @@ export default {
clearInterval(this.updateCountdown);
this.updateCountdown = setInterval(() => {
const countdown = dayjs.duration(this.lastUpdateTime.add(this.config.autoRefreshInterval, "seconds").add(10, "seconds").diff(dayjs()));
// rounding here as otherwise we sometimes skip numbers in cases of time drift
const countdown = dayjs.duration(
Math.round(
this.lastUpdateTime
.add(Math.max(5, this.config.autoRefreshInterval), "seconds")
.diff(dayjs())
/ 1000
), "seconds");
if (countdown.as("seconds") < 0) {
clearInterval(this.updateCountdown);
} else {

View File

@@ -213,3 +213,78 @@ export function getToastErrorTimeout() {
return errorTimeout;
}
class RelativeTimeFormatter {
/**
* Default locale and options for Relative Time Formatter
*/
constructor() {
this.options = { numeric: "auto" };
this.instance = new Intl.RelativeTimeFormat(currentLocale(), this.options);
}
/**
* Method to update the instance locale and options
* @param {string} locale Localization identifier (e.g., "en", "ar-sy") to update the instance with.
* @returns {void} No return value.
*/
updateLocale(locale) {
this.instance = new Intl.RelativeTimeFormat(locale, this.options);
}
/**
* Method to convert seconds into Human readable format
* @param {number} seconds Receive value in seconds.
* @returns {string} String converted to Days Mins Seconds Format
*/
secondsToHumanReadableFormat(seconds) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor(((seconds % 86400) % 3600) / 60);
const secs = ((seconds % 86400) % 3600) % 60;
const parts = [];
/**
* Build the formatted string from parts
* 1. Get the relative time formatted parts from the instance.
* 2. Filter out the relevant parts literal (unit of time) or integer (value).
* 3. Map out the required values.
* @param {number} value Receives value in seconds.
* @param {string} unitOfTime Expected unit of time after conversion.
* @returns {void}
*/
const toFormattedPart = (value, unitOfTime) => {
const partsArray = this.instance.formatToParts(value, unitOfTime);
const filteredParts = partsArray
.filter(
(part, index) =>
(part.type === "literal" || part.type === "integer") &&
index > 0
)
.map((part) => part.value);
const formattedString = filteredParts.join("").trim();
parts.push(formattedString);
};
if (days > 0) {
toFormattedPart(days, "days");
}
if (hours > 0) {
toFormattedPart(hours, "hour");
}
if (minutes > 0) {
toFormattedPart(minutes, "minute");
}
if (secs > 0) {
toFormattedPart(secs, "second");
}
if (parts.length > 0) {
return `${parts.join(" ")}`;
}
return this.instance.format(0, "second"); // Handle case for 0 seconds
}
}
export const relativeTimeFormatter = new RelativeTimeFormatter();

View File

@@ -23,6 +23,7 @@ async function testMqtt(mqttSuccessMessage, mqttCheckType, receivedMessage) {
port: connectionString.split(":")[2],
mqttUsername: null,
mqttPassword: null,
mqttWebsocketPath: null, // for WebSocket connections
interval: 20, // controls the timeout
mqttSuccessMessage: mqttSuccessMessage, // for keywords
expectedValue: mqttSuccessMessage, // for json-query

View File

@@ -121,8 +121,8 @@ test.describe("Status Page", () => {
await expect(page.getByTestId("update-countdown-text")).toContainText("00:");
const updateCountdown = Number((await page.getByTestId("update-countdown-text").textContent()).match(/(\d+):(\d+)/)[2]);
expect(updateCountdown).toBeGreaterThanOrEqual(refreshInterval); // cant be certain when the timer will start, so ensure it's within expected range
expect(updateCountdown).toBeLessThanOrEqual(refreshInterval + 10);
expect(updateCountdown).toBeGreaterThanOrEqual(refreshInterval - 10); // cant be certain when the timer will start, so ensure it's within expected range
expect(updateCountdown).toBeLessThanOrEqual(refreshInterval);
await expect(page.locator("body")).toHaveClass(theme);