Merge branch 'master' into feature/response-body

This commit is contained in:
Frank Elsinga 2026-01-12 13:37:21 +01:00 committed by GitHub
commit ad6603cd03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 388 additions and 86 deletions

View File

@ -4,9 +4,9 @@ on:
# Safety
# This workflow uses pull_request_target so it can run with write permissions on first-time contributor PRs.
# It is safe because it does not check out or execute any code from the pull request and
# only uses the pinned, trusted actions/first-interaction action
# only uses the pinned, trusted plbstl/first-contribution action
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened]
types: [opened, closed]
branches:
- master
@ -20,20 +20,21 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/first-interaction@1c4688942c71f71d4f5502a26ea67c331730fa4d # v3.1.0
- uses: plbstl/first-contribution@4b2b042fffa26792504a18e49aa9543a87bec077 # v4.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue_message: "This is a required parameter, but we don't run on this event as we consider it a bit spammy.."
pr_message: |
Hello! Thank you for your contribution 👋
pr-reactions: rocket
pr-opened-msg: >
Hello and thanks for lending a paw to Uptime Kuma! 🐻👋
As this is your first contribution, please be sure to check out our
[Pull Request guidelines](https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md#can-i-create-a-pull-request-for-uptime-kuma).
As this is your first contribution, please be sure to check out our [Pull Request guidelines](https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md#can-i-create-a-pull-request-for-uptime-kuma).
In particular:
- Mark your PR as Draft while youre still making changes
- Mark it as Ready for review once its fully ready 🟢
- Mark it as Ready for review once its fully ready
If you have any design or process questions, feel free to ask them right here in this pull request - unclear documentation is a bug too.
pr-merged-msg: >
@{fc-author} congrats on your first contribution to Uptime Kuma! 🐻
Thanks for lending a paw to Uptime Kuma 🐻
We hope you enjoy contributing to our project and look forward to seeing more of your work in the future!
If you want to see your contribution in action, please see our [nightly builds here](https://hub.docker.com/layers/louislam/uptime-kuma/nightly2).

View File

@ -2,20 +2,29 @@
<div class="shadow-box mb-3" :style="boxStyle">
<div class="list-header">
<div class="header-top">
<button
class="btn btn-outline-normal ms-2"
:class="{ active: selectMode }"
type="button"
@click="selectMode = !selectMode"
>
{{ $t("Select") }}
</button>
<div class="select-checkbox-wrapper">
<input
v-if="!selectMode"
v-model="selectMode"
class="form-check-input"
type="checkbox"
:aria-label="$t('selectAllMonitorsAria')"
@change="selectAll = selectMode"
/>
<input
v-else
v-model="selectAll"
class="form-check-input"
type="checkbox"
:aria-label="selectAll ? $t('deselectAllMonitorsAria') : $t('selectAllMonitorsAria')"
/>
</div>
<div class="placeholder"></div>
<div class="search-wrapper">
<a v-if="searchText == ''" class="search-icon">
<font-awesome-icon icon="search" />
</a>
<div class="header-filter">
<MonitorListFilter :filterState="filterState" @update-filter="updateFilter" />
</div>
<div class="search-wrapper ms-auto">
<a v-if="searchText != ''" class="search-icon" @click="clearSearchText">
<font-awesome-icon icon="times" />
</a>
@ -30,25 +39,51 @@
</form>
</div>
</div>
<div class="header-filter">
<MonitorListFilter :filterState="filterState" @update-filter="updateFilter" />
</div>
<!-- Selection Controls -->
<div v-if="selectMode" class="selection-controls px-2 pt-2">
<input v-model="selectAll" class="form-check-input select-input" type="checkbox" />
<button class="btn-outline-normal" @click="pauseDialog">
<font-awesome-icon icon="pause" size="sm" />
{{ $t("Pause") }}
<div v-if="selectMode && selectedMonitorCount > 0" class="selected-count-row">
<button class="btn btn-outline-normal" @click="cancelSelectMode">
{{ $t("Cancel") }}
</button>
<button class="btn-outline-normal" @click="resumeSelected">
<font-awesome-icon icon="play" size="sm" />
{{ $t("Resume") }}
</button>
<span v-if="selectedMonitorCount > 0">
{{ $t("selectedMonitorCount", [selectedMonitorCount]) }}
<div class="actions-wrapper ms-2">
<div class="dropdown">
<button
class="btn btn-outline-normal dropdown-toggle"
type="button"
data-bs-toggle="dropdown"
:aria-label="$t('Actions')"
:disabled="bulkActionInProgress"
aria-expanded="false"
>
{{ $t("Actions") }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="#" @click.prevent="pauseDialog">
<font-awesome-icon icon="pause" class="me-2" />
{{ $t("Pause") }}
</a>
</li>
<li>
<a class="dropdown-item" href="#" @click.prevent="resumeSelected">
<font-awesome-icon icon="play" class="me-2" />
{{ $t("Resume") }}
</a>
</li>
<li>
<a
class="dropdown-item text-danger"
href="#"
@click.prevent="$refs.confirmDelete.show()"
>
<font-awesome-icon icon="trash" class="me-2" />
{{ $t("Delete") }}
</a>
</li>
</ul>
</div>
</div>
<span class="selected-count ms-2">
{{ $tc("selectedMonitorCountMsg", selectedMonitorCount) }}
</span>
</div>
</div>
@ -81,6 +116,10 @@
<Confirm ref="confirmPause" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="pauseSelected">
{{ $t("pauseMonitorMsg") }}
</Confirm>
<Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteSelected">
{{ $t("deleteMonitorsMsg") }}
</Confirm>
</template>
<script>
@ -109,6 +148,7 @@ export default {
disableSelectAllWatcher: false,
selectedMonitors: {},
windowTop: 0,
bulkActionInProgress: false,
filterState: {
status: null,
active: null,
@ -307,10 +347,21 @@ export default {
* @returns {void}
*/
pauseSelected() {
Object.keys(this.selectedMonitors)
.filter((id) => this.$root.monitorList[id].active)
.forEach((id) => this.$root.getSocket().emit("pauseMonitor", id, () => {}));
if (this.bulkActionInProgress) {
return;
}
const activeMonitors = Object.keys(this.selectedMonitors).filter((id) => this.$root.monitorList[id].active);
if (activeMonitors.length === 0) {
this.$root.toastError(this.$t("noMonitorsPausedMsg"));
return;
}
this.bulkActionInProgress = true;
activeMonitors.forEach((id) => this.$root.getSocket().emit("pauseMonitor", id, () => {}));
this.$root.toastSuccess(this.$tc("pausedMonitorsMsg", activeMonitors.length));
this.bulkActionInProgress = false;
this.cancelSelectMode();
},
/**
@ -318,9 +369,66 @@ export default {
* @returns {void}
*/
resumeSelected() {
Object.keys(this.selectedMonitors)
.filter((id) => !this.$root.monitorList[id].active)
.forEach((id) => this.$root.getSocket().emit("resumeMonitor", id, () => {}));
if (this.bulkActionInProgress) {
return;
}
const inactiveMonitors = Object.keys(this.selectedMonitors).filter(
(id) => !this.$root.monitorList[id].active
);
if (inactiveMonitors.length === 0) {
this.$root.toastError(this.$t("noMonitorsResumedMsg"));
return;
}
this.bulkActionInProgress = true;
inactiveMonitors.forEach((id) => this.$root.getSocket().emit("resumeMonitor", id, () => {}));
this.$root.toastSuccess(this.$tc("resumedMonitorsMsg", inactiveMonitors.length));
this.bulkActionInProgress = false;
this.cancelSelectMode();
},
/**
* Delete each selected monitor
* @returns {Promise<void>}
*/
async deleteSelected() {
if (this.bulkActionInProgress) {
return;
}
const monitorIds = Object.keys(this.selectedMonitors);
this.bulkActionInProgress = true;
let successCount = 0;
let errorCount = 0;
for (const id of monitorIds) {
try {
await new Promise((resolve, reject) => {
this.$root.getSocket().emit("deleteMonitor", id, false, (res) => {
if (res.ok) {
successCount++;
resolve();
} else {
errorCount++;
reject();
}
});
});
} catch (error) {
// Error already counted
}
}
this.bulkActionInProgress = false;
if (successCount > 0) {
this.$root.toastSuccess(this.$tc("deletedMonitorsMsg", successCount));
}
if (errorCount > 0) {
this.$root.toastError(this.$tc("bulkDeleteErrorMsg", errorCount));
}
this.cancelSelectMode();
},
@ -438,10 +546,32 @@ export default {
}
}
.search-row {
display: flex;
padding: 10px;
padding-bottom: 5px;
}
.header-top {
display: flex;
justify-content: space-between;
justify-content: flex-start;
align-items: center;
gap: 8px;
padding: 10px;
@media (max-width: 549px), (min-width: 770px) and (max-width: 1149px), (min-width: 1200px) and (max-width: 1499px) {
flex-wrap: wrap;
}
}
.select-checkbox-wrapper {
display: flex;
align-items: center;
.form-check-input {
cursor: pointer;
margin: 0;
}
}
.header-filter {
@ -449,6 +579,104 @@ export default {
align-items: center;
}
.actions-wrapper {
display: flex;
align-items: center;
.dropdown-toggle {
white-space: nowrap;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.dropdown-menu {
min-width: 140px;
padding: 4px 0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
.dark & {
background-color: $dark-bg;
border-color: $dark-border-color;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
}
.dropdown-item {
cursor: pointer;
padding: 6px 12px;
font-size: 0.9em;
.dark & {
color: $dark-font-color;
&:hover {
background-color: $dark-bg2;
color: $dark-font-color;
}
}
&.text-danger {
color: #dc3545;
.dark & {
color: #dc3545;
}
&:hover {
background-color: #dc3545 !important;
color: white !important;
.dark & {
background-color: #dc3545 !important;
color: white !important;
}
svg {
color: white !important;
}
}
}
}
}
.selected-count {
white-space: nowrap;
font-size: 0.9em;
color: $primary;
.dark & {
color: $dark-font-color;
}
}
.selected-count-row {
padding: 5px 10px 0 10px;
display: flex;
align-items: center;
}
.selection-controls {
margin-top: 5px;
display: flex;
align-items: center;
.d-flex {
width: 100%;
}
.gap-2 {
gap: 0.5rem;
}
.selected-count {
margin-left: auto;
}
}
@media (max-width: 770px) {
.list-header {
margin: -20px;
@ -460,25 +688,40 @@ export default {
.search-wrapper {
display: flex;
align-items: center;
position: relative;
@media (max-width: 549px), (min-width: 770px) and (max-width: 1149px), (min-width: 1200px) and (max-width: 1499px) {
order: -1;
width: 100%;
margin-bottom: 8px;
form {
width: 100%;
}
}
}
.search-icon {
padding: 10px;
position: absolute;
right: 10px;
color: #c0c0c0;
cursor: pointer;
transition: all ease-in-out 0.1s;
z-index: 1;
// Clear filter button (X)
svg[data-icon="times"] {
cursor: pointer;
transition: all ease-in-out 0.1s;
&:hover {
opacity: 0.5;
}
&:hover {
opacity: 0.5;
}
}
.search-input {
max-width: 15em;
padding-right: 30px;
@media (max-width: 549px), (min-width: 770px) and (max-width: 1149px), (min-width: 1200px) and (max-width: 1499px) {
max-width: 100%;
width: 100%;
}
}
.monitor-item {
@ -498,10 +741,13 @@ export default {
margin-top: 5px;
}
.selection-controls {
margin-top: 5px;
display: flex;
align-items: center;
gap: 10px;
@media (max-width: 549px), (min-width: 770px) and (max-width: 1149px), (min-width: 1200px) and (max-width: 1499px) {
.selection-controls {
.selected-count {
margin-left: 0;
width: 100%;
margin-top: 0.25rem;
}
}
}
</style>

View File

@ -1,18 +1,5 @@
<template>
<div class="px-2 pt-2 d-flex">
<button
type="button"
:title="$t('Clear current filters')"
class="clear-filters-btn btn"
:class="{ active: numFiltersActive > 0 }"
tabindex="0"
:disabled="numFiltersActive === 0"
@click="clearFilters"
>
<font-awesome-icon icon="stream" />
<span v-if="numFiltersActive > 0" class="px-1 fw-bold">{{ numFiltersActive }}</span>
<font-awesome-icon v-if="numFiltersActive > 0" icon="times" />
</button>
<div class="d-flex align-items-center">
<MonitorListFilterDropdown :filterActive="filterState.status?.length > 0">
<template #status>
<Status v-if="filterState.status?.length === 1" :status="filterState.status[0]" />

View File

@ -333,7 +333,11 @@
"Cancel": "Cancel",
"auto-select": "Auto Select",
"Select": "Select",
"selectedMonitorCount": "Selected: {0}",
"Actions": "Actions",
"selectedMonitorCountMsg": "selected: {n} | selected: {n}",
"selectMonitorMsg": "Select monitors to perform actions",
"selectAllMonitorsAria": "Select all monitors",
"deselectAllMonitorsAria": "Deselect all monitors",
"Check/Uncheck": "Check/Uncheck",
"Powered by": "Powered by",
"Customize": "Customize",
@ -629,6 +633,13 @@
"grpcMethodDescription": "Method name is convert to camelCase format such as sayHello, check, etc.",
"acceptedStatusCodesDescription": "Select status codes which are considered as a successful response.",
"deleteMonitorMsg": "Are you sure want to delete this monitor?",
"deleteMonitorsMsg": "Are you sure you want to delete the selected monitors?",
"pausedMonitorsMsg": "Paused {n} monitor | Paused {n} monitors",
"resumedMonitorsMsg": "Resumed {n} monitor | Resumed {n} monitors",
"deletedMonitorsMsg": "Deleted {n} monitor | Deleted {n} monitors",
"noMonitorsPausedMsg": "No monitors paused (none were active)",
"noMonitorsResumedMsg": "No monitors resumed (none were inactive)",
"bulkDeleteErrorMsg": "Failed to delete {n} monitor | Failed to delete {n} monitors",
"deleteGroupMsg": "Are you sure you want to delete this group?",
"deleteChildrenMonitors": "Also delete the direct child monitors and its children if it has any | Also delete all {count} direct child monitors and their children if they have any",
"deleteMaintenanceMsg": "Are you sure want to delete this maintenance?",

View File

@ -1147,7 +1147,7 @@
</div>
</div>
<div class="my-3">
<div v-if="monitor.maxretries" class="my-3">
<label for="retry-interval" class="form-label">
{{ $t("Heartbeat Retry Interval") }}
<span>({{ $t("retryCheckEverySecond", [monitor.retryInterval]) }})</span>

View File

@ -1,16 +1,16 @@
const { describe, it } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const fs = require("fs/promises");
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.
* @returns {AsyncGenerator<string>} A generator that yields file paths.
*/
function* walk(dir) {
const files = fs.readdirSync(dir, { withFileTypes: true });
async function* walk(dir) {
const files = await fs.readdir(dir, { withFileTypes: true });
for (const file of files) {
if (file.isDirectory()) {
yield* walk(path.join(dir, file.name));
@ -20,6 +20,29 @@ function* walk(dir) {
}
}
const UPSTREAM_EN_JSON = "https://raw.githubusercontent.com/louislam/uptime-kuma/refs/heads/master/src/lang/en.json";
/**
* Extract `{placeholders}` from a translation string.
* @param {string} value The translation string to extract placeholders from.
* @returns {Set<string>} A set of placeholder names.
*/
function extractParams(value) {
if (typeof value !== "string") {
return new Set();
}
const regex = /\{([^}]+)\}/g;
const params = new Set();
let match;
while ((match = regex.exec(value)) !== null) {
params.add(match[1]);
}
return params;
}
/**
* Fallback to get start/end indices of a key within a line.
* @param {string} line - Line of text to search in.
@ -35,8 +58,8 @@ function getStartEnd(line, key) {
}
describe("Check Translations", () => {
it("should not have missing translation keys", () => {
const enTranslations = JSON.parse(fs.readFileSync("src/lang/en.json", "utf-8"));
it("should not have missing translation keys", async () => {
const enTranslations = JSON.parse(await fs.readFile("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 ^^
@ -50,9 +73,9 @@ describe("Check Translations", () => {
const roots = ["src", "server"];
for (const root of roots) {
for (const filePath of walk(root)) {
for await (const filePath of walk(root)) {
if (filePath.endsWith(".vue") || filePath.endsWith(".js")) {
const lines = fs.readFileSync(filePath, "utf-8").split("\n");
const lines = (await fs.readFile(filePath, "utf-8")).split("\n");
lines.forEach((line, lineNum) => {
let match;
// front-end style keys ($t / i18n-t)
@ -112,4 +135,38 @@ describe("Check Translations", () => {
assert.fail(report);
}
});
it("en.json translations must not change placeholder parameters", async () => {
// Load local reference (the one translators are synced against)
const enTranslations = JSON.parse(await fs.readFile("src/lang/en.json", "utf-8"));
// Fetch upstream version
const res = await fetch(UPSTREAM_EN_JSON);
assert.equal(res.ok, true, "Failed to fetch upstream en.json");
const upstreamEn = await res.json();
for (const [key, upstreamValue] of Object.entries(upstreamEn)) {
if (!(key in enTranslations)) {
// deleted keys are fine
continue;
}
const localParams = extractParams(enTranslations[key]);
const upstreamParams = extractParams(upstreamValue);
assert.deepEqual(
localParams,
upstreamParams,
[
`Translation key "${key}" changed placeholder parameters.`,
`This is a breaking change for existing translations.`,
`Please rename the translation key instead of changing placeholders.`,
``,
`your version: ${[...localParams].join(", ")}`,
`on master: ${[...upstreamParams].join(", ")}`,
].join("\n")
);
}
});
});