Merge branch 'master' into status-page-domain

# Conflicts:
#	server/database.js
This commit is contained in:
Louis Lam 2022-04-09 16:07:09 +08:00
commit 0afa0be5c2
20 changed files with 564 additions and 166 deletions

1
.npmrc Normal file
View file

@ -0,0 +1 @@
legacy-peer-deps=true

View file

@ -37,7 +37,6 @@ VPS is sponsored by Uptime Kuma sponsors on [Open Collective](https://opencollec
### 🐳 Docker
```bash
docker volume create uptime-kuma
docker run -d --restart=always -p 3001:3001 -v uptime-kuma:/app/data --name uptime-kuma louislam/uptime-kuma:1
```
@ -47,7 +46,10 @@ Browse to http://localhost:3001 after starting.
### 💪🏻 Non-Docker
Required Tools: Node.js >= 14, git and pm2.
Required Tools:
- [Node.js](https://nodejs.org/en/download/) >= 14
- [Git](https://git-scm.com/downloads)
- [pm2](https://pm2.keymetrics.io/) - For run in background
```bash
# Update your npm to the latest version
@ -67,11 +69,19 @@ npm install pm2 -g && pm2 install pm2-logrotate
# Start Server
pm2 start server/server.js --name uptime-kuma
```
Browse to http://localhost:3001 after starting.
More useful PM2 Commands
```bash
# If you want to see the current console output
pm2 monit
```
Browse to http://localhost:3001 after starting.
# If you want to add it to startup
pm2 save && pm2 startup
```
### Advanced Installation

View file

@ -5,7 +5,7 @@ version: '3.3'
services:
uptime-kuma:
image: louislam/uptime-kuma
image: louislam/uptime-kuma:1
container_name: uptime-kuma
volumes:
- ./uptime-kuma:/app/data

View file

@ -4,7 +4,10 @@ const fs = require("fs");
* to avoid the runtime deprecation warning triggered for using `fs.rmdirSync` with `{ recursive: true }` in Node.js v16,
* or the `recursive` property removing completely in the future Node.js version.
* See the link below.
* @link https://nodejs.org/docs/latest-v16.x/api/deprecations.html#dep0147-fsrmdirpath--recursive-true-
*
* @todo Once we drop the support for Node.js v14 (or at least versions before v14.14.0), we can safely replace this function with `fs.rmSync`, since `fs.rmSync` was add in Node.js v14.14.0 and currently we supports all the Node.js v14 versions that include the versions before the v14.14.0, and this function have almost the same signature with `fs.rmSync`.
* @link https://nodejs.org/docs/latest-v16.x/api/deprecations.html#dep0147-fsrmdirpath--recursive-true- the deprecation infomation of `fs.rmdirSync`
* @link https://nodejs.org/docs/latest-v16.x/api/fs.html#fsrmsyncpath-options the document of `fs.rmSync`
* @param {fs.PathLike} path Valid types for path values in "fs".
* @param {fs.RmDirOptions} [options] options for `fs.rmdirSync`, if `fs.rmSync` is available and property `recursive` is true, it will automatically have property `force` with value `true`.
*/

View file

@ -1,7 +1,5 @@
console.log("== Uptime Kuma Reset Password Tool ==");
console.log("Loading the database");
const Database = require("../server/database");
const { R } = require("redbean-node");
const readline = require("readline");
@ -13,8 +11,9 @@ const rl = readline.createInterface({
});
const main = async () => {
console.log("Connecting the database");
Database.init(args);
await Database.connect();
await Database.connect(false, false, true);
try {
// No need to actually reset the password for testing, just make sure no connection problem. It is ok for now.

View file

@ -36,7 +36,7 @@
"build-docker-nightly-alpine": "docker buildx build -f docker/dockerfile-alpine --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:nightly-alpine --target nightly . --push",
"build-docker-nightly-amd64": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:nightly-amd64 --target nightly . --push --progress plain",
"upload-artifacts": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:upload-artifact --build-arg VERSION --build-arg GITHUB_TOKEN --target upload-artifact . --progress plain",
"setup": "git checkout 1.13.1 && npm ci --production && npm run download-dist",
"setup": "git checkout 1.13.2 && npm ci --production && 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",

View file

@ -55,6 +55,7 @@ class Database {
"patch-monitor-basic-auth.sql": true,
"patch-status-page.sql": true,
"patch-proxy.sql": true,
"patch-monitor-expiry-notification.sql": true,
}
/**
@ -82,7 +83,7 @@ class Database {
console.log(`Data Dir: ${Database.dataDir}`);
}
static async connect(testMode = false) {
static async connect(testMode = false, autoloadModels = true, noLog = false) {
const acquireConnectionTimeout = 120 * 1000;
const Dialect = require("knex/lib/dialects/sqlite3/index.js");
@ -112,7 +113,10 @@ class Database {
// Auto map the model to a bean object
R.freeze(true);
await R.autoloadModels("./server/model");
if (autoloadModels) {
await R.autoloadModels("./server/model");
}
await R.exec("PRAGMA foreign_keys = ON");
if (testMode) {
@ -130,10 +134,12 @@ class Database {
// Read more: https://sqlite.org/pragma.html#pragma_synchronous
await R.exec("PRAGMA synchronous = FULL");
console.log("SQLite config:");
console.log(await R.getAll("PRAGMA journal_mode"));
console.log(await R.getAll("PRAGMA cache_size"));
console.log("SQLite Version: " + await R.getCell("SELECT sqlite_version()"));
if (!noLog) {
console.log("SQLite config:");
console.log(await R.getAll("PRAGMA journal_mode"));
console.log(await R.getAll("PRAGMA cache_size"));
console.log("SQLite Version: " + await R.getCell("SELECT sqlite_version()"));
}
}
static async patch() {

View file

@ -15,12 +15,17 @@ class Mattermost extends NotificationProvider {
let mattermostTestData = {
username: mattermostUserName,
text: msg,
}
await axios.post(notification.mattermostWebhookUrl, mattermostTestData)
};
await axios.post(notification.mattermostWebhookUrl, mattermostTestData);
return okMsg;
}
const mattermostChannel = notification.mattermostchannel.toLowerCase();
let mattermostChannel;
if (typeof notification.mattermostchannel === "string") {
mattermostChannel = notification.mattermostchannel.toLowerCase();
}
const mattermostIconEmoji = notification.mattermosticonemo;
const mattermostIconUrl = notification.mattermosticonurl;

View file

@ -3,6 +3,7 @@ const HttpProxyAgent = require("http-proxy-agent");
const HttpsProxyAgent = require("https-proxy-agent");
const SocksProxyAgent = require("socks-proxy-agent");
const { debug } = require("../src/util");
const server = require("./server");
class Proxy {
@ -144,6 +145,22 @@ class Proxy {
httpsAgent
};
}
/**
* Reload proxy settings for current monitors
* @returns {Promise<void>}
*/
static async reloadProxy() {
let updatedList = await R.getAssoc("SELECT id, proxy_id FROM monitor");
for (let monitorID in server.monitorList) {
let monitor = server.monitorList[monitorID];
if (updatedList[monitorID]) {
monitor.proxy_id = updatedList[monitorID].proxy_id;
}
}
}
}
/**

View file

@ -48,6 +48,27 @@ debug("Importing 2FA Modules");
const notp = require("notp");
const base32 = require("thirty-two");
/**
* `module.exports` (alias: `server`) should be inside this class, in order to avoid circular dependency issue.
* @type {UptimeKumaServer}
*/
class UptimeKumaServer {
/**
* Main monitor list
* @type {{}}
*/
monitorList = {};
entryPage = "dashboard";
async sendMonitorList(socket) {
let list = await getMonitorJSONList(socket.userID);
io.to(socket.userID).emit("monitorList", list);
return list;
}
}
const server = module.exports = new UptimeKumaServer();
console.log("Importing this project modules");
debug("Importing Monitor");
const Monitor = require("./model/monitor");
@ -115,20 +136,20 @@ if (config.demoMode) {
console.log("Creating express and socket.io instance");
const app = express();
let server;
let httpServer;
if (sslKey && sslCert) {
console.log("Server Type: HTTPS");
server = https.createServer({
httpServer = https.createServer({
key: fs.readFileSync(sslKey),
cert: fs.readFileSync(sslCert)
}, app);
} else {
console.log("Server Type: HTTP");
server = http.createServer(app);
httpServer = http.createServer(app);
}
const io = new Server(server);
const io = new Server(httpServer);
module.exports.io = io;
// Must be after io instantiation
@ -138,6 +159,7 @@ const databaseSocketHandler = require("./socket-handlers/database-socket-handler
const TwoFA = require("./2fa");
const StatusPage = require("./model/status_page");
const { cloudflaredSocketHandler, autoStart: cloudflaredAutoStart, stop: cloudflaredStop } = require("./socket-handlers/cloudflared-socket-handler");
const { proxySocketHandler } = require("./socket-handlers/proxy-socket-handler");
app.use(express.json());
@ -162,12 +184,6 @@ let totalClient = 0;
*/
let jwtSecret = null;
/**
* Main monitor list
* @type {{}}
*/
let monitorList = {};
/**
* Show Setup Page
* @type {boolean}
@ -190,8 +206,6 @@ try {
}
}
exports.entryPage = "dashboard";
(async () => {
Database.init(args);
await initDatabase(testMode);
@ -606,7 +620,7 @@ exports.entryPage = "dashboard";
await updateMonitorNotification(bean.id, notificationIDList);
await sendMonitorList(socket);
await server.sendMonitorList(socket);
await startMonitor(socket.userID, bean.id);
callback({
@ -635,7 +649,7 @@ exports.entryPage = "dashboard";
}
// Reset Prometheus labels
monitorList[monitor.id]?.prometheus()?.remove();
server.monitorList[monitor.id]?.prometheus()?.remove();
bean.name = monitor.name;
bean.type = monitor.type;
@ -669,7 +683,7 @@ exports.entryPage = "dashboard";
await restartMonitor(socket.userID, bean.id);
}
await sendMonitorList(socket);
await server.sendMonitorList(socket);
callback({
ok: true,
@ -689,7 +703,7 @@ exports.entryPage = "dashboard";
socket.on("getMonitorList", async (callback) => {
try {
checkLogin(socket);
await sendMonitorList(socket);
await server.sendMonitorList(socket);
callback({
ok: true,
});
@ -763,7 +777,7 @@ exports.entryPage = "dashboard";
try {
checkLogin(socket);
await startMonitor(socket.userID, monitorID);
await sendMonitorList(socket);
await server.sendMonitorList(socket);
callback({
ok: true,
@ -782,7 +796,7 @@ exports.entryPage = "dashboard";
try {
checkLogin(socket);
await pauseMonitor(socket.userID, monitorID);
await sendMonitorList(socket);
await server.sendMonitorList(socket);
callback({
ok: true,
@ -803,9 +817,9 @@ exports.entryPage = "dashboard";
console.log(`Delete Monitor: ${monitorID} User ID: ${socket.userID}`);
if (monitorID in monitorList) {
monitorList[monitorID].stop();
delete monitorList[monitorID];
if (monitorID in server.monitorList) {
server.monitorList[monitorID].stop();
delete server.monitorList[monitorID];
}
await R.exec("DELETE FROM monitor WHERE id = ? AND user_id = ? ", [
@ -818,7 +832,7 @@ exports.entryPage = "dashboard";
msg: "Deleted Successfully.",
});
await sendMonitorList(socket);
await server.sendMonitorList(socket);
// Clear heartbeat list on client
await sendImportantHeartbeatList(socket, monitorID, true, true);
@ -1118,52 +1132,6 @@ exports.entryPage = "dashboard";
}
});
socket.on("addProxy", async (proxy, proxyID, callback) => {
try {
checkLogin(socket);
const proxyBean = await Proxy.save(proxy, proxyID, socket.userID);
await sendProxyList(socket);
if (proxy.applyExisting) {
await restartMonitors(socket.userID);
}
callback({
ok: true,
msg: "Saved",
id: proxyBean.id,
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("deleteProxy", async (proxyID, callback) => {
try {
checkLogin(socket);
await Proxy.delete(proxyID, socket.userID);
await sendProxyList(socket);
await restartMonitors(socket.userID);
callback({
ok: true,
msg: "Deleted",
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("checkApprise", async (callback) => {
try {
checkLogin(socket);
@ -1190,8 +1158,8 @@ exports.entryPage = "dashboard";
// If the import option is "overwrite" it'll clear most of the tables, except "settings" and "user"
if (importHandle == "overwrite") {
// Stops every monitor first, so it doesn't execute any heartbeat while importing
for (let id in monitorList) {
let monitor = monitorList[id];
for (let id in server.monitorList) {
let monitor = server.monitorList[id];
await monitor.stop();
}
await R.exec("DELETE FROM heartbeat");
@ -1354,7 +1322,7 @@ exports.entryPage = "dashboard";
}
await sendNotificationList(socket);
await sendMonitorList(socket);
await server.sendMonitorList(socket);
}
callback({
@ -1444,6 +1412,7 @@ exports.entryPage = "dashboard";
statusPageSocketHandler(socket);
cloudflaredSocketHandler(socket);
databaseSocketHandler(socket);
proxySocketHandler(socket);
debug("added all socket handlers");
@ -1464,12 +1433,12 @@ exports.entryPage = "dashboard";
console.log("Init the server");
server.once("error", async (err) => {
httpServer.once("error", async (err) => {
console.error("Cannot listen: " + err.message);
await shutdownFunction();
});
server.listen(port, hostname, () => {
httpServer.listen(port, hostname, () => {
if (hostname) {
console.log(`Listening on ${hostname}:${port}`);
} else {
@ -1516,17 +1485,11 @@ async function checkOwner(userID, monitorID) {
}
}
async function sendMonitorList(socket) {
let list = await getMonitorJSONList(socket.userID);
io.to(socket.userID).emit("monitorList", list);
return list;
}
async function afterLogin(socket, user) {
socket.userID = user.id;
socket.join(user.id);
let monitorList = await sendMonitorList(socket);
let monitorList = await server.sendMonitorList(socket);
sendNotificationList(socket);
sendProxyList(socket);
@ -1609,11 +1572,11 @@ async function startMonitor(userID, monitorID) {
monitorID,
]);
if (monitor.id in monitorList) {
monitorList[monitor.id].stop();
if (monitor.id in server.monitorList) {
server.monitorList[monitor.id].stop();
}
monitorList[monitor.id] = monitor;
server.monitorList[monitor.id] = monitor;
monitor.start(io);
}
@ -1621,19 +1584,6 @@ async function restartMonitor(userID, monitorID) {
return await startMonitor(userID, monitorID);
}
async function restartMonitors(userID) {
// Fetch all active monitors for user
const monitors = await R.getAll("SELECT id FROM monitor WHERE active = 1 AND user_id = ?", [userID]);
for (const monitor of monitors) {
// Start updated monitor
await startMonitor(userID, monitor.id);
// Give some delays, so all monitors won't make request at the same moment when just start the server.
await sleep(getRandomInt(300, 1000));
}
}
async function pauseMonitor(userID, monitorID) {
await checkOwner(userID, monitorID);
@ -1644,8 +1594,8 @@ async function pauseMonitor(userID, monitorID) {
userID,
]);
if (monitorID in monitorList) {
monitorList[monitorID].stop();
if (monitorID in server.monitorList) {
server.monitorList[monitorID].stop();
}
}
@ -1656,7 +1606,7 @@ async function startMonitors() {
let list = await R.find("monitor", " active = 1 ");
for (let monitor of list) {
monitorList[monitor.id] = monitor;
server.monitorList[monitor.id] = monitor;
}
for (let monitor of list) {
@ -1671,8 +1621,8 @@ async function shutdownFunction(signal) {
console.log("Called signal: " + signal);
console.log("Stopping all monitors");
for (let id in monitorList) {
let monitor = monitorList[id];
for (let id in server.monitorList) {
let monitor = server.monitorList[id];
monitor.stop();
}
await sleep(2000);
@ -1686,7 +1636,7 @@ function finalFunction() {
console.log("Graceful shutdown successful!");
}
gracefulShutdown(server, {
gracefulShutdown(httpServer, {
signals: "SIGINT SIGTERM",
timeout: 30000, // timeout: 30 secs
development: false, // not in dev mode

View file

@ -0,0 +1,53 @@
const { checkLogin } = require("../util-server");
const { Proxy } = require("../proxy");
const { sendProxyList } = require("../client");
const server = require("../server");
module.exports.proxySocketHandler = (socket) => {
socket.on("addProxy", async (proxy, proxyID, callback) => {
try {
checkLogin(socket);
const proxyBean = await Proxy.save(proxy, proxyID, socket.userID);
await sendProxyList(socket);
if (proxy.applyExisting) {
await Proxy.reloadProxy();
await server.sendMonitorList(socket);
}
callback({
ok: true,
msg: "Saved",
id: proxyBean.id,
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("deleteProxy", async (proxyID, callback) => {
try {
checkLogin(socket);
await Proxy.delete(proxyID, socket.userID);
await sendProxyList(socket);
await Proxy.reloadProxy();
callback({
ok: true,
msg: "Deleted",
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
};

View file

@ -11,23 +11,23 @@
<table class="text-start">
<tbody>
<tr class="my-3">
<td class="px-3">Subject:</td>
<td class="px-3">{{ $t("Subject:") }}</td>
<td>{{ formatSubject(cert.subject) }}</td>
</tr>
<tr class="my-3">
<td class="px-3">Valid To:</td>
<td class="px-3">{{ $t("Valid To:") }}</td>
<td><Datetime :value="cert.validTo" /></td>
</tr>
<tr class="my-3">
<td class="px-3">Days Remaining:</td>
<td class="px-3">{{ $t("Days Remaining:") }}</td>
<td>{{ cert.daysRemaining }}</td>
</tr>
<tr class="my-3">
<td class="px-3">Issuer:</td>
<td class="px-3">{{ $t("Issuer:") }}</td>
<td>{{ formatSubject(cert.issuer) }}</td>
</tr>
<tr class="my-3">
<td class="px-3">Fingerprint:</td>
<td class="px-3">{{ $t("Fingerprint:") }}</td>
<td>{{ cert.fingerprint }}</td>
</tr>
</tbody>

View file

@ -20,11 +20,16 @@
</div>
<div v-if="errorMessage" class="mt-3">
Message:
{{ $t("Message:") }}
<textarea v-model="errorMessage" class="form-control" readonly></textarea>
</div>
<p v-if="installed === false">(Download cloudflared from <a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/">Cloudflare Website</a>)</p>
<i18n-t v-if="installed === false" tag="p" keypath="wayToGetCloudflaredURL">
<a
href="https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/"
target="_blank"
>{{ $t("cloudflareWebsite") }}</a>
</i18n-t>
</div>
<!-- If installed show token input -->
@ -44,7 +49,7 @@
<span v-if="!running" class="remove-token" @click="removeToken">{{ $t("Remove Token") }}</span>
</div>
Don't know how to get the token? Please read the guide:<br />
{{ $t("Don't know how to get the token? Please read the guide:") }}<br />
<a href="https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy-with-Cloudflare-Tunnel" target="_blank">
https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy-with-Cloudflare-Tunnel
</a>
@ -61,7 +66,7 @@
</button>
<Confirm ref="confirmStop" btn-style="btn-danger" :yes-text="$t('Stop') + ' cloudflared'" :no-text="$t('Cancel')" @yes="stop">
The current connection may be lost if you are currently connecting via Cloudflare Tunnel. Are you sure want to stop it? Type your current password to confirm it.
{{ $t("The current connection may be lost if you are currently connecting via Cloudflare Tunnel. Are you sure want to stop it? Type your current password to confirm it.") }}
<div class="mt-3">
<label for="current-password2" class="form-label">
@ -79,10 +84,10 @@
</div>
</div>
<h4 class="mt-4">Other Software</h4>
<h4 class="mt-4">{{ $t("Other Software") }}</h4>
<div>
For example: nginx, Apache and Traefik. <br />
Please read <a href="https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy" target="_blank">https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy</a>.
{{ $t("For example: nginx, Apache and Traefik.") }} <br />
{{ $t("Please read") }} <a href="https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy" target="_blank">https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy</a>.
</div>
</div>
</template>

View file

@ -331,21 +331,21 @@ export default {
dark: "dark",
Post: "Post",
"Please input title and content": "Please input title and content",
"Created": "Created",
Created: "Created",
"Last Updated": "Last Updated",
"Unpin": "Unpin",
Unpin: "Unpin",
"Switch to Light Theme": "Switch to Light Theme",
"Switch to Dark Theme": "Switch to Dark Theme",
"Show Tags": "Show Tags",
"Hide Tags": "Hide Tags",
"Description": "Description",
Description: "Description",
"No monitors available.": "No monitors available.",
"Add one": "Add one",
"No Monitors": "No Monitors",
"Untitled Group": "Untitled Group",
"Services": "Services",
"Discard": "Discard",
"Cancel": "Cancel",
Services: "Services",
Discard: "Discard",
Cancel: "Cancel",
"Powered by": "Powered by",
shrinkDatabaseDescription: "Trigger database VACUUM for SQLite. If your database is created after 1.10.0, AUTO_VACUUM is already enabled and this action is not needed.",
serwersms: "SerwerSMS.pl",
@ -379,4 +379,67 @@ export default {
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.",
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",
Valid: "Valid",
Invalid: "Invalid",
AccessKeyId: "AccessKey ID",
SecretAccessKey: "AccessKey Secret",
PhoneNumbers: "PhoneNumbers",
TemplateCode: "TemplateCode",
SignName: "SignName",
"Sms template must contain parameters: ": "Sms template must contain parameters: ",
"Bark Endpoint": "Bark Endpoint",
WebHookUrl: "WebHookUrl",
SecretKey: "SecretKey",
"For safety, must use secret key": "For safety, must use secret key",
"Device Token": "Device Token",
Platform: "Platform",
iOS: "iOS",
Android: "Android",
Huawei: "Huawei",
High: "High",
Retry: "Retry",
Topic: "Topic",
"WeCom Bot Key": "WeCom Bot Key",
"Setup Proxy": "Setup Proxy",
"Proxy Protocol": "Proxy Protocol",
"Proxy Server": "Proxy Server",
"Proxy server has authentication": "Proxy server has authentication",
User: "User",
Installed: "Installed",
"Not installed": "Not installed",
Running: "Running",
"Not running": "Not running",
"Remove Token": "Remove Token",
Start: "Start",
Stop: "Stop",
"Uptime Kuma": "Uptime Kuma",
"Add New Status Page": "Add New Status Page",
Slug: "Slug",
"Accept characters:": "Accept characters:",
"startOrEndWithOnly": "Start or end with {0} only",
"No consecutive dashes": "No consecutive dashes",
Next: "Next",
"The slug is already taken. Please choose another slug.": "The slug is already taken. Please choose another slug.",
"No Proxy": "No Proxy",
"HTTP Basic Auth": "HTTP Basic Auth",
"New Status Page": "New Status Page",
"Page Not Found": "Page Not Found",
"Reverse Proxy": "Reverse Proxy",
Backup: "Backup",
About: "About",
wayToGetCloudflaredURL: "(Download cloudflared from {0})",
cloudflareWebsite: "Cloudflare Website",
"Message:": "Message:",
"Don't know how to get the token? Please read the guide:": "Don't know how to get the token? Please read the guide:",
"The current connection may be lost if you are currently connecting via Cloudflare Tunnel. Are you sure want to stop it? Type your current password to confirm it.": "The current connection may be lost if you are currently connecting via Cloudflare Tunnel. Are you sure want to stop it? Type your current password to confirm it.",
"Other Software": "Other Software",
"For example: nginx, Apache and Traefik.": "For example: nginx, Apache and Traefik.",
"Please read": "Please read",
"Subject:": "Subject:",
"Valid To:": "Valid To:",
"Days Remaining:": "Days Remaining:",
"Issuer:": "Issuer:",
"Fingerprint:": "Fingerprint:",
"No status pages": "No status pages",
};

View file

@ -88,8 +88,8 @@ export default {
Dark: "黑暗",
Auto: "自动",
"Theme - Heartbeat Bar": "主题 - 心跳栏",
Normal: "正常显示",
Bottom: "靠下显示",
Normal: "正常", // 此处还供 Gorush 的通知优先级功能使用,不应翻译为“正常显示”
Bottom: "靠下",
None: "不显示",
Timezone: "时区",
"Search Engine Visibility": "搜索引擎可见性",
@ -373,4 +373,80 @@ export default {
"For safety, must use secret key": "出于安全考虑,必须使用加签密钥",
WeCom: "企业微信群机器人",
"WeCom Bot Key": "企业微信群机器人 Key",
PushByTechulus: "Push by Techulus",
gorush: "Gorush",
alerta: "Alerta",
alertaApiEndpoint: "API 接入点",
alertaEnvironment: "环境参数",
alertaApiKey: "API Key",
alertaAlertState: "报警时的严重性",
alertaRecoverState: "恢复后的严重性",
deleteStatusPageMsg: "您确认要删除此状态页吗?",
Proxies: "代理",
default: "默认",
enabled: "启用",
setAsDefault: "设为默认",
deleteProxyMsg: "您确认要在所有监控项中删除此代理吗?",
proxyDescription: "代理必须配置到至少一个监控项后才会工作。",
enableProxyDescription: "此代理必须启用才能对监控项的网络请求起作用。您可以通过修改激活状态,临时在所有监控项中禁用此代理。",
setAsDefaultProxyDescription: "此代理会对新创建的监控项默认激活,您仍可以在监控项配置中单独禁用此代理。",
"Proxy Protocol": "代理协议",
"Proxy Server": "代理服务器",
"Server Address": "服务器地址",
"Certificate Chain": "证书链",
Valid: "有效",
Invalid: "无效",
AccessKeyId: "AccessKey ID",
SecretAccessKey: "AccessKey Secret",
/* 以下为阿里云短信服务 API Dysms#SendSms 的参数 */
PhoneNumbers: "PhoneNumbers",
TemplateCode: "TemplateCode",
SignName: "SignName",
/* 以上为阿里云短信服务 API Dysms#SendSms 的参数 */
"Bark Endpoint": "Bark 接入点",
"Device Token": "Apple Device Token",
Platform: "平台",
iOS: "iOS",
Android: "Android",
Huawei: "华为",
High: "高",
Retry: "重试次数",
Topic: "Gorush Topic",
"Setup Proxy": "设置代理",
"Proxy server has authentication": "代理服务器启用了身份验证功能",
User: "用户名",
Installed: "已安装",
"Not installed": "未安装",
Running: "运行中",
"Not running": "未运行",
"Message:": "信息:",
wayToGetCloudflaredURL: "(可从 {0} 下载 cloudflared",
cloudflareWebsite: "Cloudflare 网站",
"Don't know how to get the token? Please read the guide:": "不知道如何获取 Token请阅读指南",
"The current connection may be lost if you are currently connecting via Cloudflare Tunnel. Are you sure want to stop it? Type your current password to confirm it.": "如果您正在通过 Cloudflare Tunnel 访问网站,则停止可能会导致当前连接断开。您确定要停止吗?请输入密码以确认。",
"Other Software": "其他软件",
"For example: nginx, Apache and Traefik.": "例如nginx、Apache 和 Traefik。",
"Please read": "请阅读",
"Remove Token": "移除 Token",
Start: "启动",
Stop: "停止",
"Uptime Kuma": "Uptime Kuma",
"Add New Status Page": "添加新的状态页",
Slug: "路径",
"Accept characters:": "可接受的字符:",
"startOrEndWithOnly": "开头和结尾必须为 {0}",
"No consecutive dashes": "不能有连续的破折号",
Next: "下一步",
"The slug is already taken. Please choose another slug.": "该路径已被使用。请选择其他路径。",
"No Proxy": "无代理",
"HTTP Basic Auth": "HTTP 基础身份验证",
"New Status Page": "新的状态页",
"Page Not Found": "状态页未找到",
"Reverse Proxy": "反向代理",
"Subject:": "颁发给:",
"Valid To:": "有效期至:",
"Days Remaining:": "剩余有效天数:",
"Issuer:": "颁发者:",
"Fingerprint:": "指纹:",
"No status pages": "无状态页",
};

View file

@ -200,4 +200,183 @@ export default {
line: "Line Messenger",
mattermost: "Mattermost",
deleteStatusPageMsg: "是否確定刪除這個 Status Page",
"Push URL": "推送網址",
needPushEvery: "您應每 {0} 秒呼叫此網址。",
pushOptionalParams: "選填參數:{0}",
defaultNotificationName: "我的 {notification} 通知 ({number})",
here: "此處",
Required: "必填",
"Bot Token": "機器人權杖",
wayToGetTelegramToken: "您可以從 {0} 取得 Token。",
"Chat ID": "聊天 ID",
supportTelegramChatID: "支援 對話/群組/頻道的聊天 ID",
wayToGetTelegramChatID: "傳送訊息給機器人,並前往以下網址以取得您的 chat ID",
"YOUR BOT TOKEN HERE": "在此填入您的機器人權杖",
chatIDNotFound: "找不到 Chat ID請先傳送訊息給機器人",
"Post URL": "Post 網址",
"Content Type": "Content Type",
webhookJsonDesc: "{0} 適合任何現代的 HTTP 伺服器,如 Express.js",
webhookFormDataDesc: "{multipart} 適合 PHP。 JSON 必須先經由 {decodeFunction} 剖析。",
secureOptionNone: "無 / STARTTLS (25, 587)",
secureOptionTLS: "TLS (465)",
"Ignore TLS Error": "忽略 TLS 錯誤",
"From Email": "寄件人",
emailCustomSubject: "自訂主旨",
"To Email": "收件人",
smtpCC: "CC",
smtpBCC: "BCC",
"Discord Webhook URL": "Discord Webhook 網址",
wayToGetDiscordURL: "您可以前往伺服器設定 -> 整合 -> Webhook -> 新 Webhook 以取得",
"Bot Display Name": "機器人顯示名稱",
"Prefix Custom Message": "前綴自訂訊息",
"Webhook URL": "Webhook 網址",
wayToGetTeamsURL: "您可以前往此頁面以了解如何建立 Webhook 網址 {0}。",
Number: "號碼",
Recipients: "收件人",
needSignalAPI: "您需要有 REST API 的 Signal 客戶端。",
wayToCheckSignalURL: "您可以前往下列網址以了解如何設定:",
signalImportant: "注意: 不得混合收件人的群組和號碼!",
"Application Token": "應用程式權杖",
"Server URL": "伺服器網址",
Priority: "優先度",
"Icon Emoji": "Emoji 圖示",
"Channel Name": "頻道名稱",
"Uptime Kuma URL": "Uptime Kuma 網址",
aboutWebhooks: "更多關於 Webhook 的資訊: {0}",
aboutChannelName: "如果您不想使用 Webhook 頻道,請在 {0} 頻道名稱欄位填入您想使用的頻道。例如: #其他頻道",
aboutKumaURL: "如果您未填入 Uptime Kuma 網址。將預設使用專案 Github 頁面。",
emojiCheatSheet: "Emoji 一覽表: {0}",
PushByTechulus: "Push by Techulus",
clicksendsms: "ClickSend SMS",
GoogleChat: "Google Chat (僅限 Google Workspace)",
"User Key": "使用者金鑰",
Device: "裝置",
"Message Title": "訊息標題",
"Notification Sound": "通知音效",
"More info on:": "更多資訊: {0}",
pushoverDesc1: "緊急優先度 (2) 的重試間隔為 30 秒並且會在 1 小時後過期。",
pushoverDesc2: "如果您想要傳送通知到不同裝置,請填寫裝置欄位。",
"SMS Type": "簡訊類型",
octopushTypePremium: "Premium (快速 - 建議用於警報)",
octopushTypeLowCost: "Low Cost (緩慢 - 有時會被營運商阻擋)",
checkPrice: "查看 {0} 價格:",
apiCredentials: "API 認證",
octopushLegacyHint: "您使用的是舊版的 Octopush (2011-2020) 還是新版?",
"Check octopush prices": "查看 octopush 價格 {0}。",
octopushPhoneNumber: "電話號碼 (intl 格式,例如:+33612345678) ",
octopushSMSSender: "簡訊寄件人名稱3-11位英數字元及空白 (a-zA-Z0-9)",
"LunaSea Device ID": "LunaSea 裝置 ID",
"Apprise URL": "Apprise 網址",
"Example:": "範例:{0}",
"Read more:": "深入瞭解:{0}",
"Status:": "狀態:{0}",
"Read more": "深入瞭解",
appriseInstalled: "已安裝 Apprise。",
appriseNotInstalled: "尚未安裝 Apprise。{0}",
"Access Token": "存取權杖",
"Channel access token": "頻道存取權杖",
"Line Developers Console": "Line 開發者控制台",
lineDevConsoleTo: "Line 開發者控制台 - {0}",
"Basic Settings": "基本設定",
"User ID": "使用者 ID",
"Messaging API": "Messaging API",
wayToGetLineChannelToken: "首先,前往 {0},建立 provider 和 channel (Messaging API)。接著您就可以從上面提到的選單項目中取得頻道存取權杖及使用者 ID。",
"Icon URL": "圖示網址",
aboutIconURL: "您可以在 \"圖示網址\" 中提供圖片網址以覆蓋預設個人檔案圖片。若已設定 Emoji 圖示,將忽略此設定。",
aboutMattermostChannelName: "您可以在 \"頻道名稱\" 欄位中填寫頻道名稱以覆蓋 Webhook 的預設頻道。必須在 Mattermost 的 Webhook 設定中啟用。例如:#其他頻道",
matrix: "Matrix",
promosmsTypeEco: "SMS ECO - 便宜,但是很慢且經常過載。僅限位於波蘭的收件人。",
promosmsTypeFlash: "SMS FLASH - 訊息會自動在收件人的裝置上顯示。僅限位於波蘭的收件人。",
promosmsTypeFull: "SMS FULL - 高級版,您可以使用您的寄件人名稱 (必須先註冊名稱。對於警報來說十分可靠。",
promosmsTypeSpeed: "SMS SPEED - 系統中的最高優先度。快速、可靠,但昂貴 (約 SMS FULL 的兩倍價格)。",
promosmsPhoneNumber: "電話號碼 (若收件人位於波蘭則無需輸入區域代碼)",
promosmsSMSSender: "簡訊寄件人名稱預先註冊的名稱或以下的預設名稱InfoSMS、SMS Info、MaxSMS、INFO、SMS",
"Feishu WebHookUrl": "飛書 WebHook 網址",
matrixHomeserverURL: "Homeserver 網址 (開頭為 http(s)://,結尾可能帶連接埠)",
"Internal Room Id": "Internal Room ID",
matrixDesc1: "您可以在 Matrix 客戶端的房間設定中的進階選項找到 internal room ID。應該看起來像 !QMdRCpUIfLwsfjxye6:home.server。",
matrixDesc2: "使用您自己的 Matrix 使用者存取權杖將賦予存取您的帳號和您加入的房間的完整權限。建議建立新使用者,並邀請至您想要接收通知的房間中。您可以執行 {0} 以取得存取權杖",
Method: "方法",
Body: "主體",
Headers: "標頭",
PushUrl: "Push URL",
HeadersInvalidFormat: "要求標頭不是有效的 JSON",
BodyInvalidFormat: "請求主體不是有效的 JSON",
"Monitor History": "監測器歷史紀錄",
clearDataOlderThan: "保留 {0} 天內的監測器歷史紀錄。",
PasswordsDoNotMatch: "密碼不相符。",
records: "記錄",
"One record": "一項記錄",
"Showing {from} to {to} of {count} records": "正在顯示 {count} 項記錄中的 {from} 至 {to} 項",
steamApiKeyDescription: "若要監測 Steam 遊戲伺服器,您將需要 Steam Web-API 金鑰。您可以在此註冊您的 API 金鑰:",
"Current User": "目前使用者",
recent: "最近",
Done: "完成",
Info: "資訊",
Security: "安全性",
"Steam API Key": "Steam API 金鑰",
"Shrink Database": "壓縮資料庫",
"Pick a RR-Type...": "選擇資源記錄類型...",
"Pick Accepted Status Codes...": "選擇可接受的狀態碼...",
Default: "預設",
"HTTP Options": "HTTP 選項",
"Create Incident": "建立事件",
Title: "標題",
Content: "內容",
Style: "樣式",
info: "資訊",
warning: "警告",
danger: "危險",
primary: "主要",
light: "淺色",
dark: "暗色",
Post: "發佈",
"Please input title and content": "請輸入標題及內容",
Created: "建立",
"Last Updated": "最後更新",
Unpin: "取消釘選",
"Switch to Light Theme": "切換至淺色佈景主題",
"Switch to Dark Theme": "切換至深色佈景主題",
"Show Tags": "顯示標籤",
"Hide Tags": "隱藏標籤",
Description: "說明",
"No monitors available.": "沒有可用的監測器。",
"Add one": "新增一個",
"No Monitors": "無監測器",
"Untitled Group": "未命名群組",
Services: "服務",
Discard: "捨棄",
Cancel: "取消",
"Powered by": "技術支援",
shrinkDatabaseDescription: "觸發 SQLite 的資料庫清理 (VACUUM)。如果您的資料庫是在 1.10.0 版本後建立AUTO_VACUUM 已自動啟用,則無需此操作。",
serwersms: "SerwerSMS.pl",
serwersmsAPIUser: "API 使用者名稱 (包括 webapi_ 前綴)",
serwersmsAPIPassword: "API 密碼",
serwersmsPhoneNumber: "電話號碼",
serwersmsSenderName: "SMS 寄件人名稱 (由客戶入口網站註冊)",
stackfield: "Stackfield",
smtpDkimSettings: "DKIM 設定",
smtpDkimDesc: "請參考 Nodemailer DKIM {0} 使用方式。",
documentation: "文件",
smtpDkimDomain: "網域名稱",
smtpDkimKeySelector: "DKIM 選取器",
smtpDkimPrivateKey: "私密金鑰",
smtpDkimHashAlgo: "雜湊演算法 (選填)",
smtpDkimheaderFieldNames: "要簽署的郵件標頭 (選填)",
smtpDkimskipFields: "不簽署的郵件標頭 (選填)",
gorush: "Gorush",
alerta: "Alerta",
alertaApiEndpoint: "API Endpoint",
alertaEnvironment: "環境",
alertaApiKey: "API 金鑰",
alertaAlertState: "警示狀態",
alertaRecoverState: "恢復狀態",
Proxies: "代理伺服器",
default: "預設",
enabled: "啟用",
setAsDefault: "設為預設",
deleteProxyMsg: "您確定要為所有監測器刪除此代理伺服器嗎?",
proxyDescription: "必須將代理伺服器指派給監測器才能運作。",
enableProxyDescription: "此代理伺服器在啟用前不會在監測器上生效,您可以藉由控制啟用狀態來暫時對所有的監測器停用代理伺服器。",
setAsDefaultProxyDescription: "預設情況下,新監測器將啟用此代理伺服器。您仍可分別停用各監測器的代理伺服器。",
};

View file

@ -239,11 +239,13 @@ export default {
"rocket.chat": "Rocket.Chat",
pushover: "Pushover",
pushy: "Pushy",
PushByTechulus: "Push by Techulus",
octopush: "Octopush",
promosms: "PromoSMS",
clicksendsms: "ClickSend SMS",
lunasea: "LunaSea",
apprise: "Apprise (支援 50 種以上的通知服務)",
GoogleChat: "Google Chat (僅限 Google Workspace)",
pushbullet: "Pushbullet",
line: "Line Messenger",
mattermost: "Mattermost",
@ -352,5 +354,30 @@ export default {
serwersmsAPIPassword: "API 密碼",
serwersmsPhoneNumber: "電話號碼",
serwersmsSenderName: "SMS 寄件人名稱 (由客戶入口網站註冊)",
"stackfield": "Stackfield",
stackfield: "Stackfield",
smtpDkimSettings: "DKIM 設定",
smtpDkimDesc: "請參考 Nodemailer DKIM {0} 使用方式。",
documentation: "文件",
smtpDkimDomain: "網域名稱",
smtpDkimKeySelector: "DKIM 選取器",
smtpDkimPrivateKey: "私密金鑰",
smtpDkimHashAlgo: "雜湊演算法 (選填)",
smtpDkimheaderFieldNames: "要簽署的郵件標頭 (選填)",
smtpDkimskipFields: "不簽署的郵件標頭 (選填)",
gorush: "Gorush",
alerta: "Alerta",
alertaApiEndpoint: "API Endpoint",
alertaEnvironment: "環境",
alertaApiKey: "API 金鑰",
alertaAlertState: "警示狀態",
alertaRecoverState: "恢復狀態",
deleteStatusPageMsg: "您確定要刪除此狀態頁嗎?",
Proxies: "代理伺服器",
default: "預設",
enabled: "啟用",
setAsDefault: "設為預設",
deleteProxyMsg: "您確定要為所有監測器刪除此代理伺服器嗎?",
proxyDescription: "必須將代理伺服器指派給監測器才能運作。",
enableProxyDescription: "此代理伺服器在啟用前不會在監測器上生效,您可以藉由控制啟用狀態來暫時對所有的監測器停用代理伺服器。",
setAsDefaultProxyDescription: "預設情況下,新監測器將啟用此代理伺服器。您仍可分別停用各監測器的代理伺服器。",
};

View file

@ -21,7 +21,9 @@
<div class="form-text">
<ul>
<li>{{ $t("Accept characters:") }} <mark>a-z</mark> <mark>0-9</mark> <mark>-</mark></li>
<li>{{ $t("Start or end with") }} <mark>a-z</mark> <mark>0-9</mark> only</li>
<i18n-t tag="li" keypath="startOrEndWithOnly">
<mark>a-z</mark> <mark>0-9</mark>
</i18n-t>
<li>{{ $t("No consecutive dashes") }} <mark>--</mark></li>
</ul>
</div>

View file

@ -232,31 +232,33 @@
</button>
<!-- Proxies -->
<h2 class="mt-5 mb-2">{{ $t("Proxies") }}</h2>
<p v-if="$root.proxyList.length === 0">
{{ $t("Not available, please setup.") }}
</p>
<div v-if="monitor.type === 'http' || monitor.type === 'keyword'">
<h2 class="mt-5 mb-2">{{ $t("Proxy") }}</h2>
<p v-if="$root.proxyList.length === 0">
{{ $t("Not available, please setup.") }}
</p>
<div v-if="$root.proxyList.length > 0" class="form-check form-switch my-3">
<input id="proxy-disable" v-model="monitor.proxyId" :value="null" name="proxy" class="form-check-input" type="radio">
<label class="form-check-label" for="proxy-disable">{{ $t("No Proxy") }}</label>
<div v-if="$root.proxyList.length > 0" class="form-check my-3">
<input id="proxy-disable" v-model="monitor.proxyId" :value="null" name="proxy" class="form-check-input" type="radio">
<label class="form-check-label" for="proxy-disable">{{ $t("No Proxy") }}</label>
</div>
<div v-for="proxy in $root.proxyList" :key="proxy.id" class="form-check my-3">
<input :id="`proxy-${proxy.id}`" v-model="monitor.proxyId" :value="proxy.id" name="proxy" class="form-check-input" type="radio">
<label class="form-check-label" :for="`proxy-${proxy.id}`">
{{ proxy.host }}:{{ proxy.port }} ({{ proxy.protocol }})
<a href="#" @click="$refs.proxyDialog.show(proxy.id)">{{ $t("Edit") }}</a>
</label>
<span v-if="proxy.default === true" class="badge bg-primary ms-2">{{ $t("default") }}</span>
</div>
<button class="btn btn-primary me-2" type="button" @click="$refs.proxyDialog.show()">
{{ $t("Setup Proxy") }}
</button>
</div>
<div v-for="proxy in $root.proxyList" :key="proxy.id" class="form-check form-switch my-3">
<input :id="`proxy-${proxy.id}`" v-model="monitor.proxyId" :value="proxy.id" name="proxy" class="form-check-input" type="radio">
<label class="form-check-label" :for="`proxy-${proxy.id}`">
{{ proxy.host }}:{{ proxy.port }} ({{ proxy.protocol }})
<a href="#" @click="$refs.proxyDialog.show(proxy.id)">{{ $t("Edit") }}</a>
</label>
<span v-if="proxy.default === true" class="badge bg-primary ms-2">{{ $t("default") }}</span>
</div>
<button class="btn btn-primary me-2" type="button" @click="$refs.proxyDialog.show()">
{{ $t("Setup Proxy") }}
</button>
<!-- HTTP Options -->
<template v-if="monitor.type === 'http' || monitor.type === 'keyword' ">
<h2 class="mt-5 mb-2">{{ $t("HTTP Options") }}</h2>

View file

@ -12,7 +12,7 @@
<div class="shadow-box">
<template v-if="$root.statusPageListLoaded">
<span v-if="Object.keys($root.statusPageList).length === 0" class="d-flex align-items-center justify-content-center my-3">
No status pages
{{ $t("No status pages") }}
</span>
<!-- use <a> instead of <router-link>, because the heartbeat won't load. -->