Merge branch 'master' into dev

This commit is contained in:
Louis Lam
2026-03-25 02:30:17 +08:00
committed by GitHub
35 changed files with 1921 additions and 1085 deletions

49
.github/workflows/build-docker-push.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: Build Docker Push Image
on:
schedule:
# Runs at 2:00 AM UTC on the 1st of every month
- cron: "0 2 1 * *"
workflow_dispatch: # Allow manual trigger
permissions: {}
jobs:
build-docker-push:
# Only run on the original repository, not on forks
if: github.repository == 'louislam/uptime-kuma'
runs-on: ubuntu-latest
timeout-minutes: 120
permissions:
contents: read
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: { persist-credentials: false }
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3.6.1
- name: Login to Docker Hub
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Use Node.js 20
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version: 20
- name: Set up Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
- name: Install cross-env
run: npm install -g cross-env
- name: Build and push Docker image
working-directory: extra/uptime-kuma-push
run: npm run build-docker

1069
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "uptime-kuma",
"version": "2.2.0",
"version": "2.2.1",
"license": "MIT",
"repository": {
"type": "git",
@@ -44,7 +44,7 @@
"build-docker-pr-test": "docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64 -t louislam/uptime-kuma:pr-test2 --target pr-test2 . --push",
"upload-artifacts": "node extra/release/upload-artifacts.mjs",
"upload-artifacts-beta": "node extra/release/upload-artifacts-beta.mjs",
"setup": "git checkout 2.2.0 && npm ci --omit dev --no-audit && npm run download-dist",
"setup": "git checkout 2.2.1 && npm ci --omit dev --no-audit && npm run download-dist",
"download-dist": "node extra/download-dist.js",
"mark-as-nightly": "node extra/mark-as-nightly.js",
"reset-password": "node extra/reset-password.js",
@@ -126,6 +126,7 @@
"nostr-tools": "~2.20.0",
"notp": "~2.0.3",
"openid-client": "~5.7.1",
"oracledb": "~6.10.0",
"password-hash": "~1.2.2",
"pg": "~8.11.6",
"pg-connection-string": "~2.6.4",
@@ -164,6 +165,7 @@
"@testcontainers/mariadb": "^10.28.0",
"@testcontainers/mssqlserver": "^10.28.0",
"@testcontainers/mysql": "^11.12.0",
"@testcontainers/oraclefree": "^11.13.0",
"@testcontainers/postgresql": "^11.12.0",
"@testcontainers/rabbitmq": "^10.28.0",
"@types/bootstrap": "~5.1.13",

View File

@@ -275,7 +275,6 @@ class Database {
// See: https://github.com/knex/knex/issues/3176#issuecomment-3389054899
min: 0,
max: 20,
propagateCreateError: false,
acquireTimeoutMillis: acquireConnectionTimeout,
afterCreate: (rawConn, done) => {
this.initSQLite(rawConn, testMode)

View File

@@ -2059,7 +2059,7 @@ class Monitor extends BeanModel {
}
const parentActive = await Monitor.isParentActive(parent.id);
return parent.active && parentActive;
return parent.active === 1 && parentActive;
}
/**

View File

@@ -0,0 +1,155 @@
const { MonitorType } = require("./monitor-type");
const { log, UP } = require("../../src/util");
const dayjs = require("dayjs");
const oracledb = require("oracledb");
const { ConditionVariable } = require("../monitor-conditions/variables");
const { defaultStringOperators } = require("../monitor-conditions/operators");
const { ConditionExpressionGroup } = require("../monitor-conditions/expression");
const { evaluateExpressionGroup } = require("../monitor-conditions/evaluator");
class OracleDbMonitorType extends MonitorType {
name = "oracledb";
supportsConditions = true;
conditionVariables = [new ConditionVariable("result", defaultStringOperators)];
/**
* @inheritdoc
*/
async check(monitor, heartbeat, _server) {
let query = monitor.databaseQuery;
if (!query || (typeof query === "string" && query.trim() === "")) {
query = "SELECT 1 FROM DUAL";
}
const conditions = monitor.conditions ? ConditionExpressionGroup.fromMonitor(monitor) : null;
const hasConditions = conditions && conditions.children && conditions.children.length > 0;
const startTime = dayjs().valueOf();
try {
if (hasConditions) {
const result = await this.oracledbQuerySingleValue(
monitor.databaseConnectionString,
query,
monitor.basic_auth_user,
monitor.basic_auth_pass
);
heartbeat.ping = dayjs().valueOf() - startTime;
const conditionsResult = evaluateExpressionGroup(conditions, { result: String(result) });
if (!conditionsResult) {
throw new Error(`Query result did not meet the specified conditions (${result})`);
}
heartbeat.status = UP;
heartbeat.msg = "Query did meet specified conditions";
} else {
const result = await this.oracledbQuery(
monitor.databaseConnectionString,
query,
monitor.basic_auth_user,
monitor.basic_auth_pass
);
heartbeat.ping = dayjs().valueOf() - startTime;
heartbeat.status = UP;
heartbeat.msg = result;
}
} catch (error) {
heartbeat.ping = dayjs().valueOf() - startTime;
if (error.message.includes("did not meet the specified conditions")) {
throw error;
}
throw new Error(`Database connection/query failed: ${error.message}`);
}
}
/**
* Run a query on Oracle Database.
* @param {string} connectionString The Oracle DB connection string
* @param {string} query The query to execute
* @param {string} username Oracle DB username
* @param {string} password Oracle DB password
* @returns {Promise<string>} Row count or execution message
*/
async oracledbQuery(connectionString, query, username, password) {
let connection;
try {
connection = await oracledb.getConnection({
connectString: connectionString.trim(),
user: username.trim(),
password: password.trim(),
});
const result = await connection.execute(query, [], {
outFormat: oracledb.OUT_FORMAT_OBJECT,
});
if (Array.isArray(result.rows)) {
return `Rows: ${result.rows.length}`;
}
if (typeof result.rowsAffected === "number") {
return `Rows affected: ${result.rowsAffected}`;
}
return "Query executed successfully";
} catch (error) {
log.debug(this.name, "Error caught in the query execution.", error.message);
throw error;
} finally {
if (connection) {
await connection.close();
}
}
}
/**
* Run a query on Oracle Database expecting a single value result.
* @param {string} connectionString The Oracle DB connection string
* @param {string} query The query to execute
* @param {string} username Oracle DB username
* @param {string} password Oracle DB password
* @returns {Promise<any>} Single value from the first column of the first row
*/
async oracledbQuerySingleValue(connectionString, query, username, password) {
let connection;
try {
connection = await oracledb.getConnection({
connectString: connectionString,
user: username,
password: password,
});
const result = await connection.execute(query, [], {
outFormat: oracledb.OUT_FORMAT_OBJECT,
});
if (!result.rows || result.rows.length === 0) {
throw new Error("Query returned no results");
}
if (result.rows.length > 1) {
throw new Error("Multiple values were found, expected only one value");
}
const firstRow = result.rows[0];
const columnNames = Object.keys(firstRow);
if (columnNames.length > 1) {
throw new Error("Multiple columns were found, expected only one value");
}
return firstRow[columnNames[0]];
} catch (error) {
log.debug(this.name, "Error caught in the query execution.", error.message);
throw error;
} finally {
if (connection) {
await connection.close();
}
}
}
}
module.exports = {
OracleDbMonitorType,
};

View File

@@ -0,0 +1,47 @@
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
class Max extends NotificationProvider {
name = "max";
/**
* @inheritdoc
*/
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
const okMsg = "Sent Successfully.";
const baseUrl = (notification.maxApiUrl || "https://platform-api.max.ru").replace(/\/$/, "");
const chatId = notification.maxChatID;
try {
const config = this.getAxiosConfigWithProxy({
headers: {
Authorization: notification.maxBotToken,
"Content-Type": "application/json",
},
});
const body = {
text: msg,
};
if (notification.maxUseTemplate && notification.maxTemplate) {
const rendered = await this.renderTemplate(notification.maxTemplate, msg, monitorJSON, heartbeatJSON);
body.text = rendered;
if (notification.maxTemplateFormat && notification.maxTemplateFormat !== "plain") {
body.format = notification.maxTemplateFormat;
}
}
const url = `${baseUrl}/messages?chat_id=${encodeURIComponent(chatId)}`;
await axios.post(url, body, config);
return okMsg;
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}
module.exports = Max;

View File

@@ -0,0 +1,44 @@
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
class VK extends NotificationProvider {
name = "VK";
/**
* @inheritdoc
*/
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
const okMsg = "Sent Successfully.";
const url = "https://api.vk.ru/method/messages.send";
try {
const data = new URLSearchParams({
access_token: notification.vkAccessToken,
v: notification.vkApiVersion,
peer_id: notification.vkPeerId,
message: msg,
dont_parse_links: notification.vkDontParseLinks ? "1" : "0",
random_id: String(Math.floor(Math.random() * 2147483647)),
});
const config = this.getAxiosConfigWithProxy({});
const response = await axios.post(url, data, config);
if (response.data?.error) {
throw new Error(
`VK API returned error ${response.data.error.error_code}: ${response.data.error.error_msg}`
);
}
if (typeof response.data?.response === "undefined") {
throw new Error("Invalid VK API response");
}
return okMsg;
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}
module.exports = VK;

View File

@@ -90,6 +90,8 @@ const { commandExists } = require("./util-server");
const Whatsapp360messenger = require("./notification-providers/360messenger");
const Webpush = require("./notification-providers/Webpush");
const HaloPSA = require("./notification-providers/HaloPSA");
const Max = require("./notification-providers/max");
const VK = require("./notification-providers/vk");
class Notification {
providerList = {};
@@ -195,6 +197,8 @@ class Notification {
new Whatsapp360messenger(),
new Webpush(),
new HaloPSA(),
new Max(),
new VK(),
];
for (let item of list) {
if (!item.name) {

View File

@@ -129,7 +129,7 @@ router.all("/api/push/:pushToken", async (request, response) => {
Monitor.sendStats(io, monitor.id, monitor.user_id);
try {
new Prometheus(monitor, []).update(bean, undefined);
new Prometheus(monitor, await monitor.getTags()).update(bean, undefined);
} catch (e) {
log.error("prometheus", "Please submit an issue to our GitHub repo. Prometheus update error: ", e.message);
}

View File

@@ -131,6 +131,7 @@ class UptimeKumaServer {
UptimeKumaServer.monitorTypeList["system-service"] = new SystemServiceMonitorType();
UptimeKumaServer.monitorTypeList["sqlserver"] = new MssqlMonitorType();
UptimeKumaServer.monitorTypeList["mysql"] = new MysqlMonitorType();
UptimeKumaServer.monitorTypeList["oracledb"] = new OracleDbMonitorType();
// Allow all CORS origins (polling) in development
let cors = undefined;
@@ -582,4 +583,5 @@ const { RedisMonitorType } = require("./monitor-types/redis");
const { SystemServiceMonitorType } = require("./monitor-types/system-service");
const { MssqlMonitorType } = require("./monitor-types/mssql");
const { MysqlMonitorType } = require("./monitor-types/mysql");
const { OracleDbMonitorType } = require("./monitor-types/oracledb");
const Monitor = require("./model/monitor");

View File

@@ -1,493 +0,0 @@
<template>
<div v-if="group && group.monitorList && group.monitorList.length > 1" class="sort-dropdown">
<div class="dropdown">
<button
:id="'sortDropdown' + groupIndex"
type="button"
class="btn btn-sm btn-outline-secondary dropdown-toggle sort-button"
data-bs-toggle="dropdown"
aria-expanded="false"
:aria-label="$t('Sort options')"
:title="$t('Sort options')"
>
<div class="sort-arrows">
<font-awesome-icon
icon="arrow-down"
:class="{
'arrow-inactive': !group.sortKey || group.sortDirection !== 'desc',
'arrow-active': group.sortKey && group.sortDirection === 'desc',
}"
/>
<font-awesome-icon
icon="arrow-up"
:class="{
'arrow-inactive': !group.sortKey || group.sortDirection !== 'asc',
'arrow-active': group.sortKey && group.sortDirection === 'asc',
}"
/>
</div>
</button>
<ul class="dropdown-menu dropdown-menu-end sort-menu" :aria-labelledby="'sortDropdown' + groupIndex">
<li>
<button
class="dropdown-item sort-item"
type="button"
:aria-label="$t('Sort by status')"
:title="$t('Sort by status')"
@click="setSort('status')"
>
<div class="sort-item-content">
<span>{{ $t("Status") }}</span>
<span v-if="getSortKey() === 'status'" class="sort-indicators">
<font-awesome-icon
:icon="group.sortDirection === 'asc' ? 'arrow-up' : 'arrow-down'"
class="arrow-active me-1"
/>
</span>
</div>
</button>
</li>
<li>
<button
class="dropdown-item sort-item"
type="button"
:aria-label="$t('Sort by name')"
:title="$t('Sort by name')"
@click="setSort('name')"
>
<div class="sort-item-content">
<span>{{ $t("Name") }}</span>
<span v-if="getSortKey() === 'name'" class="sort-indicators">
<font-awesome-icon
:icon="group.sortDirection === 'asc' ? 'arrow-up' : 'arrow-down'"
class="arrow-active me-1"
/>
</span>
</div>
</button>
</li>
<li>
<button
class="dropdown-item sort-item"
type="button"
:aria-label="$t('Sort by uptime')"
:title="$t('Sort by uptime')"
@click="setSort('uptime')"
>
<div class="sort-item-content">
<span>{{ $t("Uptime") }}</span>
<span v-if="getSortKey() === 'uptime'" class="sort-indicators">
<font-awesome-icon
:icon="group.sortDirection === 'asc' ? 'arrow-up' : 'arrow-down'"
class="arrow-active me-1"
/>
</span>
</div>
</button>
</li>
<li v-if="showCertificateExpiry">
<button
class="dropdown-item sort-item"
type="button"
:aria-label="$t('Sort by certificate expiry')"
:title="$t('Sort by certificate expiry')"
@click="setSort('cert')"
>
<div class="sort-item-content">
<span>{{ $t("Cert Exp.") }}</span>
<span v-if="getSortKey() === 'cert'" class="sort-indicators">
<font-awesome-icon
:icon="group.sortDirection === 'asc' ? 'arrow-up' : 'arrow-down'"
class="arrow-active me-1"
/>
</span>
</div>
</button>
</li>
</ul>
</div>
</div>
</template>
<script>
export default {
name: "GroupSortDropdown",
props: {
/** Group object containing monitorList and sort settings */
group: {
type: Object,
required: true,
},
/** Index of the group for unique IDs */
groupIndex: {
type: Number,
required: true,
},
/** Should certificate expiry options be shown? */
showCertificateExpiry: {
type: Boolean,
default: false,
},
},
emits: ["update-group"],
computed: {
/**
* Parse sort settings from URL query parameters
* @returns {object} Parsed sort settings for all groups
*/
sortSettingsFromURL() {
const sortSettings = {};
if (this.$route && this.$route.query) {
for (const [key, value] of Object.entries(this.$route.query)) {
if (key.startsWith("sort_") && typeof value === "string") {
const groupId = key.replace("sort_", "");
const [sortKey, direction] = value.split("_");
if (
sortKey &&
["status", "name", "uptime", "cert"].includes(sortKey) &&
direction &&
["asc", "desc"].includes(direction)
) {
sortSettings[groupId] = {
sortKey,
direction,
};
}
}
}
}
return sortSettings;
},
},
watch: {
// Watch for changes in heartbeat list, reapply sorting
"$root.heartbeatList": {
handler() {
this.applySort();
},
deep: true,
},
// Watch for changes in uptime list, reapply sorting
"$root.uptimeList": {
handler() {
this.applySort();
},
deep: true,
},
// Watch for URL changes and apply sort settings
sortSettingsFromURL: {
handler(newSortSettings) {
if (this.group) {
const groupId = this.getGroupIdentifier();
const urlSetting = newSortSettings[groupId];
if (urlSetting) {
this.updateGroup({
sortKey: urlSetting.sortKey,
sortDirection: urlSetting.direction,
});
} else {
// Set defaults if not in URL
if (this.group.sortKey === undefined) {
this.updateGroup({ sortKey: "status" });
}
if (this.group.sortDirection === undefined) {
this.updateGroup({ sortDirection: "asc" });
}
}
this.applySort();
}
},
immediate: true,
deep: true,
},
},
methods: {
/**
* Get sort key for the group
* @returns {string} sort key
*/
getSortKey() {
return this.group.sortKey || "status";
},
/**
* Update group properties by emitting to parent
* @param {object} updates - object with properties to update
* @returns {void}
*/
updateGroup(updates) {
this.$emit("update-group", this.groupIndex, updates);
},
/**
* Set group sort key and direction, then apply sorting
* @param {string} key - sort key ('status', 'name', 'uptime', 'cert')
* @returns {void}
*/
setSort(key) {
if (this.group.sortKey === key) {
this.updateGroup({
sortDirection: this.group.sortDirection === "asc" ? "desc" : "asc",
});
} else {
this.updateGroup({
sortKey: key,
sortDirection: "asc",
});
}
this.applySort();
this.updateRouterQuery();
},
/**
* Update router query parameters with sort settings
* @returns {void}
*/
updateRouterQuery() {
if (!this.$router) {
return;
}
const query = { ...this.$route.query };
const groupId = this.getGroupIdentifier();
if (this.group.sortKey && this.group.sortDirection) {
query[`sort_${groupId}`] = `${this.group.sortKey}_${this.group.sortDirection}`;
} else {
delete query[`sort_${groupId}`];
}
this.$router.push({ query }).catch(() => {});
},
/**
* Apply sorting logic directly to the group's monitorList (in-place)
* @returns {void}
*/
applySort() {
if (!this.group || !this.group.monitorList || !Array.isArray(this.group.monitorList)) {
return;
}
const sortKey = this.group.sortKey || "status";
const sortDirection = this.group.sortDirection || "desc";
this.updateGroup({
monitorList: [...this.group.monitorList].sort((a, b) => {
if (!a || !b) {
return 0;
}
let comparison = 0;
let valueA;
let valueB;
if (sortKey === "status") {
// Sort by status
const getStatusPriority = (monitor) => {
if (!monitor || !monitor.id) {
return 4;
}
const hbList = this.$root.heartbeatList || {};
const hbArr = hbList[monitor.id];
if (hbArr && hbArr.length > 0) {
const lastStatus = hbArr.at(-1).status;
if (lastStatus === 0) {
return 0;
} // Down
if (lastStatus === 1) {
return 1;
} // Up
if (lastStatus === 2) {
return 2;
} // Pending
if (lastStatus === 3) {
return 3;
} // Maintenance
}
return 4; // Unknown/No data
};
valueA = getStatusPriority(a);
valueB = getStatusPriority(b);
} else if (sortKey === "name") {
// Sort alphabetically by name
valueA = a.name ? a.name.toLowerCase() : "";
valueB = b.name ? b.name.toLowerCase() : "";
} else if (sortKey === "uptime") {
// Sort by uptime
const uptimeList = this.$root.uptimeList || {};
const uptimeA = a.id ? parseFloat(uptimeList[`${a.id}_24`]) || 0 : 0;
const uptimeB = b.id ? parseFloat(uptimeList[`${b.id}_24`]) || 0 : 0;
valueA = uptimeA;
valueB = uptimeB;
} else if (sortKey === "cert") {
// Sort by certificate expiry time
valueA = a.validCert && a.certExpiryDaysRemaining ? a.certExpiryDaysRemaining : -1;
valueB = b.validCert && b.certExpiryDaysRemaining ? b.certExpiryDaysRemaining : -1;
}
if (valueA < valueB) {
comparison = -1;
} else if (valueA > valueB) {
comparison = 1;
}
// Special handling for status sorting
if (sortKey === "status") {
return sortDirection === "desc" ? comparison * -1 : comparison;
} else {
return sortDirection === "asc" ? comparison : comparison * -1;
}
}),
});
},
/**
* Get unique identifier for the group
* @returns {string} group identifier
*/
getGroupIdentifier() {
// Prefer a stable server-provided id to avoid clashes between groups with the same name
if (this.group.id !== undefined && this.group.id !== null) {
return this.group.id.toString();
}
// Fallback to the current index for unsaved groups
return `group${this.groupIndex}`;
},
},
};
</script>
<style lang="scss" scoped>
@import "../assets/vars";
.sort-dropdown {
margin-left: auto;
}
.sort-button {
display: flex;
align-items: center;
justify-content: center;
padding: 0.3rem 0.6rem;
min-width: 40px;
border-radius: 10px;
background-color: white;
border: none;
box-shadow: 0 15px 70px rgba(0, 0, 0, 0.1);
transition: all ease-in-out 0.15s;
&:hover {
background-color: #f8f9fa;
}
&:focus,
&:active {
box-shadow: 0 15px 70px rgba(0, 0, 0, 0.1);
border: none;
outline: none;
}
.dark & {
background-color: $dark-bg;
color: $dark-font-color;
box-shadow: 0 15px 70px rgba(0, 0, 0, 0.3);
&:hover {
background-color: $dark-bg2;
}
&:focus,
&:active {
box-shadow: 0 15px 70px rgba(0, 0, 0, 0.3);
}
}
}
.sort-arrows {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 2px;
}
.arrow-inactive {
color: #aaa;
font-size: 0.7rem;
opacity: 0.5;
.dark & {
color: #6c757d;
}
}
.arrow-active {
color: #4caf50;
font-size: 0.8rem;
.dark & {
color: $primary;
}
}
.sort-menu {
min-width: auto;
width: auto;
padding: 0.2rem 0;
border-radius: 10px;
border: none;
box-shadow: 0 15px 70px rgba(0, 0, 0, 0.1);
overflow: hidden;
.dark & {
background-color: $dark-bg;
color: $dark-font-color;
border-color: $dark-border-color;
box-shadow: 0 15px 70px rgba(0, 0, 0, 0.3);
}
}
.sort-item {
padding: 0.4rem 0.8rem;
text-align: left;
width: 100%;
background: none;
border: none;
cursor: pointer;
&:hover {
background-color: #f8f9fa;
}
.dark & {
color: $dark-font-color;
&:hover {
background-color: $dark-bg2;
}
}
}
.sort-item-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
min-width: 120px;
}
.sort-indicators {
display: flex;
align-items: center;
margin-left: 10px;
}
</style>

View File

@@ -215,6 +215,7 @@ export default {
bale: "Bale",
Bitrix24: "Bitrix24",
discord: "Discord",
max: this.$t("maxMessenger"),
fluxer: "Fluxer",
GoogleChat: "Google Chat (Google Workspace)",
gorush: "Gorush",
@@ -333,6 +334,7 @@ export default {
WPush: "WPush(wpush.cn)",
YZJ: "YZJ (云之家自定义机器人)",
SMSPlanet: "SMSPlanet.pl",
VK: "VK",
};
// Sort by notification name alphabetically

View File

@@ -310,15 +310,15 @@ export default {
// Show ping values if it was up in this period
avgPingData.push({
x,
y: datapoint.up > 0 && datapoint.avgPing > 0 ? datapoint.avgPing : null,
y: datapoint.up > 0 && datapoint.avgPing != null ? datapoint.avgPing : null,
});
minPingData.push({
x,
y: datapoint.up > 0 && datapoint.avgPing > 0 ? datapoint.minPing : null,
y: datapoint.up > 0 && datapoint.avgPing != null ? datapoint.minPing : null,
});
maxPingData.push({
x,
y: datapoint.up > 0 && datapoint.avgPing > 0 ? datapoint.maxPing : null,
y: datapoint.up > 0 && datapoint.avgPing != null ? datapoint.maxPing : null,
});
downData.push({
x,

View File

@@ -5,125 +5,125 @@
<div class="mb-5" data-testid="group">
<!-- Group Title -->
<h2 class="group-title">
<div class="title-section">
<font-awesome-icon v-if="editMode && showGroupDrag" icon="arrows-alt-v" class="action drag me-3" />
<font-awesome-icon
v-if="editMode"
icon="times"
class="action remove me-3"
@click="removeGroup(group.index)"
/>
<span class="collapse-toggle" @click="toggleGroup(group.element)">
<font-awesome-icon
v-if="editMode && showGroupDrag"
icon="arrows-alt-v"
class="action drag me-3"
icon="chevron-down"
class="chevron me-2"
:class="{ collapsed: isGroupCollapsed(group.element) }"
/>
<font-awesome-icon
v-if="editMode"
icon="times"
class="action remove me-3"
@click="removeGroup(group.index)"
/>
<Editable
v-model="group.element.name"
:contenteditable="editMode"
tag="span"
data-testid="group-name"
/>
</div>
<GroupSortDropdown
:group="group.element"
:group-index="group.index"
:show-certificate-expiry="showCertificateExpiry"
@update-group="updateGroup"
</span>
<Editable
v-model="group.element.name"
:contenteditable="editMode"
tag="span"
:class="{ 'collapse-toggle': !editMode }"
data-testid="group-name"
@click="!editMode && toggleGroup(group.element)"
/>
</h2>
<div class="shadow-box monitor-list mt-4 position-relative">
<div v-if="group.element.monitorList.length === 0" class="text-center no-monitor-msg">
{{ $t("No Monitors") }}
</div>
<transition name="slide-fade-up">
<div v-if="!isGroupCollapsed(group.element)" class="shadow-box monitor-list mt-4 position-relative">
<div v-if="group.element.monitorList.length === 0" class="text-center no-monitor-msg">
{{ $t("No Monitors") }}
</div>
<!-- Monitor List -->
<!-- animation is not working, no idea why -->
<Draggable
v-model="group.element.monitorList"
class="monitor-list"
group="same-group"
:disabled="!editMode"
:animation="100"
item-key="id"
>
<template #item="monitor">
<div class="item" data-testid="monitor">
<div class="row">
<div class="col-9 col-xl-6 small-padding">
<div class="info">
<font-awesome-icon
v-if="editMode"
icon="arrows-alt-v"
class="action drag me-3"
/>
<font-awesome-icon
v-if="editMode"
icon="times"
class="action remove me-3"
@click="removeMonitor(group.index, monitor.index)"
/>
<font-awesome-icon
v-if="editMode"
icon="cog"
class="action me-3 ms-0"
:class="{ 'link-active': true, 'btn-link': true }"
data-testid="monitor-settings"
@click="$refs.monitorSettingDialog.show(group, monitor)"
/>
<Status
v-if="showOnlyLastHeartbeat"
:status="statusOfLastHeartbeat(monitor.element.id)"
/>
<Uptime v-else :monitor="monitor.element" type="24" :pill="true" />
<a
v-if="showLink(monitor)"
:href="monitor.element.url"
class="item-name"
target="_blank"
rel="noopener noreferrer"
data-testid="monitor-name"
>
{{ monitor.element.name }}
</a>
<p v-else class="item-name" data-testid="monitor-name">
{{ monitor.element.name }}
</p>
</div>
<div class="extra-info">
<div
v-if="showCertificateExpiry && monitor.element.certExpiryDaysRemaining"
>
<Tag
:item="{
name: $t('Cert Exp.'),
value: formattedCertExpiryMessage(monitor),
color: certExpiryColor(monitor),
}"
:size="'sm'"
<!-- Monitor List -->
<!-- animation is not working, no idea why -->
<Draggable
v-model="group.element.monitorList"
class="monitor-list"
group="same-group"
:disabled="!editMode"
:animation="100"
item-key="id"
>
<template #item="monitor">
<div class="item" data-testid="monitor">
<div class="row">
<div class="col-9 col-xl-6 small-padding">
<div class="info">
<font-awesome-icon
v-if="editMode"
icon="arrows-alt-v"
class="action drag me-3"
/>
<font-awesome-icon
v-if="editMode"
icon="times"
class="action remove me-3"
@click="removeMonitor(group.index, monitor.index)"
/>
<font-awesome-icon
v-if="editMode"
icon="cog"
class="action me-3 ms-0"
:class="{ 'link-active': true, 'btn-link': true }"
data-testid="monitor-settings"
@click="$refs.monitorSettingDialog.show(group, monitor)"
/>
<Status
v-if="showOnlyLastHeartbeat"
:status="statusOfLastHeartbeat(monitor.element.id)"
/>
<Uptime v-else :monitor="monitor.element" type="24" :pill="true" />
<a
v-if="showLink(monitor)"
:href="monitor.element.url"
class="item-name"
target="_blank"
rel="noopener noreferrer"
data-testid="monitor-name"
>
{{ monitor.element.name }}
</a>
<p v-else class="item-name" data-testid="monitor-name">
{{ monitor.element.name }}
</p>
</div>
<div v-if="showTags">
<Tag
v-for="tag in monitor.element.tags"
:key="tag"
:item="tag"
:size="'sm'"
data-testid="monitor-tag"
/>
<div class="extra-info">
<div
v-if="
showCertificateExpiry && monitor.element.certExpiryDaysRemaining
"
>
<Tag
:item="{
name: $t('Cert Exp.'),
value: formattedCertExpiryMessage(monitor),
color: certExpiryColor(monitor),
}"
:size="'sm'"
/>
</div>
<div v-if="showTags">
<Tag
v-for="tag in monitor.element.tags"
:key="tag"
:item="tag"
:size="'sm'"
data-testid="monitor-tag"
/>
</div>
</div>
</div>
</div>
<div :key="$root.userHeartbeatBar" class="col-3 col-xl-6">
<HeartbeatBar size="mid" :monitor-id="monitor.element.id" />
<div :key="$root.userHeartbeatBar" class="col-3 col-xl-6">
<HeartbeatBar size="mid" :monitor-id="monitor.element.id" />
</div>
</div>
</div>
</div>
</template>
</Draggable>
</div>
</template>
</Draggable>
</div>
</transition>
</div>
</template>
</Draggable>
@@ -137,7 +137,6 @@ import HeartbeatBar from "./HeartbeatBar.vue";
import Uptime from "./Uptime.vue";
import Tag from "./Tag.vue";
import Status from "./Status.vue";
import GroupSortDropdown from "./GroupSortDropdown.vue";
export default {
components: {
@@ -147,7 +146,6 @@ export default {
Uptime,
Tag,
Status,
GroupSortDropdown,
},
props: {
/** Are we in edit mode? */
@@ -176,13 +174,60 @@ export default {
return this.$root.publicGroupList.length >= 2;
},
},
watch: {
// No watchers needed - sorting is handled by GroupSortDropdown component
},
created() {
// Sorting is now handled by GroupSortDropdown component
},
methods: {
/**
* Toggle collapsed state for a group
* @param {object} group Group to toggle
* @returns {void}
*/
toggleGroup(group) {
if (!this.$router) {
return;
}
const groupId = this.getGroupIdentifier(group);
const collapsed = this.getCollapsedList();
const index = collapsed.indexOf(groupId);
if (index >= 0) {
collapsed.splice(index, 1);
} else {
collapsed.push(groupId);
}
const query = { ...this.$route.query };
if (collapsed.length > 0) {
query.collapse = collapsed;
} else {
delete query.collapse;
}
this.$router.push({ query }).catch(() => {});
},
/**
* Check if a group is collapsed
* @param {object} group Group to check
* @returns {boolean} Whether the group is collapsed
*/
isGroupCollapsed(group) {
return this.getCollapsedList().includes(this.getGroupIdentifier(group));
},
/**
* Get list of collapsed group identifiers from the query param.
* Vue Router normalises repeated params (?collapse=1&collapse=2) into an array.
* @returns {string[]} Collapsed group identifiers
*/
getCollapsedList() {
const raw = this.$route.query.collapse;
if (!raw) {
return [];
}
// Normalise to array: a single query param is a string, repeated params are already an array
return [].concat(raw);
},
/**
* Remove the specified group
* @param {number} index Index of group to remove
@@ -262,30 +307,16 @@ export default {
return "#DC2626";
},
/**
* Update group properties
* @param {number} groupIndex Index of group to update
* @param {object} updates Object with properties to update
* @returns {void}
*/
updateGroup(groupIndex, updates) {
Object.assign(this.$root.publicGroupList[groupIndex], updates);
},
/**
* Get unique identifier for a group
* @param {object} group object
* @returns {string} group identifier
*/
getGroupIdentifier(group) {
// Use the name directly if available
if (group.name) {
// Only remove spaces and use encodeURIComponent for URL safety
const cleanName = group.name.replace(/\s+/g, "");
return cleanName;
if (group.id !== undefined && group.id !== null) {
return group.id.toString();
}
// Fallback to ID or index
return group.id ? `group${group.id}` : `group${this.$root.publicGroupList.indexOf(group)}`;
return `group${this.$root.publicGroupList.indexOf(group)}`;
},
},
};
@@ -348,30 +379,31 @@ export default {
}
.group-title {
display: flex;
justify-content: space-between;
align-items: center;
.title-section {
display: flex;
align-items: center;
}
span {
display: inline-block;
min-width: 15px;
}
}
.collapse-toggle {
cursor: pointer;
padding: 2px;
}
.chevron {
font-size: 0.8em;
color: #bbb;
transition: all 0.2s $easing-in;
&.collapsed {
transform: rotate(-90deg);
}
}
.mobile {
.item {
padding: 13px 0 10px;
}
.group-title {
flex-direction: column;
align-items: flex-start;
}
}
.bg-maintenance {

View File

@@ -38,7 +38,7 @@
</div>
<div class="mb-3">
<label for="notificationService" class="form-label">{{ $t("Notification Service") }}</label>
<label for="notificationService" class="form-label">{{ $t("Notification Action") }}</label>
<input
id="notificationService"
v-model="$parent.notification.notificationService"
@@ -48,13 +48,7 @@
/>
<div class="form-text">
<p>
{{
$t(
'A list of Notification Services can be found in Home Assistant under "Developer Tools > Services" search for "notification" to find your device/phone name.'
)
}}
</p>
<p>{{ $t("homeAssistantNotificationActionHelptext") }}</p>
<p>{{ $t("Automations can optionally be triggered in Home Assistant:") }}</p>
<p>
{{ $t("Trigger type:") }}

View File

@@ -0,0 +1,82 @@
<template>
<div class="mb-3">
<label for="max-bot-token" class="form-label">{{ $t("Bot Token") }}</label>
<HiddenInput
id="max-bot-token"
v-model="$parent.notification.maxBotToken"
:required="true"
autocomplete="new-password"
></HiddenInput>
<i18n-t tag="div" keypath="wayToGetMaxToken" class="form-text">
<a href="https://dev.max.ru/docs" target="_blank">https://dev.max.ru/docs</a>
</i18n-t>
</div>
<div class="mb-3">
<label for="max-api-url" class="form-label">{{ $t("API URL") }}</label>
<input id="max-api-url" v-model="$parent.notification.maxApiUrl" type="text" class="form-control" required />
<div class="form-text">
{{ $t("maxApiUrlDescription") }}
</div>
</div>
<div class="mb-3">
<label for="max-chat-id" class="form-label">{{ $t("Chat ID") }}</label>
<input id="max-chat-id" v-model="$parent.notification.maxChatID" type="text" class="form-control" required />
<div class="form-text">
{{ $t("wayToGetMaxChatID") }}
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input v-model="$parent.notification.maxUseTemplate" class="form-check-input" type="checkbox" />
<label class="form-check-label">{{ $t("maxUseTemplate") }}</label>
</div>
<div class="form-text">
{{ $t("maxUseTemplateDescription") }}
</div>
</div>
<template v-if="$parent.notification.maxUseTemplate">
<div class="mb-3">
<label class="form-label" for="max-message-format">{{ $t("Message Format") }}</label>
<select
id="max-message-format"
v-model="$parent.notification.maxTemplateFormat"
class="form-select"
required
>
<option value="plain">{{ $t("Plain Text") }}</option>
<option value="markdown">Markdown</option>
<option value="html">HTML</option>
</select>
<p class="form-text">
{{ $t("maxTemplateFormatDescription") }}
</p>
<label class="form-label" for="max-message-template">{{ $t("Message Template") }}</label>
<TemplatedTextarea
id="max-message-template"
v-model="$parent.notification.maxTemplate"
:required="true"
></TemplatedTextarea>
</div>
</template>
</template>
<script>
import HiddenInput from "../HiddenInput.vue";
import TemplatedTextarea from "../TemplatedTextarea.vue";
export default {
components: {
HiddenInput,
TemplatedTextarea,
},
mounted() {
this.$parent.notification.maxApiUrl ||= "https://platform-api.max.ru";
this.$parent.notification.maxTemplateFormat ||= "plain";
},
};
</script>

View File

@@ -0,0 +1,57 @@
<template>
<div class="mb-3">
<label for="vk-access-token" class="form-label">{{ $t("Access Token") }}</label>
<HiddenInput
id="vk-access-token"
v-model="$parent.notification.vkAccessToken"
:required="true"
autocomplete="new-password"
></HiddenInput>
</div>
<div class="mb-3">
<label for="vk-api-version" class="form-label">{{ $t("API Version") }}</label>
<input
id="vk-api-version"
v-model="$parent.notification.vkApiVersion"
type="text"
class="form-control"
required
/>
<div class="form-text">
{{ $t("vkApiVersionDescription") }}
</div>
</div>
<div class="mb-3">
<label for="vk-peer-id" class="form-label">{{ $t("Peer ID") }}</label>
<input id="vk-peer-id" v-model="$parent.notification.vkPeerId" type="text" class="form-control" required />
<div class="form-text">
{{ $t("vkPeerIdDescription") }}
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input v-model="$parent.notification.vkDontParseLinks" class="form-check-input" type="checkbox" />
<label class="form-check-label">{{ $t("vkDontParseLinks") }}</label>
</div>
<div class="form-text">
{{ $t("vkDontParseLinksDescription") }}
</div>
</div>
</template>
<script>
import HiddenInput from "../HiddenInput.vue";
export default {
components: {
HiddenInput,
},
mounted() {
this.$parent.notification.vkApiVersion ||= "5.199";
this.$parent.notification.vkDontParseLinks ||= false;
},
};
</script>

View File

@@ -87,6 +87,8 @@ import SMSIR from "./SMSIR.vue";
import Webpush from "./Webpush.vue";
import HaloPSA from "./HaloPSA.vue";
import Resend from "./Resend.vue";
import Max from "./Max.vue";
import VK from "./VK.vue";
/**
* Manage all notification form.
@@ -182,6 +184,8 @@ const NotificationFormList = {
SMSPlanet: SMSPlanet,
Webpush: Webpush,
HaloPSA: HaloPSA,
max: Max,
VK: VK,
};
export default NotificationFormList;

View File

@@ -8,8 +8,6 @@ import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
// 2) add the icon name to the library.add() statement below.
import {
faArrowAltCircleUp,
faArrowDown,
faArrowUp,
faCog,
faEdit,
faExclamationTriangle,
@@ -59,8 +57,6 @@ import {
library.add(
faArrowAltCircleUp,
faArrowDown,
faArrowUp,
faCog,
faEdit,
faExclamationTriangle,

View File

@@ -1611,5 +1611,14 @@
"360messengerWayToGetUrlAndToken": "Можете да получите вашия API ключ за 360messenger от {0}.",
"360messengerWayToWriteRecipient": "Въведете един или повече телефонни номера в международен формат без водещ плюс (напр. {0}). Разделете отделните номера със запетая.",
"GlobalpingMultipleLocationsError": "Не се поддържат множество местоположения, моля, използвайте едно местоположение за всеки монитор.",
"GlobalpingLocationDescription": "Полето за местоположение приема континенти, държави, региони, градове, ASN, интернет доставчици или облачни региони. Можете да комбинирате филтри с {plus} (напр. {amazonPlusGermany} или {comcastPlusCalifornia}). Ако латентността е важен показател, използвайте филтри, за да стесните местоположението до малък регион, за да избегнете пикове, и за по-добра стабилност задайте филтъра {datacenter}. {fullDocs}."
"GlobalpingLocationDescription": "Полето за местоположение приема континенти, държави, региони, градове, ASN, интернет доставчици или облачни региони. Можете да комбинирате филтри с {plus} (напр. {amazonPlusGermany} или {comcastPlusCalifornia}). Ако латентността е важен показател, използвайте филтри, за да стесните местоположението до малък регион, за да избегнете пикове, и за по-добра стабилност задайте филтъра {datacenter}. {fullDocs}.",
"fluxerMessageFormat": "Формат на съобщението",
"fluxerMessageFormatNormal": "Нормално (с вграден rich)",
"fluxerMessageFormatCustom": "Персонализиран шаблон",
"fluxerUseMessageTemplate": "Използвай персонализиран шаблон за съобщение",
"fluxerMessageTemplate": "Шаблон за съобщение",
"Fluxer Webhook URL": "Fluxer URL адрес за уебкука",
"fluxerMessageFormatMinimalist": "Минималистичен (кратък статус)",
"fluxerUseMessageTemplateDescription": "Ако е активирано, съобщението ще бъде изпратено с помощта на персонализиран шаблон (LiquidJS). Оставете празно, за да използвате Uptime Kuma формат, който е по подразбиране.",
"wayToGetFluxerURL": "Можете да получите, като отидете в настройките на целевия канал > Уеб куки > Създаване на уеб кука > Копиране на URL адрес на уеб кука."
}

View File

@@ -6,6 +6,7 @@
"setupDatabaseSQLite": "A simple database file, recommended for small-scale deployments. Prior to v2.0.0, Uptime Kuma used SQLite as the default database.",
"settingUpDatabaseMSG": "Setting up the database. It may take a while, please be patient.",
"dbName": "Database Name",
"oracledbConnectionString": "Oracle Database: {connectionString}",
"enableSSL": "Enable SSL/TLS",
"mariadbUseSSLHelptext": "Enable to use a encrypted connection to your database. Required for most cloud databases.",
"mariadbCaCertificateLabel": "CA Certificate",
@@ -486,6 +487,8 @@
"Packet Size": "Packet Size",
"Bot Token": "Bot Token",
"wayToGetTelegramToken": "You can get a token from {0}.",
"wayToGetMaxToken": "You can get a MAX bot token and other details from {0}.",
"maxMessenger": "MAX messenger",
"Chat ID": "Chat ID",
"telegramMessageThreadID": "(Optional) Message Thread ID",
"telegramMessageThreadIDDescription": "Optional Unique identifier for the target message thread (topic) of the forum; for forum supergroups only",
@@ -500,6 +503,11 @@
"telegramTemplateFormatDescription": "Telegram allows using different markup languages for messages, see Telegram {0} for specifc details.",
"supportTelegramChatID": "Support Direct Chat / Group / Channel's Chat ID",
"wayToGetTelegramChatID": "You can get your chat ID by sending a message to the bot and going to this URL to view the chat_id:",
"maxApiUrlDescription": "Base API URL for MAX messenger. Default: https://platform-api.max.ru",
"wayToGetMaxChatID": "Specify the chat identifier in MAX where messages should be delivered.",
"maxUseTemplate": "Use custom message template",
"maxUseTemplateDescription": "If enabled, the message will be sent using a custom template.",
"maxTemplateFormatDescription": "MAX messenger supports plain text, Markdown and HTML formatting.",
"telegramServerUrl": "(Optional) Server Url",
"telegramServerUrlDescription": "To lift Telegram's bot api limitations or gain access in blocked areas (China, Iran, etc). For more information click {0}. Default: {1}",
"YOUR BOT TOKEN HERE": "YOUR BOT TOKEN HERE",
@@ -514,9 +522,9 @@
"Home Assistant URL": "Home Assistant URL",
"Long-Lived Access Token": "Long-Lived Access Token",
"Long-Lived Access Token can be created by clicking on your profile name (bottom left) and scrolling to the bottom then click Create Token. ": "Long-Lived Access Token can be created by clicking on your profile name (bottom left) and scrolling to the bottom then click Create Token.",
"Notification Service": "Notification Service",
"Notification Action": "Notification Action",
"default: notify all devices": "default: notify all devices",
"A list of Notification Services can be found in Home Assistant under \"Developer Tools > Services\" search for \"notification\" to find your device/phone name.": "A list of Notification Services can be found in Home Assistant under \"Developer Tools > Services\" search for \"notification\" to find your device/phone name.",
"homeAssistantNotificationActionHelptext": "A list of Notification Actions can be found in Home Assistant under \"Settings > Developer Tools > Actions\". Search for \"notify\" to find your actions. Enter only the part after \"notify.\", e.g. for the action \"notify.mobile_app_xyz\" enter \"mobile_app_xyz\". For built-in mobile notifications, look for \"Send a notification via mobile_app_xyz\" (not \"Send a notification\").",
"Automations can optionally be triggered in Home Assistant:": "Automations can optionally be triggered in Home Assistant:",
"Trigger type:": "Trigger type:",
"Event type:": "Event type:",
@@ -652,6 +660,7 @@
"Number": "Number",
"Recipients": "Recipients",
"Access Token": "Access Token",
"API Version": "API Version",
"Channel access token": "Channel access token",
"Channel access token (Long-lived)": "Channel access token (Long-lived)",
"Line Developers Console": "Line Developers Console",
@@ -710,6 +719,11 @@
"noMonitorsOrStatusPagesSelectedError": "Cannot create maintenance without affected monitors or status pages",
"passwordNotMatchMsg": "The repeat password does not match.",
"notificationDescription": "Notifications must be assigned to a monitor to function.",
"Peer ID": "Peer ID",
"vkApiVersionDescription": "VK API version used for requests. Leave the default unless you specifically need another VK API version for compatibility.",
"vkDontParseLinks": "Disable link snippets",
"vkDontParseLinksDescription": "If enabled, VK will not generate link previews/snippets.",
"vkPeerIdDescription": "Enter the target VK peer_id. This value is sent to the API as-is.",
"keywordDescription": "Search keyword in plain HTML or JSON response. The search is case-sensitive.",
"invertKeywordDescription": "Look for the keyword to be absent rather than present.",
"jsonQueryDescription": "Parse and extract specific data from the server's JSON response using JSON query or use \"$\" for the raw response, if not expecting JSON. The result is then compared to the expected value, as strings. See {0} for documentation and use {1} to experiment with queries.",

1
src/lang/en_GB.json Normal file
View File

@@ -0,0 +1 @@
{}

View File

@@ -155,7 +155,7 @@
"Skip existing": "Omitir existente",
"Overwrite": "Sobrescribir",
"Options": "Opciones",
"Keep both": "Manténer ambos",
"Keep both": "Mantener ambos",
"Tags": "Etiquetas",
"Add New below or Select...": "Agregar nuevo a continuación o seleccionar…",
"Tag with this name already exist.": "Una etiqueta con este nombre ya existe.",
@@ -510,7 +510,7 @@
"Octopush API Version": "Versión API Octopush",
"From Name/Number": "De Nombre/Número",
"Recipient Number": "Número de Destinatario",
"Long-Lived Access Token can be created by clicking on your profile name (bottom left) and scrolling to the bottom then click Create Token. ": "El token de acceso de larga duración se puede crear haciendo clic en el nombre de su perfil (abajo a la izquierda) y desplazándose hasta la parte inferior y luego haciendo clic en Crear token. ",
"Long-Lived Access Token can be created by clicking on your profile name (bottom left) and scrolling to the bottom then click Create Token. ": "El token de acceso de larga duración puede crearse haciendo clic en el nombre de su perfil (abajo a la izquierda), desplazándose hasta la parte inferior y luego haciendo clic en Crear token.",
"backupOutdatedWarning": "Obsoleto: dado que se agregaron muchas funciones y esta función de copia de seguridad no se mantiene desde hace un tiempo, no puede generar ni restaurar una copia de seguridad completa.",
"Optional": "Opcional",
"loadingError": "No se pueden obtener los datos, inténtelo de nuevo más tarde.",
@@ -597,7 +597,7 @@
"checkPrice": "Consultar {0} precios:",
"apiCredentials": "Credenciales de API",
"Check octopush prices": "Consulta los precios de octopush {0}.",
"octopushPhoneNumber": "Número de teléfono (en formato internacional, ejemplo: +33612345678) ",
"octopushPhoneNumber": "Número de teléfono (en formato internacional, ejemplo: +33612345678)",
"octopushSMSSender": "Nombre de Remitente del SMS: 3-11 caracteres alfanuméricos y espacio (a-zA-Z0-9)",
"LunaSea Device ID": "ID Dispositivo LunaSea",
"goAlert": "GoAlert",
@@ -649,7 +649,7 @@
"alertaEnvironment": "Entorno",
"PushDeer Key": "Key de PushDeer",
"onebotSafetyTips": "Por seguridad, deberías colocara el token de acceso",
"wayToGetClickSendSMSToken": "Puedes obtener Nombre de Usuario de la API y la llave {aquí}.",
"wayToGetClickSendSMSToken": "Puedes obtener Usuario de API y llave de API {aquí}.",
"Apprise URL": "URL Apprise",
"gorush": "Gorush",
"squadcast": "Squadcast",
@@ -680,7 +680,7 @@
"smseagleGroup": "Nombre(s) de grupo(s) de Guía Telefónica",
"Unpin": "Dejar de Fijar",
"Prefix Custom Message": "Prefijo personalizado",
"markdownSupported": "Sintaxis de Markdown soportada",
"markdownSupported": "Sintaxis de Markdown soportada. Si estas usando HTML, evita espacios al principio para prevenir problemas de formato.",
"Server Address": "Dirección del Servidor",
"Learn More": "Aprende Más",
"Pick a RR-Type...": "Seleccione un Tipo RR…",
@@ -846,7 +846,7 @@
"toastSuccessTimeout": "Tiempo de espera para notificaciones de éxito",
"toastErrorTimeout": "Tiempo de espera para notificaciones de error",
"setupDatabaseChooseDatabase": "¿Qué base de datos te gustaría usar?",
"setupDatabaseEmbeddedMariaDB": "No necesitas configurar nada. Esta imagen de Docker tiene incorporada y configurada MariaDB automáticamente para ti. Uptime Kuma se conectará a esta base de datos a través de un socket Unix.",
"setupDatabaseEmbeddedMariaDB": "No necesitas configurar nada. Esta imagen de Docker tiene incorporado y configurado MariaDB para ti automáticamente. Uptime Kuma se conectará a esta base de datos a través de un socket Unix.",
"setupDatabaseMariaDB": "Conectarse a una base de datos MariaDB externa. Debe configurar la información de conexión a la base de datos.",
"setupDatabaseSQLite": "Un archivo de base de datos simple, recomendado para despliegues a pequeña escala. Antes de la versión 2.0.0, Uptime Kuma utilizaba SQLite como base de datos predeterminada.",
"dbName": "Nombre de la Base de Datos",
@@ -854,7 +854,7 @@
"authIncorrectCreds": "Nombre de usuario o contraseña incorrectos.",
"2faEnabled": "2FA habilitado.",
"2faDisabled": "2FA deshabilitado.",
"liquidIntroduction": "La plantilla se logra a través del lenguaje de plantillas Liquid. Consulte {0} para obtener instrucciones de uso. Estas son las variables disponibles:",
"liquidIntroduction": "El plantillaje se logra a través del lenguaje de plantillas Liquid. Consulte {0} para obtener instrucciones de uso.",
"templateLimitedToUpDownCertNotifications": "solo disponible para notificaciones FUNCIONAL/CAÍDO/Caducidad de certificado",
"emailTemplateMsg": "mensaje de la notificación",
"emailTemplateLimitedToUpDownNotification": "sólo disponible para latidos FUNCIONAL/CAÍDO, de lo contrario nulo",
@@ -939,7 +939,7 @@
"threemaSenderIdentity": "ID de Gateway",
"threemaSenderIdentityFormat": "8 caracteres, generalmente comienza con *",
"Host URL": "URL del anfitrión",
"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": "Ingresa el nombre del host del servidor al que deseas conectarte, o {localhost} si deseas usar un {local_mta}",
"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": "Ingresa el nombre del host del servidor al que deseas conectarte o {localhost} si deseas usar un {local_mta}",
"smspartnerPhoneNumberHelptext": "El número debe estar en el formato internacional {0}, {1}. Múltiples números deben estar separados por {2}",
"smspartnerSenderName": "Nombre del emisor del SMS",
"smspartnerApiurl": "Puedes encontrar tu clave API en tu panel de control en {0}",
@@ -1213,19 +1213,19 @@
"Number of retry attempts if webhook fails": "Número de intentos de reintento (cada 60180 segundos) si el webhook falla.",
"Maximum Retries": "Máximo de reintentos",
"sipsakPingWarning": "Para poder utilizar el monitor de SIP Options Ping, necesitas instalar Uptime Kuma sin Docker e instalar el cliente Sipsak en tu servidor.",
"Plausible": "Plausible",
"Plausible": "Admisible",
"Select All": "Seleccionar todo",
"Deselect All": "Desmarcar todo",
"HTTP Method": "Método HTTP",
"webhookPostMethodDesc": "POST es aceptado para la mayoría de servidores HTTP.",
"webhookPostMethodDesc": "POST es adecuado para la mayoría de servidores HTTP.",
"resendApiKey": "Reenviar la llave API",
"deleteGroupMsg": "¿Estás seguro de querer eliminar este grupo?",
"deleteGroupMsg": "¿Está seguro que quiere eliminar este grupo?",
"settingsDomainExpiry": "Expiración de dominio",
"labelDomainExpiry": "Exp. de dominio",
"message": "mensaje",
"domainExpiryDescription": "Lanzar notificación cuando el nombre de dominio expire en:",
"year": "año | años",
"descriptionHelpText": "Se muestra en el panel principal interno. El código Markdown está permitido y se senea (preserva los espacios y las identaciones) antes de mostrarse.",
"descriptionHelpText": "Mostrar en el panel de control interno. Se permite markdown limpio (conserva el espacio y la sangría) antes de mostrarse.",
"json_value": "Valor JSON",
"Press Enter to add node": "Pulsa Enter para añadir el nodo",
"resendApiHelp": "Crear una llave de API aquí {0}",
@@ -1235,7 +1235,7 @@
"wsCodeDescription": "Para más información acerca de los códigos de estado, por favor consulta {rfc6455}",
"Subprotocol(s)": "Subprotocolo(s)",
"certHostnameMismatch": "El nombre de host del certificado no coincide con la URL del monitor.",
"twilioMessagingServiceSID": "Servicio de mensajería SID (opcional)",
"twilioMessagingServiceSID": "SID del servicio de Mensajería (opcional)",
"resendFromEmail": "Correo electrónico del remitente",
"resendLeaveBlankForDefaultSubject": "Deja en blanco para utilizar el asunto por defecto",
"ignoreSecWebsocketAcceptHeaderDescription": "En caso de que la actualización del websocket sea satisfactoria, permite al servidor no responder con la cabecera Sec-WebSocket-Accept.",
@@ -1261,7 +1261,7 @@
"systemServiceDescriptionWindows": "Comprueba si el gestor de servicios {service_name} de Windows está ejecutándose",
"invalidURL": "URL no válida",
"Clone Maintenance": "Clonar Mantenimiento",
"ariaPauseMaintenance": "Pausar este horario de mantenimiento",
"ariaPauseMaintenance": "Pausar este cronograma de mantenimiento",
"systemServiceName": "Nombre del servicio",
"systemService": "Servicio del sistema",
"systemServiceCommandHint": "Comando utilizado: {command}",
@@ -1276,15 +1276,15 @@
"Browser not supported": "Navegador no permitido",
"labelDomainNameExpiryNotification": "Notificación de expiración de dominio",
"Duration (Minutes)": "Duración (Minutos)",
"ariaResumeMaintenance": "Reanudar este horario de mantenimiento",
"ariaCloneMaintenance": "Crear una copia de este horario de mantenimiento",
"ariaEditMaintenance": "Editar este horario de mantenimiento",
"ariaDeleteMaintenance": "Eliminar este horario de mantenimiento",
"ariaResumeMaintenance": "Reanudar este cronograma de mantenimiento",
"ariaCloneMaintenance": "Crear una copia de este cronograma de mantenimiento",
"ariaEditMaintenance": "Editar este cronograma de mantenimiento",
"ariaDeleteMaintenance": "Eliminar este cronograma de mantenimiento",
"SMTP Security": "Seguridad SMTP",
"Ignore STARTTLS": "Ignorar STARTTLS",
"Use STARTTLS": "Utilizar STARTTLS",
"twilloMessagingServiceSIDHelptext": "Introduce el SID de tu servicio de mensajería si utilizas {twillo_messaging_service_help_link} para genstionar los remitentes y las características",
"webhookGetMethodDesc": "GET envía los datos como parámetros de la búsqueda y no permite configurar un cuerpo de mensaje. Útil para disparar los monitores Push de Uptime Kuma.",
"twilloMessagingServiceSIDHelptext": "Ingrese el SID del Servicio de Mensajería aquí si está usando {twillo_messaging_service_help_link} para administrar los remitentes y características",
"webhookGetMethodDesc": "GET envía los datos como parámetros de consulta y no permite la configuración del cuerpo de la consulta. Útil para disparar monitores PUSH de Uptime Kuma.",
"showOnlyLastHeartbeat": "Mostrar solo el último latido",
"Analytics Type": "Tipo de analíticas",
"Google": "Google",
@@ -1298,7 +1298,7 @@
"checkPriceAt": "Comprueba los precios de {service} en {url}",
"noMonitorsOrStatusPagesSelectedError": "No se puede crear un mantenimiento sin monitores afectados o páginas de estado",
"noMonitorsSelectedWarning": "Estás creando un mantenimiento sin ningún monitor afectado. ¿Estás seguro de que deseas continuar?",
"deleteChildrenMonitors": "Borra también los sub-monitores y sus descendientes si los tuvieran|Borra también todos los {count} sub-monitores directos y sus descendientes si los tuvieran",
"deleteChildrenMonitors": "Tambien elimina los monitores hijos directos y sus descendientes si los tuvieran | Tambien elimina todos los {count} monitores hijos directos y sus descendientes si los tuvieran",
"OptionalParameters": "Parámetros Opcionales",
"aliyun-template-requirements-and-parameters": "La plantilla de SMS de aliyun debe de contener los siguientes parámetros: {parameters}",
"aliyun-template-optional-parameters": "Parámetros opcionales: {parameters}",
@@ -1310,7 +1310,7 @@
"enableSSL": "Habilitar SSL/TLS",
"mariadbCaCertificateLabel": "Certificado CA",
"unknownDays": "Días desconocidos",
"No incidents recorded": "No se registraron incidentes.",
"No incidents recorded": "No se registraron incidentes",
"Load More": "Cargar más",
"mariadbUseSSLHelptext": "Habilita el uso de una conexión cifrada a tu base de datos. Requerido para la mayoría de las bases de datos en la nube.",
"mariadbCaCertificateHelptext": "Pegue el certificado CA en formato PEM para utilizarlo con certificados autofirmados. Déjelo en blanco si su base de datos utiliza un certificado firmado por una CA pública.",
@@ -1329,5 +1329,48 @@
"Only retry if status code check fails": "Reintentar solo si la comprobación del código de estado falla",
"retryOnlyOnStatusCodeFailureDescription": "Si está habilitado, los reintentos solo se realizarán cuando falle la comprobación del código de estado HTTP (por ejemplo, si el servidor está caído). Si la comprobación del código de estado es correcta pero falla la consulta JSON, el monitor se marcará como inactivo inmediatamente, sin reintentos.",
"responseMaxLengthDescription": "Tamaño máximo de los datos de la respuesta que se van a almacenar. Establezca 0 para ilimitado. Las respuestas más grandes se truncarán. Valor por defecto: 1024 (1KB)",
"logoutCurrentUser": "Cerrar sesión de {username}"
"logoutCurrentUser": "Cerrar sesión de {username}",
"Incident description": "Descripción del incidente",
"twilioApiKeyHelptext": "La llave de la API es opcional pero recomendada. Puede proporcionar el SID de la cuenta y el Token de Autorizacion desde la consola de Twilio o el SID de la cuenta y la llave de la API junto con el secreto de la llave de la API",
"monitorTypeGameServer": "Servidor de Juego",
"monitorTypeDatabase": "Tipo de Monitor de Base de Datos",
"monitorTypeSpecial": "Especial",
"Recipient Numbers": "Números de destinatarios",
"Incident not found or access denied": "No se encontró incidente o acceso denegado",
"Past Incidents": "Incidentes pasados",
"Incident title": "Título del incidente",
"example": "Ejemplo",
"Result": "Resultado",
"lastUpdatedAt": "Última actualización: {date}",
"Actions": "Acciones",
"selectAllMonitorsAria": "Seleccionar todos los monitores",
"deselectAllMonitorsAria": "Deseleccionar todos los monitores",
"lastUpdatedAtFromNow": "Última actualización: {date} ({fromNow})",
"See Jira Cloud Docs": "Ver la documentación de Jura Cloud",
"Cloud ID": "ID de nube",
"API Token": "Token de API",
"templateAvailableVariables": "Variables disponibles",
"selectMonitorMsg": "Selecciona monitores para realizar acciones",
"Examples:": "Ejemplos: {0}",
"Pinned incidents are shown prominently on the status page": "Incidentes marcados se muestran prominentemente en la pagina de estado",
"Edit Incident": "Editar incidente",
"Please input title": "Por favor ingresa título",
"Resolve": "Resolver",
"Resolved": "Resuelto",
"createdAt": "Creado: {date}",
"deleteIncidentMsg": "Estas seguro que quieres eliminar este incidente?",
"Certificate Chain:": "Cadena de certificado:",
"Please input content": "Por favor ingresa contenido",
"dateCreatedAtFromNow": "Fecha de creación: {date} ({fromNow})",
"360messengerAuthToken": "Clave de la API 360messenger",
"360messengerGroupId": "ID de grupo 360messenger",
"360messengerGroupList": "Grupos de WhatsApp",
"ntfyUseTemplateDescription": "Habilite esta opción para personalizar los títulos y mensajes de notificación mediante plantillas LiquidJS",
"ntfyCustomTitle": "Plantilla de título personalizado",
"ntfyCustomMessage": "Plantilla de mensaje personalizado",
"ntfyNotificationTemplateFallback": "Dejar en blanco para utilizar el formato predeterminado de Uptime Kuma",
"Screenshot Delay": "Retraso de captura de pantalla (espera {milliseconds})",
"milliseconds": "{n} milisegundos | {n} milisegundos",
"snmpV3Username": "Nombre de usuario SNMPv3",
"ntfyUseTemplate": "Personalizar plantillas de notificación"
}

View File

@@ -1611,5 +1611,14 @@
"360messengerWayToWriteRecipient": "Saisissez un ou plusieurs numéros de téléphone au format international sans signe plus (par exemple : {0}). Séparez les numéros par des virgules.",
"monitorTypeSpecial": "Spécial",
"monitorTypeGameServer": "Serveur de jeu",
"monitorTypeDatabase": "Sonde de Type base de données"
"monitorTypeDatabase": "Sonde de Type base de données",
"fluxerMessageFormatNormal": "Normal (intégrations riches)",
"fluxerUseMessageTemplate": "Utiliser un modèle de message personnalisé",
"Fluxer Webhook URL": "URL du Webhook de Fluxer",
"wayToGetFluxerURL": "Vous pouvez obtenir cela en allant dans les paramètres du canal cible > Webhooks > Créer un webhook > Copier lURL du webhook.",
"fluxerMessageFormat": "Format du message",
"fluxerMessageFormatCustom": "Modèle personnalisé",
"fluxerMessageTemplate": "Modèle de message",
"fluxerMessageFormatMinimalist": "Minimalist (statut court",
"fluxerUseMessageTemplateDescription": "Si activé, le message sera envoyé en utilisant un modèle personnalisé (LiquidJS). Laissez vide pour utiliser le format Uptime Kuma par défaut."
}

View File

@@ -1377,7 +1377,7 @@
"TLS Alert Spec": "RFC 8446",
"Suppress Notifications": "Fógraí a Chosc",
"discordSuppressNotificationsHelptext": "Nuair a bheidh sé cumasaithe, cuirfear teachtaireachtaí chuig an gcainéal ach ní spreagfar fógraí brú ná fógraí deisce do fhaighteoirí.",
"domain_expiry_unsupported_is_icann": "Ní iarrthóir é an fearann \"{domain}\" le haghaidh monatóireachta ar dhul in éag fearainn, toisc nach bhfuil a iarmhír phoiblí \".{publicSuffix}\" ICAN",
"domain_expiry_unsupported_is_icann": "Ní iarrthóir é an fearann \"{domain}\" le haghaidh monatóireachta ar dhul in éag fearainn, toisc nach bhfuil a iarmhír phoiblí \".{publicSuffix}\" á bhainistiú ag ICANN",
"notificationUniversal": "Uilíoch",
"notificationChatPlatforms": "Ardáin Comhrá",
"notificationPushServices": "Seirbhísí Brúigh",
@@ -1540,5 +1540,30 @@
"GlobalpingIpFamilyInfo": "An leagan IP le húsáid. Ní cheadaítear é seo ach amháin má tá an sprioc ina hainm óstach.",
"GlobalpingResolverInfo": "Seoladh IPv4/IPv6 nó Ainm Fearainn Cáilithe go Lán (FQDN). Is é an réamhshocrú ná réiteoir líonra áitiúil an tóireadóra. Is féidir leat an freastalaí réiteora a athrú am ar bith.",
"Jira Service Management": "Bainistíocht Seirbhíse Jira",
"Google Apps Script Webhook URL": "URL Gréasáin-chrúca Script Google Apps"
"Google Apps Script Webhook URL": "URL Gréasáin-chrúca Script Google Apps",
"360messengerEnableSendToGroup": "Cumasaigh seoltaí chuig grúpa(í) WhatsApp",
"360messengerAuthToken": "Eochair API 360messenger",
"360messengerRecipient": "Uimhir(í) theileafóin an fhaighteora",
"360messengerGroupId": "Aitheantas Grúpa 360messenger",
"360messengerUseTemplate": "Úsáid teimpléad teachtaireachta saincheaptha",
"360messengerTemplate": "Teimpléad Teachtaireachta 360messenger",
"360messengerGroupList": "Grúpaí WhatsApp",
"360messengerSelectGroupList": "Roghnaigh grúpa le cur leis",
"360messengerSelectedGroupID": "Aitheantais Ghrúpa Roghnaithe",
"360messengerCustomMessageTemplate": "Teimpléad teachtaireachta saincheaptha",
"360messengerEnableCustomMessage": "Cumasaigh teimpléad teachtaireachta saincheaptha in ionad an teachtaireachta réamhshocraithe.",
"360messengerMessageTemplate": "Teimpléad teachtaireachta",
"360messengerWayToGetUrlAndToken": "Is féidir leat d'eochair API 360messenger a fháil ó {0}.",
"360messengerErrorNoApiKey": "Cuir isteach deochair API 360messenger ar dtús.",
"360messengerErrorNoGroups": "Ní bhfuarthas aon ghrúpaí WhatsApp don chuntas seo.",
"360messengerErrorApi": "Ní féidir liosta na ngrúpaí WhatsApp a luchtú (Earráid {statusCode}: {message}).",
"360messengerErrorGeneric": "Ní féidir an liosta grúpa WhatsApp a luchtú: {message}",
"GlobalpingLocationDescription": "Glacann an réimse suímh le hilchríocha, tíortha, réigiúin, cathracha, ASNanna, ISPanna, nó réigiúin scamall. Is féidir leat scagairí a chomhcheangal le {plus} (m.sh. {amazonPlusGermany} nó {comcastPlusCalifornia}). Más méadracht thábhachtach í an mhoill, bain úsáid as scagairí chun an suíomh a chúngú síos go réigiún beag chun spící a sheachaint agus socraigh an scagaire {datacenter} ar mhaithe le cobhsaíocht níos fearr. {fullDocs}.",
"GlobalpingMultipleLocationsError": "Ní thacaítear le hilshuíomhanna, bain úsáid as suíomh amháin do gach monatóir le do thoil.",
"360messengerWayToWriteRecipient": "Cuir isteach uimhir theileafóin amháin nó níos mó i bhformáid idirnáisiúnta gan móide tosaigh (m.sh. {0}). Scar uimhreacha iolracha le camóga.",
"signalUseTemplate": "Úsáid teimpléad teachtaireachta saincheaptha",
"signalUseTemplateDescription": "Má tá sé cumasaithe, seolfar an teachtaireacht ag baint úsáide as teimpléad saincheaptha. Is féidir leat teimpléadú Liquid a úsáid chun formáid an fhógra a shaincheapadh.",
"monitorTypeGameServer": "Freastalaí Cluiche",
"monitorTypeDatabase": "Cineál Monatóra Bunachar Sonraí",
"monitorTypeSpecial": "Speisialta"
}

View File

@@ -1275,5 +1275,8 @@
"Ignore STARTTLS": "Ignora STARTTLS",
"Use STARTTLS": "Utilizza STARTTLS",
"Enter the list of nodes": "Inserisci l'elenco dei nodi di gestione RabbitMQ",
"Press Enter to add node": "Premi Invio per aggiungere il nodo"
"Press Enter to add node": "Premi Invio per aggiungere il nodo",
"enableSSL": "Abilita SSL/TLS",
"mariadbUseSSLHelptext": "Abilita per usare una connessione criptata per il tuo database. Richiesto dalla maggior parte dei database cloud.",
"mariadbCaCertificateLabel": "Certificato CA"
}

View File

@@ -3,5 +3,72 @@
"setupDatabaseChooseDatabase": "Kuru datubāzi izmantosiet?",
"setupDatabaseEmbeddedMariaDB": "Jums nav nekas jādara. Docker imidžā ir iebūvēta un automātiski konfigurēta MariaDB datubāze. Uptime Kuma pieslēgsies šai datubāzei izmantojot unix soketu.",
"setupDatabaseSQLite": "Vienkāršs datu bāzes fails, iesakāms maza izmēra risinājumiem. Pirms versijas v2.0.0 SQLite bija noklusējuma datubāze.",
"setupDatabaseMariaDB": "Pieslēgties ārējai MariaDB datubāzei. Jums būs jākonfigurē datubāzes pieslēgšanās informācija."
"setupDatabaseMariaDB": "Pieslēgties ārējai MariaDB datubāzei. Jums būs jākonfigurē datubāzes pieslēgšanās informācija.",
"Name": "Nosaukums",
"Ping": "Ping",
"Dashboard": "Panelis",
"dbName": "Datubāzes nosaukums",
"enableSSL": "Iespējot SSL/TLS",
"Settings": "Iestatījumi",
"Help": "Palīdzība",
"New Update": "Jauns atjauninājums",
"Language": "Valoda",
"Appearance": "Izskats",
"Theme": "Tēma",
"General": "Vispārīgi",
"Game": "Spēle",
"mariadbCaCertificateLabel": "CA sertifikāts",
"Primary Base URL": "Galvenais bāzes URL",
"Check Update On GitHub": "Pārbaudīt atjauninājumu GitHub",
"List": "Saraksts",
"Home": "Sākums",
"Add": "Pievienot",
"Add New Monitor": "Pievienot jaunu monitoru",
"Quick Stats": "Ātrā statistika",
"Down": "Nedarbojas",
"Pending": "Rindā",
"statusMaintenance": "Tehniskā apkope",
"Maintenance": "Tehniskā apkope",
"Unknown": "Nezināms",
"unknownDays": "Nezināms dienu skaits",
"Cannot connect to the socket server": "Nevar izveidot savienojumu ar soketa serveri",
"Reconnecting...": "Savienojas...",
"General Monitor Type": "Vispārīgais monitora veids",
"pauseDashboardHome": "Pauze",
"Pause": "Pauze",
"DateTime": "Datums un laiks",
"Specific Monitor Type": "Specifiskais monitora veids",
"settingUpDatabaseMSG": "Tiek uzstādīta datubāze. Uzgaidiet, lūdzu, tas var prasīt laiku.",
"mariadbUseSSLHelptext": "Iespējot šifrētu savienojumu ar jūsu datubāzi. Nepieciešams lielākajai daļai mākoņdatubāžu.",
"mariadbCaCertificateHelptext": "Ielīmējiet CA sertifikātu PEM formātā, lai to izmantotu ar pašparakstītiem sertifikātiem. Atstājiet tukšu, ja jūsu datubāze izmanto publiskas sertifikācijas iestādes parakstītu sertifikātu.",
"Passive Monitor Type": "Pasīvais monitora veids",
"markdownSupported": "Tiek atbalstīta Markdown sintakse. Ja izmantojat HTML, izvairieties no atstarpēm rindas sākumā, lai novērstu formatēšanas problēmas.",
"versionIs": "Versija: {version}",
"monitorTypeGameServer": "Spēļu serveris",
"monitorTypeDatabase": "Datubāzes monitora veids",
"monitorTypeSpecial": "Specifisks",
"Message": "Ziņa",
"No incidents recorded": "Nav reģistrētu incidentu",
"Load More": "Ielādēt vairāk",
"Loading...": "Ielāde...",
"No important events": "Nav svarīgu notikumu",
"Resume": "Turpināt",
"Edit": "Labot",
"Delete": "Dzēst",
"Current": "Pašreizējais",
"Uptime": "Darbības laiks",
"Cert Exp.": "Sert. term.",
"Monitors": "{n} monitors | {n} monitori",
"now": "tagad",
"time ago": "pirms {0}",
"days": "{n} diena | {n} dienas",
"hours": "{n} stunda | {n} stundas",
"minutes": "{n} minūte | {n} minūtes",
"minuteShort": "{n} minūte | {n} minūtes",
"years": "{n} gads| {n} gadi",
"Response": "Atbilde",
"Pin this incident": "Piespraust šo incidentu",
"Monitor Type": "Monitora tips",
"Up": "Darbojas",
"Status": "Status"
}

View File

@@ -994,7 +994,7 @@
"and": "en",
"snmpCommunityStringHelptext": "Deze string fungeert als een wachtwoord om toegang tot SNMP-apparaten te verifiëren en te beheren. Match het met de configuratie van uw SNMP-apparaat.",
"groupOnesenderDesc": "Zorg ervoor dat de GroupID juist is. Om een bericht naar een groep te sturen, bijvoorbeeld: 628123456789-342345",
"privateOnesenderDesc": "Zorg ervoor dat het telefoonnummer juist is. Om een bericht te sturen naar een privenummer, bijvoorbeeld: 628123456789",
"privateOnesenderDesc": "Zorg ervoor dat het telefoonnummer juist is. Om een bericht te sturen naar een privénummer, bijvoorbeeld: 628123456789",
"now": "nu",
"time ago": "{0} geleden",
"-year": "-jaar",
@@ -1007,7 +1007,7 @@
"Host Onesender": "Host Onesender",
"Token Onesender": "Token Onesender",
"Recipient Type": "Ontvanger Type",
"Private Number": "Privenummer",
"Private Number": "Privénummer",
"Group ID": "Groep ID",
"wayToGetOnesenderUrlandToken": "U kunt de URL en Token krijgen door naar de Onesender website te gaan. Meer informatie {0}",
"Add Remote Browser": "Externe browser toevoegen",
@@ -1551,5 +1551,27 @@
"expectedTlsAlertDescription": "Selecteer de TLS-waarschuwing waarvan u verwacht dat de server deze retourneert. Gebruik {code} om te verifiëren dat mTLS-eindpunten verbindingen weigeren zonder clientcertificaten. Zie {link} voor details.",
"Protocol": "Protocol",
"domain_expiry_unsupported_missing_target": "Geen geldige domeinnaam of hostnaam is ingesteld voor deze monitor",
"Endpoint": "Endpoint"
"Endpoint": "Endpoint",
"signalUseTemplate": "Gebruik een persoonlijk berichtensjabloon",
"signalUseTemplateDescription": "Indien ingeschakeld, wordt het bericht verzonden met een persoonlijk sjabloon. U kunt Liquid-templating gebruiken om het meldingsformaat aan te passen.",
"360messengerAuthToken": "360messenger API Sleutel",
"360messengerRecipient": "Telefoonnummer (s) van de ontvanger",
"360messengerGroupId": "360messenger Groep ID",
"360messengerUseTemplate": "Gebruik een persoonlijk berichtensjabloon",
"360messengerTemplate": "360messenger berichtensjabloon",
"360messengerGroupList": "WhatsApp groepen",
"360messengerSelectGroupList": "Selecteer een groep om toe te voegen",
"360messengerSelectedGroupID": "Geselecteerde Groep ID(s)",
"360messengerEnableSendToGroup": "Schakel versturen naar WhatsApp groep(en) in",
"360messengerCustomMessageTemplate": "Persoonlijk berichtensjabloon",
"360messengerEnableCustomMessage": "Schakel een persoonlijk berichtensjabloon in i.p.v. het standaardbericht.",
"360messengerMessageTemplate": "Berichtensjabloon",
"teamsEnableTags": "Inclusief tags",
"teamsEnableTagsDescription": "Indien ingeschakeld zal het bericht de monitortags bevatten.",
"certificateExpiryNotificationHelp": "De hoeveelheid dagen op voorhand kan in de instellingen worden geconfigureerd.",
"matrixUseTemplate": "Gebruik een persoonlijk berichtensjabloon",
"matrixUseTemplateDescription": "Indien ingeschakeld, wordt het bericht verzonden met een persoonlijk sjabloon.",
"monitorTypeGameServer": "Spelserver",
"monitorTypeDatabase": "Databank Monitor Type",
"monitorTypeSpecial": "Speciaal"
}

View File

@@ -1578,5 +1578,14 @@
"360messengerWayToWriteRecipient": "Insira um ou mais números de telefone no formato internacional, sem o sinal de mais inicial (por exemplo, {0}). Separe vários números com vírgulas.",
"360messengerErrorApi": "Não foi possível carregar a lista de grupos do WhatsApp (Erro {statusCode}: {message}).",
"GlobalpingMultipleLocationsError": "Não é possível realizar várias localizações; utilize uma única localização para cada monitor.",
"GlobalpingLocationDescription": "O campo de localização aceita continentes, países, regiões, cidades, ASNs, ISPs ou regiões de nuvem. Você pode combinar filtros com {plus} (por exemplo, {amazonPlusGermany} ou {comcastPlusCalifornia}). Se a latência for uma métrica importante, use filtros para restringir a localização a uma região pequena para evitar picos e, para maior estabilidade, defina o filtro {datacenter}. {fullDocs}."
"GlobalpingLocationDescription": "O campo de localização aceita continentes, países, regiões, cidades, ASNs, ISPs ou regiões de nuvem. Você pode combinar filtros com {plus} (por exemplo, {amazonPlusGermany} ou {comcastPlusCalifornia}). Se a latência for uma métrica importante, use filtros para restringir a localização a uma região pequena para evitar picos e, para maior estabilidade, defina o filtro {datacenter}. {fullDocs}.",
"fluxerMessageFormat": "Formato da mensagem",
"fluxerMessageFormatNormal": "Normal (rich embeds)",
"fluxerMessageFormatMinimalist": "Minimalista (status curto)",
"fluxerUseMessageTemplate": "Use um modelo de mensagem personalizado",
"fluxerMessageTemplate": "Modelo de mensagem",
"fluxerMessageFormatCustom": "Modelo personalizado",
"fluxerUseMessageTemplateDescription": "Se ativada, a mensagem será enviada usando um modelo personalizado (LiquidJS). Deixe em branco para usar o formato padrão do Uptime Kuma.",
"Fluxer Webhook URL": "URL do Webhook do Fluxer",
"wayToGetFluxerURL": "Você pode obter essa informação acessando as configurações do canal de destino > Webhooks > Criar Webhook > Copiar URL do Webhook."
}

View File

@@ -1431,7 +1431,7 @@
"legacyOctopushEndpoint": "Staršia verzia Octopush-DM (koncový bod: {url})",
"Suppress Notifications": "Stlmiť oznámenia",
"discordSuppressNotificationsHelptext": "Ak je táto funkcia povolená, správy budú odosielané do kanála, ale nebudú spúšťať push alebo desktopové notifikácie pre príjemcov.",
"domain_expiry_unsupported_is_icann": "Doména „{domain}“ nie je kandidátom na monitorovanie vypršania platnosti domény, pretože jej verejná prípona „.{publicSuffix}“ nie je ICAN",
"domain_expiry_unsupported_is_icann": "Doména „{domain}“ nie je kandidátom na monitorovanie vypršania platnosti domény, pretože jej verejná prípona „.{publicSuffix}“ nie je spravovaná organizáciou ICANN",
"snmpV3Username": "Používateľské meno SNMPv3",
"WeCom Mentioned Mobile List Description": "Zadajte telefónne čísla, ktoré chcete označiť. Viac čísel oddeľte čiarkami. Použite {'@'}all, aby ste označili všetkých.",
"WeCom Mentioned Mobile List": "WeCom zoznam zmienených",
@@ -1546,5 +1546,28 @@
"certificateExpiryNotificationHelp": "Počet dní vopred je možné nastaviť v nastaveniach.",
"signalUseTemplate": "Použite vlastnú šablónu správy",
"signalUseTemplateDescription": "Ak je táto funkcia povolená, správa bude odoslaná pomocou vlastnej šablóny. Na prispôsobenie formátu oznámenia môžete použiť šablóny Liquid.",
"domainExpiryNotificationHelp": "Počet dní vopred je možné nastaviť v nastaveniach."
"domainExpiryNotificationHelp": "Počet dní vopred je možné nastaviť v nastaveniach.",
"monitorTypeDatabase": "Typ monitoru databázy",
"monitorTypeGameServer": "Herný server",
"monitorTypeSpecial": "Špeciálny",
"360messengerAuthToken": "API kľúč 360messenger",
"360messengerRecipient": "Telefónne číslo/a príjemcu",
"360messengerGroupId": "360messenger ID skupiny",
"360messengerUseTemplate": "Použite vlastnú šablónu správy",
"360messengerTemplate": "Šablóna správy 360messenger",
"360messengerGroupList": "Skupiny WhatsApp",
"360messengerSelectGroupList": "Vyberte skupinu, ktorú chcete pridať",
"360messengerSelectedGroupID": "Vybrané ID skupiny/ín",
"360messengerEnableSendToGroup": "Povoliť odosielanie do skupín WhatsApp",
"360messengerCustomMessageTemplate": "Šablóna vlastnej správy",
"360messengerEnableCustomMessage": "Povoliť vlastnú šablónu správy namiesto predvolenej správy.",
"360messengerMessageTemplate": "Šablóna správy",
"360messengerWayToWriteRecipient": "Zadajte jedno alebo viacero telefónnych čísel v medzinárodnom formáte bez predpony plus (napr. {0}). Viacero čísel oddeľte čiarkami.",
"360messengerErrorNoApiKey": "Najskôr zadajte svoj API kľúč 360messenger.",
"360messengerErrorApi": "Nie je možné načítať zoznam skupín WhatsApp (Chyba {statusCode}: {message}).",
"360messengerErrorGeneric": "Nie je možné načítať zoznam skupín WhatsApp: {message}",
"GlobalpingMultipleLocationsError": "Viacnásobné polohy nie sú podporované, pre každý monitor použite jednu polohu.",
"360messengerWayToGetUrlAndToken": "API kľúč pre 360messenger môžete získať na {0}.",
"360messengerErrorNoGroups": "Pre tento účet neboli nájdené žiadne skupiny WhatsApp.",
"GlobalpingLocationDescription": "Do poľa polohy môžete zadávať kontinenty, krajiny, regióny, mestá, ASN, ISP alebo cloudové regióny. Filtre môžete kombinovať pomocou znaku {plus} (napr. {amazonPlusGermany} alebo {comcastPlusCalifornia}). Ak je dôležitým ukazovateľom latencia, použite filtre na zúženie polohy na malý región, aby ste sa vyhli výkyvom, a pre lepšiu stabilitu nastavte filter {datacenter}. {fullDocs}."
}

View File

@@ -86,6 +86,13 @@
MQTT: {{ monitor.hostname }}:{{ monitor.port }}/{{ monitor.mqttTopic }}
</span>
<span v-if="monitor.type === 'mysql'">{{ filterPassword(monitor.databaseConnectionString) }}</span>
<span v-if="monitor.type === 'oracledb'">
{{
$t("oracledbConnectionString", {
connectionString: filterPassword(monitor.databaseConnectionString),
})
}}
</span>
<span v-if="monitor.type === 'postgres'">{{ filterPassword(monitor.databaseConnectionString) }}</span>
<span v-if="monitor.type === 'push'">
Push:

View File

@@ -98,6 +98,7 @@
<option value="sqlserver">Microsoft SQL Server</option>
<option value="mongodb">MongoDB</option>
<option value="mysql">MySQL/MariaDB</option>
<option value="oracledb">Oracle Database</option>
<option value="postgres">PostgreSQL</option>
<option value="radius">Radius</option>
<option value="redis">Redis</option>
@@ -1161,12 +1162,13 @@
</div>
</template>
<!-- SQL Server / PostgreSQL / MySQL / Redis / MongoDB -->
<!-- SQL Server / PostgreSQL / MySQL / Oracle / Redis / MongoDB -->
<template
v-if="
monitor.type === 'sqlserver' ||
monitor.type === 'postgres' ||
monitor.type === 'mysql' ||
monitor.type === 'oracledb' ||
monitor.type === 'redis' ||
monitor.type === 'mongodb'
"
@@ -1185,6 +1187,29 @@
</div>
</template>
<template v-if="monitor.type === 'oracledb'">
<div class="my-3">
<label for="oracledb-user" class="form-label">{{ $t("Username") }}</label>
<input
id="oracledb-user"
v-model="monitor.basic_auth_user"
type="text"
class="form-control"
required
/>
</div>
<div class="my-3">
<label for="oracledb-pass" class="form-label">{{ $t("Password") }}</label>
<HiddenInput
id="oracledb-pass"
v-model="monitor.basic_auth_pass"
autocomplete="new-password"
:required="true"
/>
</div>
</template>
<template v-if="monitor.type === 'system-service'">
<div class="my-3">
<label for="system-service-name" class="form-label">{{ $t("Service Name") }}</label>
@@ -1276,12 +1301,13 @@
</div>
</template>
<!-- SQL Server / PostgreSQL / MySQL -->
<!-- SQL Server / PostgreSQL / MySQL / Oracle -->
<template
v-if="
monitor.type === 'sqlserver' ||
monitor.type === 'postgres' ||
monitor.type === 'mysql'
monitor.type === 'mysql' ||
monitor.type === 'oracledb'
"
>
<div class="my-3">
@@ -1290,7 +1316,11 @@
id="sqlQuery"
v-model="monitor.databaseQuery"
class="form-control"
:placeholder="$t('Example:', ['SELECT 1'])"
:placeholder="
$t('Example:', [
monitor.type === 'oracledb' ? 'SELECT 1 FROM DUAL' : 'SELECT 1',
])
"
></textarea>
</div>
</template>
@@ -2880,6 +2910,8 @@ const monitorDefaults = {
docker_container: "",
docker_host: null,
proxyId: null,
basic_auth_user: "",
basic_auth_pass: "",
mqttUsername: "",
mqttPassword: "",
mqttTopic: "",
@@ -2944,6 +2976,7 @@ export default {
"Server=<hostname>,<port>;Database=<your database>;User Id=<your user id>;Password=<your password>;Encrypt=<true/false>;TrustServerCertificate=<Yes/No>;Connection Timeout=<int>",
postgres: "postgres://username:password@host:port/database",
mysql: "mysql://username:password@host:port/database",
oracledb: "localhost:1521/FREEPDB1",
redis: "redis://user:password@host:port",
mongodb: "mongodb://username:password@host:port/database",
},
@@ -3842,6 +3875,20 @@ message HealthCheckResponse {
this.monitor.url = this.monitor.url.trim();
}
if (this.monitor.databaseConnectionString) {
this.monitor.databaseConnectionString = this.monitor.databaseConnectionString.trim();
}
if (this.monitor.type === "oracledb") {
if (this.monitor.basic_auth_user) {
this.monitor.basic_auth_user = this.monitor.basic_auth_user.trim();
}
if (this.monitor.basic_auth_pass) {
this.monitor.basic_auth_pass = this.monitor.basic_auth_pass.trim();
}
}
let createdNewParent = false;
if (this.draftGroupName && this.monitor.parent === -1) {

View File

@@ -0,0 +1,232 @@
const { after, before, describe, test } = require("node:test");
const assert = require("node:assert");
const { OracleDbContainer } = require("@testcontainers/oraclefree");
const { OracleDbMonitorType } = require("../../../server/monitor-types/oracledb");
const { UP, PENDING } = require("../../../src/util");
const ORACLE_IMAGE = "gvenzl/oracle-free:23-slim-faststart";
const APP_USER = "uptimekuma";
const APP_USER_PASSWORD = "Oracle123";
/**
* Create a monitor payload for Oracle monitor tests.
* @param {object} overrides Partial monitor overrides
* @returns {object} Monitor payload
*/
function createMonitor(overrides = {}) {
return {
basic_auth_user: APP_USER,
basic_auth_pass: APP_USER_PASSWORD,
conditions: "[]",
...overrides,
};
}
/**
* Create a baseline heartbeat object for Oracle monitor tests.
* @returns {{msg: string, status: string}} Heartbeat payload
*/
function createHeartbeat() {
return {
msg: "",
status: PENDING,
};
}
/**
* Helper function to create and start an Oracle container.
* @returns {Promise<{container: import("@testcontainers/oraclefree").StartedOracleDbContainer, connectString: string}>}
*/
async function createAndStartOracleContainer() {
const container = await new OracleDbContainer(ORACLE_IMAGE)
.withUsername(APP_USER)
.withPassword(APP_USER_PASSWORD)
.start();
return {
container,
connectString: container.getUrl(),
};
}
describe(
"Oracle Database Monitor",
{
skip: !!process.env.CI && (process.platform !== "linux" || process.arch !== "x64"),
},
() => {
/** @type {import("@testcontainers/oraclefree").StartedOracleDbContainer | undefined} */
let container;
/** @type {string | undefined} */
let connectString;
before(async () => {
const oracle = await createAndStartOracleContainer();
container = oracle.container;
connectString = oracle.connectString;
});
after(async () => {
if (container) {
await container.stop();
}
});
test("check() sets status to UP when Oracle server is reachable", async () => {
const oracleMonitor = new OracleDbMonitorType();
const monitor = createMonitor({
databaseConnectionString: connectString,
});
const heartbeat = createHeartbeat();
await oracleMonitor.check(monitor, heartbeat, {});
assert.strictEqual(heartbeat.status, UP, `Expected status ${UP} but got ${heartbeat.status}`);
});
test("check() rejects when Oracle server is not reachable", async () => {
const oracleMonitor = new OracleDbMonitorType();
const monitor = createMonitor({
databaseConnectionString: "localhost:1/FREEPDB1",
});
const heartbeat = createHeartbeat();
await assert.rejects(oracleMonitor.check(monitor, heartbeat, {}), (err) => {
assert.ok(
err.message.includes("Database connection/query failed"),
`Expected error message to include "Database connection/query failed" but got: ${err.message}`
);
return true;
});
assert.notStrictEqual(heartbeat.status, UP, `Expected status should not be ${UP}`);
});
test("check() sets status to UP when custom query returns single value", async () => {
const oracleMonitor = new OracleDbMonitorType();
const monitor = createMonitor({
databaseConnectionString: connectString,
databaseQuery: "SELECT 42 FROM DUAL",
});
const heartbeat = createHeartbeat();
await oracleMonitor.check(monitor, heartbeat, {});
assert.strictEqual(heartbeat.status, UP, `Expected status ${UP} but got ${heartbeat.status}`);
});
test("check() sets status to UP when custom query result meets condition", async () => {
const oracleMonitor = new OracleDbMonitorType();
const monitor = createMonitor({
databaseConnectionString: connectString,
databaseQuery: "SELECT 42 AS value FROM DUAL",
conditions: JSON.stringify([
{
type: "expression",
andOr: "and",
variable: "result",
operator: "equals",
value: "42",
},
]),
});
const heartbeat = createHeartbeat();
await oracleMonitor.check(monitor, heartbeat, {});
assert.strictEqual(heartbeat.status, UP, `Expected status ${UP} but got ${heartbeat.status}`);
});
test("check() rejects when custom query result does not meet condition", async () => {
const oracleMonitor = new OracleDbMonitorType();
const monitor = createMonitor({
databaseConnectionString: connectString,
databaseQuery: "SELECT 99 AS value FROM DUAL",
conditions: JSON.stringify([
{
type: "expression",
andOr: "and",
variable: "result",
operator: "equals",
value: "42",
},
]),
});
const heartbeat = createHeartbeat();
await assert.rejects(
oracleMonitor.check(monitor, heartbeat, {}),
new Error("Query result did not meet the specified conditions (99)")
);
assert.strictEqual(heartbeat.status, PENDING, `Expected status should not be ${heartbeat.status}`);
});
test("check() rejects when query returns no results with conditions", async () => {
const oracleMonitor = new OracleDbMonitorType();
const monitor = createMonitor({
databaseConnectionString: connectString,
databaseQuery: "SELECT 1 AS value FROM DUAL WHERE 1 = 0",
conditions: JSON.stringify([
{
type: "expression",
andOr: "and",
variable: "result",
operator: "equals",
value: "1",
},
]),
});
const heartbeat = createHeartbeat();
await assert.rejects(
oracleMonitor.check(monitor, heartbeat, {}),
new Error("Database connection/query failed: Query returned no results")
);
assert.strictEqual(heartbeat.status, PENDING, `Expected status should not be ${heartbeat.status}`);
});
test("check() rejects when query returns multiple rows with conditions", async () => {
const oracleMonitor = new OracleDbMonitorType();
const monitor = createMonitor({
databaseConnectionString: connectString,
databaseQuery: "SELECT 1 AS value FROM DUAL UNION ALL SELECT 2 AS value FROM DUAL",
conditions: JSON.stringify([
{
type: "expression",
andOr: "and",
variable: "result",
operator: "equals",
value: "1",
},
]),
});
const heartbeat = createHeartbeat();
await assert.rejects(
oracleMonitor.check(monitor, heartbeat, {}),
new Error("Database connection/query failed: Multiple values were found, expected only one value")
);
assert.strictEqual(heartbeat.status, PENDING, `Expected status should not be ${heartbeat.status}`);
});
test("check() rejects when query returns multiple columns with conditions", async () => {
const oracleMonitor = new OracleDbMonitorType();
const monitor = createMonitor({
databaseConnectionString: connectString,
databaseQuery: "SELECT 1 AS col1, 2 AS col2 FROM DUAL",
conditions: JSON.stringify([
{
type: "expression",
andOr: "and",
variable: "result",
operator: "equals",
value: "1",
},
]),
});
const heartbeat = createHeartbeat();
await assert.rejects(
oracleMonitor.check(monitor, heartbeat, {}),
new Error("Database connection/query failed: Multiple columns were found, expected only one value")
);
assert.strictEqual(heartbeat.status, PENDING, `Expected status should not be ${heartbeat.status}`);
});
}
);

View File

@@ -0,0 +1,50 @@
const { describe, test } = require("node:test");
const assert = require("node:assert");
/**
* Extracts the ping value filtering logic from PingChart.vue pushDatapoint().
* This mirrors the condition: datapoint.up > 0 && datapoint.avgPing != null
* @param {object} datapoint Datapoint with up, avgPing, minPing, maxPing
* @returns {number|null} The avgPing value or null if filtered out
*/
function filterPingValue(datapoint) {
return datapoint.up > 0 && datapoint.avgPing != null ? datapoint.avgPing : null;
}
describe("PingChart pushDatapoint filtering", () => {
test("avgPing of 0 should be rendered, not filtered out (#7143)", () => {
const datapoint = { up: 1, down: 0, avgPing: 0, minPing: 0, maxPing: 0 };
const result = filterPingValue(datapoint);
assert.strictEqual(result, 0, "avgPing of 0 must not be converted to null");
});
test("avgPing of 1 should be rendered", () => {
const datapoint = { up: 1, down: 0, avgPing: 1, minPing: 1, maxPing: 1 };
const result = filterPingValue(datapoint);
assert.strictEqual(result, 1);
});
test("avgPing of null should be filtered out", () => {
const datapoint = { up: 1, down: 0, avgPing: null, minPing: null, maxPing: null };
const result = filterPingValue(datapoint);
assert.strictEqual(result, null);
});
test("avgPing of undefined should be filtered out", () => {
const datapoint = { up: 1, down: 0, avgPing: undefined, minPing: undefined, maxPing: undefined };
const result = filterPingValue(datapoint);
assert.strictEqual(result, null);
});
test("datapoint with no up counts should be filtered out", () => {
const datapoint = { up: 0, down: 1, avgPing: 5, minPing: 5, maxPing: 5 };
const result = filterPingValue(datapoint);
assert.strictEqual(result, null);
});
test("normal ping value with up count should be rendered", () => {
const datapoint = { up: 3, down: 0, avgPing: 42, minPing: 30, maxPing: 55 };
const result = filterPingValue(datapoint);
assert.strictEqual(result, 42);
});
});