Merge branch 'master' into feature/recovery-check-interval

This commit is contained in:
oleksandr.shostak@consultic.com 2026-01-14 10:38:07 +02:00 committed by GitHub
commit 03c358e85f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 235 additions and 72 deletions

View File

@ -50,7 +50,8 @@ jobs:
git push origin --delete "release-${VERSION}" || true git push origin --delete "release-${VERSION}" || true
# Delete local branch if it exists # Delete local branch if it exists
git branch -D "release-${VERSION}" || true git branch -D "release-${VERSION}" || true
# Create new branch from master # For testing purpose
# git checkout beta-workflow
git checkout -b "release-${VERSION}" git checkout -b "release-${VERSION}"
- name: Install dependencies - name: Install dependencies

View File

@ -36,6 +36,9 @@ export default defineConfig({
srcDir: "src", srcDir: "src",
filename: "serviceWorker.ts", filename: "serviceWorker.ts",
strategies: "injectManifest", strategies: "injectManifest",
injectManifest: {
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024, // 3 MiB
},
}), }),
], ],
css: { css: {

View File

@ -1,3 +1,6 @@
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const pkg = require("../../package.json"); const pkg = require("../../package.json");
const fs = require("fs"); const fs = require("fs");
const childProcess = require("child_process"); const childProcess = require("child_process");
@ -58,8 +61,13 @@ function commit(version) {
throw new Error("commit error"); throw new Error("commit error");
} }
// Note: Push is handled by gh pr create in the release script // Get the current branch name
// No need to push here as we're on a release branch, not master res = childProcess.spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
let branchName = res.stdout.toString().trim();
console.log("Current branch:", branchName);
// Git push the branch
childProcess.spawnSync("git", ["push", "origin", branchName, "--force"], { stdio: "inherit" });
} }
/** /**

View File

@ -4,7 +4,7 @@
import * as childProcess from "child_process"; import * as childProcess from "child_process";
const ignoreList = ["louislam", "CommanderStorm", "UptimeKumaBot", "weblate", "Copilot"]; const ignoreList = ["louislam", "CommanderStorm", "UptimeKumaBot", "weblate", "Copilot", "@autofix-ci[bot]"];
const mergeList = ["Translations Update from Weblate", "Update dependencies"]; const mergeList = ["Translations Update from Weblate", "Update dependencies"];

View File

@ -48,7 +48,7 @@ checkDocker();
await checkTagExists(repoNames, version); await checkTagExists(repoNames, version);
// node extra/beta/update-version.js // node extra/beta/update-version.js
execSync("node ./extra/beta/update-version.js"); await import("../beta/update-version.mjs");
// Create Pull Request (gh pr create will handle pushing the branch) // Create Pull Request (gh pr create will handle pushing the branch)
await createReleasePR(version, previousVersion, dryRun, branchName, githubRunId); await createReleasePR(version, previousVersion, dryRun, branchName, githubRunId);

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "uptime-kuma", "name": "uptime-kuma",
"version": "2.1.0-beta.1", "version": "2.1.0-beta.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "uptime-kuma", "name": "uptime-kuma",
"version": "2.1.0-beta.1", "version": "2.1.0-beta.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@grpc/grpc-js": "~1.8.22", "@grpc/grpc-js": "~1.8.22",

View File

@ -1,6 +1,6 @@
{ {
"name": "uptime-kuma", "name": "uptime-kuma",
"version": "2.1.0-beta.1", "version": "2.1.0-beta.2",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -176,14 +176,6 @@ class DomainExpiry extends BeanModel {
const rdap = await getRdapServer(tld.publicSuffix); const rdap = await getRdapServer(tld.publicSuffix);
if (!rdap) { if (!rdap) {
// Only warn when the monitor actually has domain expiry notifications enabled.
// The edit monitor page calls this method frequently while the user is typing.
if (Boolean(monitor.domainExpiryNotification)) {
log.warn(
"domain_expiry",
`Domain expiry unsupported for '.${tld.publicSuffix}' because its RDAP endpoint is not listed in the IANA database.`
);
}
throw new TranslatableError("domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint", { throw new TranslatableError("domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint", {
publicSuffix: tld.publicSuffix, publicSuffix: tld.publicSuffix,
}); });

View File

@ -1060,7 +1060,15 @@ class Monitor extends BeanModel {
log.debug("monitor", `Failed getting expiration date for domain ${supportInfo.domain}`); log.debug("monitor", `Failed getting expiration date for domain ${supportInfo.domain}`);
} }
} catch (error) { } catch (error) {
// purposely not logged due to noise. Is accessible via checkMointor if (
error.message === "domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint" &&
Boolean(this.domainExpiryNotification)
) {
log.warn(
"domain_expiry",
`Domain expiry unsupported for '.${error.meta.publicSuffix}' because its RDAP endpoint is not listed in the IANA database.`
);
}
} }
} }

View File

@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<h4>{{ $t("Certificate Info") }}</h4> <h4>{{ $t("Certificate Info") }}</h4>
{{ $t("Certificate Chain") }}: {{ $t("Certificate Chain:") }}
<div v-if="valid" class="rounded d-inline-flex ms-2 text-white tag-valid"> <div v-if="valid" class="rounded d-inline-flex ms-2 text-white tag-valid">
{{ $t("Valid") }} {{ $t("Valid") }}
</div> </div>

View File

@ -40,14 +40,13 @@
required required
/> />
<div class="form-text"> <i18n-t tag="div" keypath="Examples:" class="form-text">
{{ $t("Examples") }}:
<ul> <ul>
<li>/var/run/docker.sock</li> <li><code>/var/run/docker.sock</code></li>
<li>http://localhost:2375</li> <li><code>http://localhost:2375</code></li>
<li>https://localhost:2376 (TLS)</li> <li><code>https://localhost:2376 (TLS)</code></li>
</ul> </ul>
</div> </i18n-t>
</div> </div>
</div> </div>

View File

@ -31,12 +31,9 @@
required required
/> />
<div class="form-text mt-3"> <i18n-t tag="div" keypath="Example:" class="form-text mt-3">
{{ $t("Examples") }}: <code>ws://chrome.browserless.io/playwright?token=YOUR-API-TOKEN</code>
<ul> </i18n-t>
<li>ws://chrome.browserless.io/playwright?token=YOUR-API-TOKEN</li>
</ul>
</div>
</div> </div>
</div> </div>

View File

@ -85,12 +85,12 @@ export default {
title() { title() {
if (this.type === "1y") { if (this.type === "1y") {
return `1 ${this.$tc("year", 1)}`; return this.$tc("years", 1, { n: 1 });
} }
if (this.type === "720") { if (this.type === "720") {
return `30 ${this.$tc("day", 30)}`; return this.$tc("days", 30, { n: 30 });
} }
return `24 ${this.$tc("hour", 24)}`; return this.$tc("hours", 24, { n: 24 });
}, },
}, },
}; };

View File

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

View File

@ -27,7 +27,7 @@
<div class="status"> <div class="status">
{{ $t("apiKey-" + item.status) }} {{ $t("apiKey-" + item.status) }}
</div> </div>
<div class="date">{{ $t("Created") }}: {{ item.createdDate }}</div> <div class="date">{{ $t("createdAt", { date: item.createdDate }) }}</div>
<div class="date"> <div class="date">
{{ $t("Expires") }}: {{ $t("Expires") }}:
{{ item.expires || $t("Never") }} {{ item.expires || $t("Never") }}

View File

@ -3,8 +3,8 @@
<div class="logo d-flex flex-column justify-content-center align-items-center"> <div class="logo d-flex flex-column justify-content-center align-items-center">
<object class="my-4" width="200" height="200" data="/icon.svg" /> <object class="my-4" width="200" height="200" data="/icon.svg" />
<div class="fs-4 fw-bold">Uptime Kuma</div> <div class="fs-4 fw-bold">Uptime Kuma</div>
<div>{{ $t("Version") }}: {{ $root.info.version }}</div> <div>{{ $t("versionIs", { version: $root.info.version }) }}</div>
<div class="frontend-version">{{ $t("Frontend Version") }}: {{ $root.frontendVersion }}</div> <div class="frontend-version">{{ $t("frontendVersionIs", { version: $root.frontendVersion }) }}</div>
<div v-if="!$root.isFrontendBackendVersionMatched" class="alert alert-warning mt-4" role="alert"> <div v-if="!$root.isFrontendBackendVersionMatched" class="alert alert-warning mt-4" role="alert">
{{ $t("Frontend Version do not match backend version!") }} {{ $t("Frontend Version do not match backend version!") }}

View File

@ -79,7 +79,7 @@
<ActionInput <ActionInput
v-model="tlsExpiryNotifInput" v-model="tlsExpiryNotifInput"
:type="'number'" :type="'number'"
:placeholder="$t('day')" :placeholder="$tc('days', 1, { n: 1 })"
:icon="'plus'" :icon="'plus'"
:action="() => addTlsExpiryNotifDay(tlsExpiryNotifInput)" :action="() => addTlsExpiryNotifDay(tlsExpiryNotifInput)"
:action-aria-label="$t('Add a new expiry notification day')" :action-aria-label="$t('Add a new expiry notification day')"
@ -117,7 +117,7 @@
<ActionInput <ActionInput
v-model="domainExpiryNotifInput" v-model="domainExpiryNotifInput"
:type="'number'" :type="'number'"
:placeholder="$t('day')" :placeholder="$tc('days', 1, { n: 1 })"
:icon="'plus'" :icon="'plus'"
:action="() => addDomainExpiryNotifDay(domainExpiryNotifInput)" :action="() => addDomainExpiryNotifDay(domainExpiryNotifInput)"
:action-aria-label="$t('Add a new expiry notification day')" :action-aria-label="$t('Add a new expiry notification day')"

View File

@ -4,15 +4,13 @@
<!-- Change Password --> <!-- Change Password -->
<template v-if="!settings.disableAuth"> <template v-if="!settings.disableAuth">
<p> <p>
{{ $t("Current User") }}:
<strong>{{ $root.username }}</strong>
<button <button
v-if="!settings.disableAuth" v-if="!settings.disableAuth"
id="logout-btn" id="logout-btn"
class="btn btn-danger ms-4 me-2 mb-2" class="btn btn-danger ms-4 me-2 mb-2"
@click="$root.logout" @click="$root.logout"
> >
{{ $t("Logout") }} {{ $t("logoutCurrentUser", { username: $root.username }) }}
</button> </button>
</p> </p>

View File

@ -20,7 +20,7 @@
"General": "General", "General": "General",
"Game": "Game", "Game": "Game",
"Primary Base URL": "Primary Base URL", "Primary Base URL": "Primary Base URL",
"Version": "Version", "versionIs": "Version: {version}",
"Check Update On GitHub": "Check Update On GitHub", "Check Update On GitHub": "Check Update On GitHub",
"List": "List", "List": "List",
"Home": "Home", "Home": "Home",
@ -55,9 +55,11 @@
"Monitor": "Monitor | Monitors", "Monitor": "Monitor | Monitors",
"now": "now", "now": "now",
"time ago": "{0} ago", "time ago": "{0} ago",
"day": "day | days", "days": "{n} day | {n} days",
"hour": "hour | hours", "hours": "{n} hour | {n} hours",
"year": "year | years", "minutes": "{n} minute | {n} minutes",
"minuteShort": "{n} min | {n} min",
"years": "{n} year | {n} years",
"Response": "Response", "Response": "Response",
"Ping": "Ping", "Ping": "Ping",
"Monitor Type": "Monitor Type", "Monitor Type": "Monitor Type",
@ -147,6 +149,7 @@
"where you intend to implement third-party authentication": "where you intend to implement third-party authentication", "where you intend to implement third-party authentication": "where you intend to implement third-party authentication",
"Please use this option carefully!": "Please use this option carefully!", "Please use this option carefully!": "Please use this option carefully!",
"Logout": "Log out", "Logout": "Log out",
"logoutCurrentUser": "Log out {username}",
"Leave": "Leave", "Leave": "Leave",
"I understand, please disable": "I understand, please disable", "I understand, please disable": "I understand, please disable",
"Confirm": "Confirm", "Confirm": "Confirm",
@ -282,7 +285,6 @@
"records": "records", "records": "records",
"One record": "One record", "One record": "One record",
"steamApiKeyDescriptionAt": "For monitoring a Steam Game Server you need a Steam Web-API key. You can register your API key at {url}", "steamApiKeyDescriptionAt": "For monitoring a Steam Game Server you need a Steam Web-API key. You can register your API key at {url}",
"Current User": "Current User",
"topic": "Topic", "topic": "Topic",
"topicExplanation": "MQTT topic to monitor", "topicExplanation": "MQTT topic to monitor",
"mqttWebSocketPath": "MQTT WebSocket Path", "mqttWebSocketPath": "MQTT WebSocket Path",
@ -322,8 +324,9 @@
"dark": "dark", "dark": "dark",
"Post": "Post", "Post": "Post",
"Please input title and content": "Please input title and content", "Please input title and content": "Please input title and content",
"Created": "Created", "createdAt": "Created: {date}",
"Last Updated": "Last Updated", "lastUpdatedAt": "Last Updated: {date}",
"lastUpdatedAtFromNow": "Last Updated: {date} ({fromNow})",
"Switch to Light Theme": "Switch to Light Theme", "Switch to Light Theme": "Switch to Light Theme",
"Switch to Dark Theme": "Switch to Dark Theme", "Switch to Dark Theme": "Switch to Dark Theme",
"Show Tags": "Show Tags", "Show Tags": "Show Tags",
@ -358,7 +361,7 @@
"proxyDescription": "Proxies must be assigned to a monitor to function.", "proxyDescription": "Proxies must be assigned to a monitor to function.",
"enableProxyDescription": "This proxy will not effect on monitor requests until it is activated. You can control temporarily disable the proxy from all monitors by activation status.", "enableProxyDescription": "This proxy will not effect on monitor requests until it is activated. You can control temporarily disable the proxy from all monitors by activation status.",
"setAsDefaultProxyDescription": "This proxy will be enabled by default for new monitors. You can still disable the proxy separately for each monitor.", "setAsDefaultProxyDescription": "This proxy will be enabled by default for new monitors. You can still disable the proxy separately for each monitor.",
"Certificate Chain": "Certificate Chain", "Certificate Chain:": "Certificate Chain:",
"Valid": "Valid", "Valid": "Valid",
"Invalid": "Invalid", "Invalid": "Invalid",
"User": "User", "User": "User",
@ -405,7 +408,7 @@
"Add a new expiry notification day": "Add a new expiry notification day", "Add a new expiry notification day": "Add a new expiry notification day",
"Remove the expiry notification": "Remove the expiry notification day", "Remove the expiry notification": "Remove the expiry notification day",
"Proxy": "Proxy", "Proxy": "Proxy",
"Date Created": "Date Created", "dateCreatedAtFromNow": "Date Created: {date} ({fromNow})",
"Footer Text": "Footer Text", "Footer Text": "Footer Text",
"RSS Title": "RSS Title", "RSS Title": "RSS Title",
"Leave blank to use status page title": "Leave blank to use status page title", "Leave blank to use status page title": "Leave blank to use status page title",
@ -479,7 +482,7 @@
"disableCloudflaredNoAuthMsg": "You are in No Auth mode, a password is not required.", "disableCloudflaredNoAuthMsg": "You are in No Auth mode, a password is not required.",
"trustProxyDescription": "Trust 'X-Forwarded-*' headers. If you want to get the correct client IP and your Uptime Kuma is behind a proxy such as Nginx or Apache, you should enable this.", "trustProxyDescription": "Trust 'X-Forwarded-*' headers. If you want to get the correct client IP and your Uptime Kuma is behind a proxy such as Nginx or Apache, you should enable this.",
"wayToGetLineNotifyToken": "You can get an access token from {0}", "wayToGetLineNotifyToken": "You can get an access token from {0}",
"Examples": "Examples", "Examples:": "Examples: {0}",
"supportBaleChatID": "Support Direct Chat / Group / Channel's Chat ID", "supportBaleChatID": "Support Direct Chat / Group / Channel's Chat ID",
"wayToGetBaleChatID": "You can get your chat ID by sending a message to the bot and going to this URL to view the chat_id:", "wayToGetBaleChatID": "You can get your chat ID by sending a message to the bot and going to this URL to view the chat_id:",
"wayToGetBaleToken": "You can get a token from {0}.", "wayToGetBaleToken": "You can get a token from {0}.",
@ -494,7 +497,7 @@
"Event type:": "Event type:", "Event type:": "Event type:",
"Event data:": "Event data:", "Event data:": "Event data:",
"Then choose an action, for example switch the scene to where an RGB light is red.": "Then choose an action, for example switch the scene to where an RGB light is red.", "Then choose an action, for example switch the scene to where an RGB light is red.": "Then choose an action, for example switch the scene to where an RGB light is red.",
"Frontend Version": "Frontend Version", "frontendVersionIs": "Frontend Version: {version}",
"Frontend Version do not match backend version!": "Frontend Version do not match backend version!", "Frontend Version do not match backend version!": "Frontend Version do not match backend version!",
"backupOutdatedWarning": "Deprecated: Since a lot of features were added and this backup feature is a bit unmaintained, it cannot generate or restore a complete backup.", "backupOutdatedWarning": "Deprecated: Since a lot of features were added and this backup feature is a bit unmaintained, it cannot generate or restore a complete backup.",
"backupRecommend": "Please backup the volume or the data folder (./data/) directly instead.", "backupRecommend": "Please backup the volume or the data folder (./data/) directly instead.",
@ -505,7 +508,7 @@
"startDateTime": "Start Date/Time", "startDateTime": "Start Date/Time",
"endDateTime": "End Date/Time", "endDateTime": "End Date/Time",
"cronExpression": "Cron Expression", "cronExpression": "Cron Expression",
"cronSchedule": "Schedule: ", "cronScheduleDescription": "Schedule: {description}",
"Duration (Minutes)": "Duration (Minutes)", "Duration (Minutes)": "Duration (Minutes)",
"invalidCronExpression": "Invalid Cron Expression: {0}", "invalidCronExpression": "Invalid Cron Expression: {0}",
"recurringInterval": "Interval", "recurringInterval": "Interval",
@ -672,6 +675,8 @@
"recurringIntervalMessage": "Run once every day | Run once every {0} days", "recurringIntervalMessage": "Run once every day | Run once every {0} days",
"affectedMonitorsDescription": "Select monitors that are affected by current maintenance", "affectedMonitorsDescription": "Select monitors that are affected by current maintenance",
"affectedStatusPages": "Show this maintenance message on selected status pages", "affectedStatusPages": "Show this maintenance message on selected status pages",
"Sets end time based on start time": "Sets end time based on start time",
"Please set start time first": "Please set start time first",
"noMonitorsSelectedWarning": "You are creating a maintenance without any affected monitors. Are you sure you want to continue?", "noMonitorsSelectedWarning": "You are creating a maintenance without any affected monitors. Are you sure you want to continue?",
"noMonitorsOrStatusPagesSelectedError": "Cannot create maintenance without affected monitors or status pages", "noMonitorsOrStatusPagesSelectedError": "Cannot create maintenance without affected monitors or status pages",
"passwordNotMatchMsg": "The repeat password does not match.", "passwordNotMatchMsg": "The repeat password does not match.",
@ -682,7 +687,6 @@
"backupDescription": "You can backup all monitors and notifications into a JSON file.", "backupDescription": "You can backup all monitors and notifications into a JSON file.",
"backupDescription2": "Note: history and event data is not included.", "backupDescription2": "Note: history and event data is not included.",
"backupDescription3": "Sensitive data such as notification tokens are included in the export file; please store export securely.", "backupDescription3": "Sensitive data such as notification tokens are included in the export file; please store export securely.",
"endpoint": "endpoint",
"octopushAPIKey": "\"API key\" from HTTP API credentials in control panel", "octopushAPIKey": "\"API key\" from HTTP API credentials in control panel",
"octopushLogin": "\"Login\" from HTTP API credentials in control panel", "octopushLogin": "\"Login\" from HTTP API credentials in control panel",
"promosmsLogin": "API Login Name", "promosmsLogin": "API Login Name",
@ -884,7 +888,8 @@
"From Name/Number": "From Name/Number", "From Name/Number": "From Name/Number",
"Leave blank to use a shared sender number.": "Leave blank to use a shared sender number.", "Leave blank to use a shared sender number.": "Leave blank to use a shared sender number.",
"Octopush API Version": "Octopush API Version", "Octopush API Version": "Octopush API Version",
"Legacy Octopush-DM": "Legacy Octopush-DM", "octopushEndpoint": "octopush (endpoint: {url})",
"legacyOctopushEndpoint": "Legacy Octopush-DM (endpoint: {url})",
"ntfy Topic": "ntfy Topic", "ntfy Topic": "ntfy Topic",
"Server URL should not contain the nfty topic": "Server URL should not contain the nfty topic", "Server URL should not contain the nfty topic": "Server URL should not contain the nfty topic",
"onebotHttpAddress": "OneBot HTTP Address", "onebotHttpAddress": "OneBot HTTP Address",

View File

@ -123,16 +123,13 @@
</select> </select>
</div> </div>
<!-- Single Maintenance Window -->
<template v-if="maintenance.strategy === 'single'"></template>
<template v-if="maintenance.strategy === 'cron'"> <template v-if="maintenance.strategy === 'cron'">
<!-- Cron --> <!-- Cron -->
<div class="my-3"> <div class="my-3">
<label for="cron" class="form-label"> <label for="cron" class="form-label">
{{ $t("cronExpression") }} {{ $t("cronExpression") }}
</label> </label>
<p>{{ $t("cronSchedule") }}{{ cronDescription }}</p> <p>{{ $t("cronScheduleDescription", { description: cronDescription }) }}</p>
<input <input
id="cron" id="cron"
v-model="maintenance.cron" v-model="maintenance.cron"
@ -331,6 +328,102 @@
</div> </div>
</div> </div>
</template> </template>
<template v-if="maintenance.strategy === 'single'">
<div class="my-3">
<div class="d-flex gap-2 flex-wrap">
<button
type="button"
class="btn btn-sm"
:class="
currentDurationMinutes === 15 ? 'btn-primary' : 'btn-outline-primary'
"
:disabled="currentDurationMinutes === 15"
@click="setQuickDuration(15)"
>
{{ $tc("minuteShort", 15, { n: 15 }) }}
</button>
<button
type="button"
class="btn btn-sm"
:class="
currentDurationMinutes === 30 ? 'btn-primary' : 'btn-outline-primary'
"
:disabled="currentDurationMinutes === 30"
@click="setQuickDuration(30)"
>
{{ $tc("minuteShort", 30, { n: 30 }) }}
</button>
<button
type="button"
class="btn btn-sm"
:class="
currentDurationMinutes === 60 ? 'btn-primary' : 'btn-outline-primary'
"
:disabled="currentDurationMinutes === 60"
@click="setQuickDuration(60)"
>
{{ $tc("hours", 1, { n: 1 }) }}
</button>
<button
type="button"
class="btn btn-sm"
:class="
currentDurationMinutes === 120 ? 'btn-primary' : 'btn-outline-primary'
"
:disabled="currentDurationMinutes === 120"
@click="setQuickDuration(120)"
>
{{ $tc("hours", 2, { n: 2 }) }}
</button>
<button
type="button"
class="btn btn-sm"
:class="
currentDurationMinutes === 240 ? 'btn-primary' : 'btn-outline-primary'
"
:disabled="currentDurationMinutes === 240"
@click="setQuickDuration(240)"
>
{{ $tc("hours", 4, { n: 4 }) }}
</button>
<button
type="button"
class="btn btn-sm"
:class="
currentDurationMinutes === 480 ? 'btn-primary' : 'btn-outline-primary'
"
:disabled="currentDurationMinutes === 480"
@click="setQuickDuration(480)"
>
{{ $tc("hours", 8, { n: 8 }) }}
</button>
<button
type="button"
class="btn btn-sm"
:class="
currentDurationMinutes === 720 ? 'btn-primary' : 'btn-outline-primary'
"
:disabled="currentDurationMinutes === 720"
@click="setQuickDuration(720)"
>
{{ $tc("hours", 12, { n: 12 }) }}
</button>
<button
type="button"
class="btn btn-sm"
:class="
currentDurationMinutes === 1440 ? 'btn-primary' : 'btn-outline-primary'
"
:disabled="currentDurationMinutes === 1440"
@click="setQuickDuration(1440)"
>
{{ $tc("hours", 24, { n: 24 }) }}
</button>
</div>
<div class="form-text">{{ $t("Sets end time based on start time") }}</div>
</div>
</template>
</div> </div>
</div> </div>
@ -511,6 +604,22 @@ export default {
hasStatusPages() { hasStatusPages() {
return this.showOnAllPages || this.selectedStatusPages.length > 0; return this.showOnAllPages || this.selectedStatusPages.length > 0;
}, },
/**
* Calculate the current duration in minutes between start and end dates
* @returns {number|null} Duration in minutes, or null if dates are invalid
*/
currentDurationMinutes() {
if (!this.maintenance.dateRange?.[0] || !this.maintenance.dateRange?.[1]) {
return null;
}
const start = new Date(this.maintenance.dateRange[0]);
const end = new Date(this.maintenance.dateRange[1]);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
return null;
}
return Math.round((end.getTime() - start.getTime()) / 60000);
},
}, },
watch: { watch: {
"$route.fullPath"() { "$route.fullPath"() {
@ -570,6 +679,19 @@ export default {
this.selectedStatusPages = []; this.selectedStatusPages = [];
if (this.isAdd) { if (this.isAdd) {
// Get current date/time in local timezone
const now = new Date();
const oneHourLater = new Date(now.getTime() + 60 * 60000);
const formatDateTime = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
this.maintenance = { this.maintenance = {
title: "", title: "",
description: "", description: "",
@ -578,7 +700,7 @@ export default {
cron: "30 3 * * *", cron: "30 3 * * *",
durationMinutes: 60, durationMinutes: 60,
intervalDay: 1, intervalDay: 1,
dateRange: [], dateRange: [formatDateTime(now), formatDateTime(oneHourLater)],
timeRange: [ timeRange: [
{ {
hours: 2, hours: 2,
@ -591,7 +713,7 @@ export default {
], ],
weekdays: [], weekdays: [],
daysOfMonth: [], daysOfMonth: [],
timezoneOption: null, timezoneOption: "SAME_AS_SERVER",
}; };
} else if (this.isEdit || this.isClone) { } else if (this.isEdit || this.isClone) {
this.$root.getSocket().emit("getMaintenance", this.$route.params.id, (res) => { this.$root.getSocket().emit("getMaintenance", this.$route.params.id, (res) => {
@ -655,6 +777,30 @@ export default {
} }
}, },
/**
* Set quick duration for single maintenance
* Calculates end time based on start time + duration in minutes
* @param {number} minutes Duration in minutes
* @returns {void}
*/
setQuickDuration(minutes) {
if (!this.maintenance.dateRange[0]) {
this.$root.toastError(this.$t("Please set start time first"));
return;
}
const startDate = new Date(this.maintenance.dateRange[0]);
const endDate = new Date(startDate.getTime() + minutes * 60000);
const year = endDate.getFullYear();
const month = String(endDate.getMonth() + 1).padStart(2, "0");
const day = String(endDate.getDate()).padStart(2, "0");
const hours = String(endDate.getHours()).padStart(2, "0");
const mins = String(endDate.getMinutes()).padStart(2, "0");
this.maintenance.dateRange[1] = `${year}-${month}-${day}T${hours}:${mins}`;
},
/** /**
* Handle form submission - show confirmation if no monitors selected * Handle form submission - show confirmation if no monitors selected
* @returns {void} * @returns {void}

View File

@ -342,14 +342,20 @@
<!-- Incident Date --> <!-- Incident Date -->
<div class="date mt-3"> <div class="date mt-3">
{{ $t("Date Created") }}: {{ $root.datetime(incident.createdDate) }} ({{ {{
dateFromNow(incident.createdDate) $t("dateCreatedAtFromNow", {
}}) date: $root.datetime(incident.createdDate),
fromNow: dateFromNow(incident.createdDate),
})
}}
<br /> <br />
<span v-if="incident.lastUpdatedDate"> <span v-if="incident.lastUpdatedDate">
{{ $t("Last Updated") }}: {{ $root.datetime(incident.lastUpdatedDate) }} ({{ {{
dateFromNow(incident.lastUpdatedDate) $t("lastUpdatedAtFromNow", {
}}) date: $root.datetime(incident.lastUpdatedDate),
fromNow: dateFromNow(incident.lastUpdatedDate),
})
}}
</span> </span>
</div> </div>
@ -572,7 +578,7 @@
</p> </p>
<div class="refresh-info mb-2"> <div class="refresh-info mb-2">
<div>{{ $t("Last Updated") }}: {{ lastUpdateTimeDisplay }}</div> <div>{{ $t("lastUpdatedAt", { date: lastUpdateTimeDisplay }) }}</div>
<div data-testid="update-countdown-text"> <div data-testid="update-countdown-text">
{{ $tc("statusPageRefreshIn", [updateCountdownText]) }} {{ $tc("statusPageRefreshIn", [updateCountdownText]) }}
</div> </div>