Merge branch 'master' into google-chat-url-link

This commit is contained in:
Frank Elsinga 2026-01-06 00:51:50 +01:00 committed by GitHub
commit 1f1d28aaa2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 148 additions and 250 deletions

View File

@ -754,18 +754,32 @@ class Monitor extends BeanModel {
log.debug("monitor", `[${this.name}] Axios Request`);
let res = await axios.request(options);
if (res.data.State.Running) {
if (!res.data.State) {
throw Error("Container state is not available");
}
if (!res.data.State.Running) {
throw Error("Container State is " + res.data.State.Status);
}
if (res.data.State.Paused) {
throw Error("Container is in a paused state");
}
if (res.data.State.Restarting) {
bean.status = PENDING;
bean.msg = "Container is reporting it is currently restarting";
} else if (res.data.State.Health && res.data.State.Health.Status !== "none") {
// if healthchecks are disabled (?), Health MAY not be present
if (res.data.State.Health.Status === "healthy") {
bean.status = UP;
bean.msg = res.data.State.Health ? res.data.State.Health.Status : res.data.State.Status;
bean.msg = "healthy";
} else if (res.data.State.Health.Status === "unhealthy") {
throw Error("Container State is unhealthy");
throw Error("Container State is unhealthy according to its healthcheck");
} else {
bean.status = PENDING;
bean.msg = res.data.State.Health.Status;
}
} else {
throw Error("Container State is " + res.data.State.Status);
bean.status = UP;
bean.msg = `Container has not reported health and is currently ${res.data.State.Status}. As it is running, it is considered UP. Consider adding a health check for better service visibility`;
}
} else if (this.type === "mysql") {
let startTime = dayjs().valueOf();

View File

@ -14,25 +14,25 @@
<option value="numeric">{{ $t("Telephone number") }}</option>
</select>
<div class="form-text">
<p><b>{{ $t("Alphanumeric (recommended)") }}:</b><br /> {{ $t("Alphanumeric string (max 11 alphanumeric characters). Recipients can not reply to the message.") }}</p>
<p><b>{{ $t("Telephone number") }}:</b><br /> {{ $t("Numeric value (max 15 digits) with telephone number on international format without leading 00 (example UK number 07920 110 000 should be set as 447920110000). Recipients can reply to the message.") }}</p>
<p><b>{{ $t("Alphanumeric (recommended)") }}:</b><br /> {{ $t("cellsyntOriginatortypeAlphanumeric") }}</p>
<p><b>{{ $t("Telephone number") }}:</b><br /> {{ $t("cellsyntOriginatortypeNumeric") }}</p>
</div>
</div>
<div class="mb-3">
<label for="cellsynt-originator" class="form-label">{{ $t("Originator") }} <small>({{ $parent.notification.cellsyntOriginatortype === 'alpha' ? $t("max 11 alphanumeric characters") : $t("max 15 digits") }})</small></label>
<input v-if="$parent.notification.cellsyntOriginatortype === 'alpha'" id="cellsynt-originator" v-model="$parent.notification.cellsyntOriginator" type="text" class="form-control" pattern="[a-zA-Z0-9\s]+" maxlength="11" required>
<input v-else id="cellsynt-originator" v-model="$parent.notification.cellsyntOriginator" type="number" class="form-control" pattern="[0-9]+" maxlength="15" required>
<div class="form-text"><p>{{ $t("Visible on recipient's mobile phone as originator of the message. Allowed values and function depends on parameter originatortype.") }}</p></div>
<div class="form-text"><p>{{ $t("cellsyntOriginator") }}</p></div>
</div>
<div class="mb-3">
<label for="cellsynt-destination" class="form-label">{{ $t("Destination") }}</label>
<input id="cellsynt-destination" v-model="$parent.notification.cellsyntDestination" type="text" class="form-control" required>
<div class="form-text"><p>{{ $t("Recipient's telephone number using international format with leading 00 followed by country code, e.g. 00447920110000 for the UK number 07920 110 000 (max 17 digits in total). Max 25000 comma separated recipients per HTTP request.") }}</p></div>
<div class="form-text"><p>{{ $t("cellsyntDestination") }}</p></div>
</div>
<div class="form-check form-switch">
<input id="cellsynt-allow-long" v-model="$parent.notification.cellsyntAllowLongSMS" type="checkbox" class="form-check-input">
<label for="cellsynt-allow-long" class="form-label">{{ $t("Allow Long SMS") }}</label>
<div class="form-text">{{ $t("Split long messages into up to 6 parts. 153 x 6 = 918 characters.") }}</div>
<div class="form-text">{{ $t("cellsyntSplitLongMessages") }}</div>
</div>
<i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">
<a href="https://www.cellsynt.com/en/" target="_blank">https://www.cellsynt.com/en/</a>

View File

@ -10,7 +10,7 @@
</div>
<div class="mb-3">
<div class="form-text">
{{ $t("checkPrice", [$t("clicksendsms")]) }}
{{ $t("checkPrice", ["clicksendsms"]) }}
<a href="https://www.clicksend.com/us/pricing" target="_blank">https://clicksend.com/us/pricing</a>
</div>
</div>

View File

@ -2,7 +2,7 @@
<div class="mb-3">
<label for="octopush-version" class="form-label">{{ $t("Octopush API Version") }}</label>
<select id="octopush-version" v-model="$parent.notification.octopushVersion" class="form-select">
<option value="2">{{ $t("octopush") }} ({{ $t("endpoint") }}: api.octopush.com)</option>
<option value="2">{{ "octopush" }} ({{ $t("endpoint") }}: api.octopush.com)</option>
<option value="1">{{ $t("Legacy Octopush-DM") }} ({{ $t("endpoint") }}: www.octopush-dm.com)</option>
</select>
<div class="form-text">

View File

@ -14,7 +14,7 @@
<a href="https://docs.rocket.chat/guides/administration/administration/integrations" target="_blank">https://docs.rocket.chat/guides/administration/administration/integrations</a>
</i18n-t>
<p style="margin-top: 8px;">
{{ $t("aboutChannelName", [$t("rocket.chat")]) }}
{{ $t("aboutChannelName", ["rocket.chat"]) }}
</p>
<p style="margin-top: 8px;">
{{ $t("aboutKumaURL") }}

View File

@ -24,7 +24,7 @@
<a href="https://api.slack.com/messaging/webhooks" target="_blank">https://api.slack.com/messaging/webhooks</a>
</i18n-t>
<p style="margin-top: 8px;">
{{ $t("aboutChannelName", [$t("slack")]) }}
{{ $t("aboutChannelName", ["slack"]) }}
</p>
<p style="margin-top: 8px;">
{{ $t("aboutKumaURL") }}

View File

@ -15,10 +15,6 @@
<span v-else-if="!running" class="text-danger">{{ $t("Not running") }}</span>
</div>
<div v-if="false">
{{ message }}
</div>
<div v-if="errorMessage" class="mt-3">
{{ $t("Message:") }}
<textarea v-model="errorMessage" class="form-control" readonly></textarea>

View File

@ -100,7 +100,7 @@
</i18n-t>
<i18n-t tag="p" keypath="disableauth.message2">
<template #intendThirdPartyAuth>
<strong>{{ $t('intend to implement third-party authentication') }}</strong>
<strong>{{ $t('where you intend to implement third-party authentication') }}</strong>
</template>
</i18n-t>
<p>{{ $t("Please use this option carefully!") }}</p>

View File

@ -1174,6 +1174,7 @@
"resendFromName": "From Name",
"resendFromEmail": "From Email",
"resendLeaveBlankForDefaultName": "leave blank for default name",
"resendLeaveBlankForDefaultSubject": "Leave blank for default subject",
"resendToEmail": "To Email",
"resendSubject": "Subject",
"pingCountLabel": "Max Packets",
@ -1245,7 +1246,43 @@
"minimumIntervalWarning": "Intervals below 20 seconds may result in poor performance.",
"lowIntervalWarning": "Are you sure want to set the interval value below 20 seconds? Performance may be degraded, particularly if there are a large number of monitors.",
"imageResetConfirmation": "Image reset to default",
"mtls-auth-server-cert-label": "Cert",
"mtls-auth-server-cert-placeholder": "Cert body",
"mtls-auth-server-key-label": "Key",
"mtls-auth-server-key-placeholder": "Key body",
"mtls-auth-server-ca-label": "CA",
"mtls-auth-server-ca-placeholder": "Server CA",
"avgPing": "Avg Ping",
"Uptime Kuma": "Uptime Kuma",
"maxPing": "Max Ping",
"minPing": "Min Ping"
"minPing": "Min Ping",
"Clear current filters": "Clear current filters",
"Sort options": "Sort options",
"Sort by status": "Sort by status",
"Sort by name": "Sort by name",
"Sort by uptime": "Sort by uptime",
"Sort by certificate expiry": "Sort by certificate expiry",
"Splunk Rest URL": "Splunk Rest URL",
"Severity": "Severity",
"SMSManager": "SMSManager",
"Message Format": "Message Format",
"smscTranslit": "smscTranslit",
"promosms": "promosms",
"Region": "Region",
"PushDeer Server URL": "PushDeer Server URL",
"To Number": "To Number",
"GrafanaOncallURL": "Grafana Oncall URL",
"Never": "Never",
"Json Query": "Json Query",
"System Service": "System Service",
"SSL/TLS": "SSL/TLS",
"playground": "playground",
"Check Type": "Check Type",
"Service Name": "Service Name",
"GRPC Options": "GRPC Options",
"Metadata": "Metadata",
"End": "End",
"Show this Maintenance Message on which Status Pages": "Show this Maintenance Message on which Status Pages",
"Endpoint": "Endpoint",
"Details": "Details"
}

View File

@ -1166,16 +1166,16 @@
<template v-if="monitor.authMethod && monitor.authMethod !== null ">
<template v-if="monitor.authMethod === 'mtls' ">
<div class="my-3">
<label for="tls-cert" class="form-label">{{ $t("Cert") }}</label>
<textarea id="tls-cert" v-model="monitor.tlsCert" class="form-control" :placeholder="$t('Cert body')" required></textarea>
<label for="tls-cert" class="form-label">{{ $t("mtls-auth-server-cert-label") }}</label>
<textarea id="tls-cert" v-model="monitor.tlsCert" class="form-control" :placeholder="$t('mtls-auth-server-cert-placeholder')" required></textarea>
</div>
<div class="my-3">
<label for="tls-key" class="form-label">{{ $t("Key") }}</label>
<textarea id="tls-key" v-model="monitor.tlsKey" class="form-control" :placeholder="$t('Key body')" required></textarea>
<label for="tls-key" class="form-label">{{ $t("mtls-auth-server-key-label") }}</label>
<textarea id="tls-key" v-model="monitor.tlsKey" class="form-control" :placeholder="$t('mtls-auth-server-key-placeholder')" required></textarea>
</div>
<div class="my-3">
<label for="tls-ca" class="form-label">{{ $t("CA") }}</label>
<textarea id="tls-ca" v-model="monitor.tlsCa" class="form-control" :placeholder="$t('Server CA')"></textarea>
<label for="tls-ca" class="form-label">{{ $t("mtls-auth-server-ca-label") }}</label>
<textarea id="tls-ca" v-model="monitor.tlsCa" class="form-control" :placeholder="$t('mtls-auth-server-ca-placeholder')"></textarea>
</div>
</template>
<template v-else-if="monitor.authMethod === 'oauth2-cc' ">
@ -1277,14 +1277,6 @@
<label for="body" class="form-label">{{ $t("Body") }}</label>
<textarea id="body" v-model="monitor.grpcBody" class="form-control" :placeholder="bodyPlaceholder"></textarea>
</div>
<!-- Metadata: temporary disable waiting for next PR allow to send gRPC with metadata -->
<template v-if="false">
<div class="my-3">
<label for="metadata" class="form-label">{{ $t("Metadata") }}</label>
<textarea id="metadata" v-model="monitor.grpcMetadata" class="form-control" :placeholder="headersPlaceholder"></textarea>
</div>
</template>
</template>
</div>
</div>
@ -1455,17 +1447,6 @@ export default {
return this.monitor.type === "ping" ? 60 : undefined;
},
timeoutLabel() {
return this.monitor.type === "ping" ? this.$t("pingTimeoutLabel") : this.$t("Request Timeout");
},
timeoutDescription() {
if (this.monitor.type === "ping") {
return this.$t("pingTimeoutDescription");
}
return "";
},
defaultFriendlyName() {
if (this.monitor.hostname) {
return this.monitor.hostname;

View File

@ -1,169 +0,0 @@
<template>
<transition name="slide-fade" appear>
<div v-if="maintenance">
<h1>{{ maintenance.title }}</h1>
<p class="url">
<span>{{ $t("Start") }}: {{ $root.datetimeMaintenance(maintenance.start_date) }}</span>
<br>
<span>{{ $t("End") }}: {{ $root.datetimeMaintenance(maintenance.end_date) }}</span>
</p>
<div class="functions" style="margin-top: 10px;">
<router-link :to=" '/maintenance/edit/' + maintenance.id " class="btn btn-secondary">
<font-awesome-icon icon="edit" /> {{ $t("Edit") }}
</router-link>
<button class="btn btn-danger" @click="deleteDialog">
<font-awesome-icon icon="trash" /> {{ $t("Delete") }}
</button>
</div>
<label for="description" class="form-label" style="margin-top: 20px;">{{ $t("Description") }}</label>
<textarea id="description" v-model="maintenance.description" class="form-control" disabled></textarea>
<label for="affected_monitors" class="form-label" style="margin-top: 20px;">{{ $t("Affected Monitors") }}</label>
<br>
<button v-for="monitor in affectedMonitors" :key="monitor.id" class="btn btn-monitor" style="margin: 5px; cursor: auto; color: white; font-weight: 500;">
{{ monitor }}
</button>
<br />
<label for="selected_status_pages" class="form-label" style="margin-top: 20px;">{{ $t("Show this Maintenance Message on which Status Pages") }}</label>
<br>
<button v-for="statusPage in selectedStatusPages" :key="statusPage.id" class="btn btn-monitor" style="margin: 5px; cursor: auto; color: white; font-weight: 500;">
{{ statusPage }}
</button>
<Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteMaintenance">
{{ $t("deleteMaintenanceMsg") }}
</Confirm>
</div>
</transition>
</template>
<script>
import { useToast } from "vue-toastification";
const toast = useToast();
import Confirm from "../components/Confirm.vue";
export default {
components: {
Confirm,
},
data() {
return {
affectedMonitors: [],
selectedStatusPages: [],
};
},
computed: {
maintenance() {
let id = this.$route.params.id;
return this.$root.maintenanceList[id];
},
},
mounted() {
this.init();
},
methods: {
/**
* Initialise page
* @returns {void}
*/
init() {
this.$root.getSocket().emit("getMonitorMaintenance", this.$route.params.id, (res) => {
if (res.ok) {
this.affectedMonitors = Object.values(res.monitors).map(monitor => monitor.name);
} else {
toast.error(res.msg);
}
});
this.$root.getSocket().emit("getMaintenanceStatusPage", this.$route.params.id, (res) => {
if (res.ok) {
this.selectedStatusPages = Object.values(res.statusPages).map(statusPage => statusPage.title);
} else {
toast.error(res.msg);
}
});
},
/**
* Confirm deletion
* @returns {void}
*/
deleteDialog() {
this.$refs.confirmDelete.show();
},
/**
* Delete maintenance after showing confirmation
* @returns {void}
*/
deleteMaintenance() {
this.$root.deleteMaintenance(this.maintenance.id, (res) => {
this.$root.toastRes(res);
this.$router.push("/maintenance");
});
},
},
};
</script>
<style lang="scss" scoped>
@import "../assets/vars.scss";
@media (max-width: 550px) {
.functions {
text-align: center;
button, a {
margin-left: 10px !important;
margin-right: 10px !important;
}
}
}
@media (max-width: 400px) {
.btn {
display: inline-flex;
flex-direction: column;
align-items: center;
padding-top: 10px;
}
a.btn {
padding-left: 25px;
padding-right: 25px;
}
}
.url {
color: $primary;
margin-bottom: 20px;
font-weight: bold;
a {
color: $primary;
}
}
.functions {
button, a {
margin-right: 20px;
}
}
textarea {
min-height: 100px;
resize: none;
}
.btn-monitor {
background-color: #5cdd8b;
}
.dark .btn-monitor {
color: #020b05 !important;
}
</style>

View File

@ -28,7 +28,6 @@
></div>
<div class="info">
<div class="title">{{ item.title }}</div>
<div v-if="false">{{ item.description }}</div>
<div class="status">
{{ $t("maintenanceStatus-" + item.status) }}
</div>
@ -38,8 +37,6 @@
</div>
<div class="buttons">
<router-link v-if="false" :to="maintenanceURL(item.id)" class="btn btn-light">{{ $t("Details") }}</router-link>
<div class="btn-group" role="group">
<button v-if="item.active" class="btn btn-normal" :aria-label="$t('ariaPauseMaintenance')" @click="pauseDialog(item.id)">
<font-awesome-icon icon="pause" /> {{ $t("Pause") }}
@ -82,7 +79,6 @@
<script>
import { getResBaseURL } from "../util-frontend";
import { getMaintenanceRelativeURL } from "../util.ts";
import Confirm from "../components/Confirm.vue";
import MaintenanceTime from "../components/MaintenanceTime.vue";
@ -135,15 +131,6 @@ export default {
}
},
/**
* Get maintenance URL
* @param {number} id ID of maintenance to read
* @returns {string} Relative URL
*/
maintenanceURL(id) {
return getMaintenanceRelativeURL(id);
},
/**
* Show delete confirmation
* @param {number} maintenanceID ID of maintenance to show delete

View File

@ -74,11 +74,6 @@
<label class="form-check-label" for="show-only-last-heartbeat">{{ $t("showOnlyLastHeartbeat") }}</label>
</div>
<div v-if="false" class="my-3">
<label for="password" class="form-label">{{ $t("Password") }} <sup>{{ $t("Coming Soon") }}</sup></label>
<input id="password" v-model="config.password" disabled type="password" autocomplete="new-password" class="form-control">
</div>
<!-- Domain Name List -->
<div class="my-3">
<label class="form-label">

View File

@ -16,7 +16,6 @@ import ManageStatusPage from "./pages/ManageStatusPage.vue";
import AddStatusPage from "./pages/AddStatusPage.vue";
import NotFound from "./pages/NotFound.vue";
import DockerHosts from "./components/settings/Docker.vue";
import MaintenanceDetails from "./pages/MaintenanceDetails.vue";
import ManageMaintenance from "./pages/ManageMaintenance.vue";
import APIKeys from "./components/settings/APIKeys.vue";
import SetupDatabase from "./pages/SetupDatabase.vue";
@ -150,10 +149,6 @@ const routes = [
path: "/maintenance",
component: ManageMaintenance,
},
{
path: "/maintenance/:id",
component: MaintenanceDetails,
},
{
path: "/add-maintenance",
component: EditMaintenance,

View File

@ -11,7 +11,7 @@
var _a;
Object.defineProperty(exports, "__esModule", { value: true });
exports.CONSOLE_STYLE_FgPink = exports.CONSOLE_STYLE_FgBrown = exports.CONSOLE_STYLE_FgViolet = exports.CONSOLE_STYLE_FgLightBlue = exports.CONSOLE_STYLE_FgLightGreen = exports.CONSOLE_STYLE_FgOrange = exports.CONSOLE_STYLE_FgGray = exports.CONSOLE_STYLE_FgWhite = exports.CONSOLE_STYLE_FgCyan = exports.CONSOLE_STYLE_FgMagenta = exports.CONSOLE_STYLE_FgBlue = exports.CONSOLE_STYLE_FgYellow = exports.CONSOLE_STYLE_FgGreen = exports.CONSOLE_STYLE_FgRed = exports.CONSOLE_STYLE_FgBlack = exports.CONSOLE_STYLE_Hidden = exports.CONSOLE_STYLE_Reverse = exports.CONSOLE_STYLE_Blink = exports.CONSOLE_STYLE_Underscore = exports.CONSOLE_STYLE_Dim = exports.CONSOLE_STYLE_Bright = exports.CONSOLE_STYLE_Reset = exports.PING_PER_REQUEST_TIMEOUT_DEFAULT = exports.PING_PER_REQUEST_TIMEOUT_MAX = exports.PING_PER_REQUEST_TIMEOUT_MIN = exports.PING_COUNT_DEFAULT = exports.PING_COUNT_MAX = exports.PING_COUNT_MIN = exports.PING_GLOBAL_TIMEOUT_DEFAULT = exports.PING_GLOBAL_TIMEOUT_MAX = exports.PING_GLOBAL_TIMEOUT_MIN = exports.PING_PACKET_SIZE_DEFAULT = exports.PING_PACKET_SIZE_MAX = exports.PING_PACKET_SIZE_MIN = exports.MIN_INTERVAL_SECOND = exports.MAX_INTERVAL_SECOND = exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = exports.SQL_DATETIME_FORMAT = exports.SQL_DATE_FORMAT = exports.STATUS_PAGE_MAINTENANCE = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.MAINTENANCE = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isNode = exports.isDev = void 0;
exports.evaluateJsonQuery = exports.intHash = exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMaintenanceRelativeURL = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = exports.badgeConstants = exports.CONSOLE_STYLE_BgGray = exports.CONSOLE_STYLE_BgWhite = exports.CONSOLE_STYLE_BgCyan = exports.CONSOLE_STYLE_BgMagenta = exports.CONSOLE_STYLE_BgBlue = exports.CONSOLE_STYLE_BgYellow = exports.CONSOLE_STYLE_BgGreen = exports.CONSOLE_STYLE_BgRed = exports.CONSOLE_STYLE_BgBlack = void 0;
exports.evaluateJsonQuery = exports.intHash = exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = exports.badgeConstants = exports.CONSOLE_STYLE_BgGray = exports.CONSOLE_STYLE_BgWhite = exports.CONSOLE_STYLE_BgCyan = exports.CONSOLE_STYLE_BgMagenta = exports.CONSOLE_STYLE_BgBlue = exports.CONSOLE_STYLE_BgYellow = exports.CONSOLE_STYLE_BgGreen = exports.CONSOLE_STYLE_BgRed = exports.CONSOLE_STYLE_BgBlack = void 0;
const dayjs_1 = require("dayjs");
const jsonata = require("jsonata");
exports.isDev = process.env.NODE_ENV === "development";
@ -321,10 +321,6 @@ function getMonitorRelativeURL(id) {
return "/dashboard/" + id;
}
exports.getMonitorRelativeURL = getMonitorRelativeURL;
function getMaintenanceRelativeURL(id) {
return "/maintenance/" + id;
}
exports.getMaintenanceRelativeURL = getMaintenanceRelativeURL;
function parseTimeObject(time) {
if (!time) {
return {

View File

@ -534,15 +534,6 @@ export function getMonitorRelativeURL(id: string) {
return "/dashboard/" + id;
}
/**
* Get relative path for maintenance
* @param id ID of maintenance
* @returns Formatted relative path
*/
export function getMaintenanceRelativeURL(id: string) {
return "/maintenance/" + id;
}
/**
* Parse to Time Object that used in VueDatePicker
* @param {string} time E.g. 12:00

View File

@ -0,0 +1,75 @@
const { describe, it } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
/**
* Recursively walks a directory and yields file paths.
* @param {string} dir The directory to walk.
* @yields {string} The path to a file.
* @returns {Generator<string>} A generator that yields file paths.
*/
function* walk(dir) {
const files = fs.readdirSync(dir, { withFileTypes: true });
for (const file of files) {
if (file.isDirectory()) {
yield* walk(path.join(dir, file.name));
} else {
yield path.join(dir, file.name);
}
}
}
describe("Check Translations", () => {
it("should not have missing translation keys", () => {
const enTranslations = JSON.parse(fs.readFileSync("src/lang/en.json", "utf-8"));
// this is a resonably crude check, you can get around this trivially
/// this check is just to save on maintainer energy to explain this on every review ^^
const translationRegex = /\$t\(['"](?<key1>.*?)['"]\s*[,)]|i18n-t[^>]*\s+keypath="(?<key2>[^"]+)"/gd;
const missingKeys = [];
for (const filePath of walk("src")) {
if (filePath.endsWith(".vue") || filePath.endsWith(".js")) {
const lines = fs.readFileSync(filePath, "utf-8").split("\n");
lines.forEach((line, lineNum) => {
let match;
while ((match = translationRegex.exec(line)) !== null) {
const key = match.groups.key1 || match.groups.key2;
if (key && !enTranslations[key]) {
const [ start, end ] = match.groups.key1 ? match.indices.groups.key1 : match.indices.groups.key2;
missingKeys.push({
filePath,
lineNum: lineNum + 1,
key,
line: line,
start,
end,
});
}
}
});
}
}
if (missingKeys.length > 0) {
let report = "Missing translation keys found:\n";
missingKeys.forEach(({ filePath, lineNum, key, line, start, end }) => {
report += `\nerror: Missing translation key: '${key}'`;
report += `\n --> ${filePath}:${lineNum}:${start}`;
report += "\n |";
report += `\n${String(lineNum).padEnd(5)}| ${line}`;
const arrow = " ".repeat(start) + "^".repeat(end - start);
report += `\n | ${arrow} unrecognized translation key`;
report += "\n |";
report += `\n = note: please register the translation key '${key}' in en.json so that our awesome team of translators can translate them`;
report += "\n = tip: if you want to contribute translations, please visit https://weblate.kuma.pet\n";
});
report += "\n===============================";
const fileCount = new Set(missingKeys.map(item => item.filePath)).size;
report += `\nFound a total of ${missingKeys.length} missing keys in ${fileCount} files.`;
assert.fail(report);
}
});
});