Compare commits

...

146 Commits

Author SHA1 Message Date
Louis Lam
b1170211b7 Update to 1.19.0-beta.1 2022-12-05 19:24:04 +08:00
Louis Lam
eadf2c810a Fix check version 2022-12-05 19:17:24 +08:00
Louis Lam
8aa97635ec Improve the clear filter button 2022-12-05 18:21:16 +08:00
Louis Lam
ee1a56caae Update /test-webhook and reevaluate sensitive fields 2022-12-05 18:18:19 +08:00
Louis Lam
e886df4788 Fix typo 2022-12-05 17:55:45 +08:00
Louis Lam
5196abfd36 Merge remote-tracking branch 'origin/master' into feat/add-auth-header-to-webhook-notification-#1919 2022-12-05 17:52:02 +08:00
Louis Lam
3e68cf2a1c Specify Accept-Encoding for axios request (Fix #2253) 2022-12-04 22:55:05 +08:00
Louis Lam
0ab82e6de3 Generate random nightly version 2022-12-04 22:44:50 +08:00
Louis Lam
8cdbe37f6f Update core-js 2022-12-04 21:41:08 +08:00
Louis Lam
28d13e198c Merge pull request #2350 from MrEddX/bulgarian
Bulgarian
2022-11-26 20:52:33 +08:00
MrEddX
14a062804e Update bg-BG.js
- Translation fixes
2022-11-25 22:00:52 +02:00
MrEddX
cf3e03ab40 Update bg-BG.js
- Added new  fields
- Translated new fields
- Fixed some typos
2022-11-25 21:56:00 +02:00
Louis Lam
191f3ad53b Merge pull request #2339 from jbrunner/fix-2296
Add socks5h support
2022-11-25 16:24:21 +08:00
Louis Lam
370d522920 Pin dependency of axios-ntlm to 1.3.0. As 1.3.1 causes error 2022-11-25 14:00:33 +08:00
Louis Lam
e0a1ad8a1c Update dependencies and drop start-server-watch-dev as it is unstable 2022-11-25 01:32:33 +08:00
Louis Lam
9720006934 Merge pull request #2151 from Computroniks/feature/#1817-add-mysql-monitor
Feat  Add MySQL/MariaDB monitor #1817
2022-11-25 01:27:40 +08:00
Joshua Brunner
cd270bd8b5 Add socks5h support
Add socks5h support as an extra option to not break previous socks5 implementation.
Allows to toggle between socks5 and socks5h explicit.

Fixes #2296
2022-11-22 11:18:16 +01:00
Louis Lam
bc3229828e Merge remote-tracking branch 'origin/master' 2022-11-20 00:11:38 +08:00
Louis Lam
a5f23b9839 Update Apprise from 1.0 to 1.2 2022-11-20 00:07:19 +08:00
Matthew Nickson
2052fa175f Merge branch 'master' into feature/#1817-add-mysql-monitor
Signed-off-by: Matthew Nickson <mnickson@sidingsmedia.com>
2022-11-17 19:04:14 +00:00
Matthew Nickson
15b63c82c3 Merge remote-tracking branch 'upstream/master' into feature/#1817-add-mysql-monitor
Signed-off-by: Matthew Nickson <mnickson@sidingsmedia.com>
2022-11-17 18:46:58 +00:00
Matthew Nickson
b053bc61ce Fixed MySQL monitor to close connection
Signed-off-by: Matthew Nickson <mnickson@sidingsmedia.com>
2022-11-17 18:34:02 +00:00
Jan Hartje
258ff56962 Merge branch 'louislam:master' into feat/add-auth-header-to-webhook-notification-#1919 2022-11-14 20:17:36 +01:00
Louis Lam
cb4e512dc6 Merge pull request #2316 from Dafnik/patch-fix-link-preview-description
Fix 'undefined' in link preview generation
2022-11-15 02:37:28 +08:00
Dafnik
4042c26390 Fix 'undefined' in link preview generation 2022-11-14 18:05:52 +01:00
Louis Lam
5da2315534 Merge pull request #2310 from Ealrang/patch-1
Update zh-CN.js
2022-11-14 22:02:43 +08:00
Ealrang
204015f1f5 Update zh-CN.js
Correct typos
2022-11-12 22:15:04 +08:00
Louis Lam
cc6d17d2e0 Merge pull request #1964 from minhhoangvn/feat/add-gRPC-protocol
Feat/add gRPC protocol
2022-11-11 12:34:35 +08:00
Louis Lam
68862c0b3f Fix Pushbullet do not handle general message correctly and fix name convention (Close #1890) 2022-11-01 20:27:40 +08:00
Louis Lam
fd15e7c2dc Merge remote-tracking branch 'origin/master' into ntfy-icon
# Conflicts:
#	server/notification-providers/ntfy.js
#	src/components/notifications/Ntfy.vue
#	src/languages/en.js
2022-10-31 17:10:20 +08:00
Louis Lam
5c4cf68937 Merge pull request #2260 from m-kiszka/smseagle
Added support for SMSEagle device API notifications
2022-10-31 17:03:34 +08:00
Louis Lam
214ddc264d Fix mistake 2022-10-29 23:40:09 +08:00
Louis Lam
2ea71839d1 Add npm run start-server-watch-dev for watching server code changes and restart (Node.js 19 only) 2022-10-29 23:37:05 +08:00
Louis Lam
54efde8185 Update socket.io and remove an useless event listener 2022-10-29 23:29:33 +08:00
Louis Lam
705124d4ac Merge pull request #2274 from 5idereal/patch-1
update zh-tw translation
2022-10-28 16:50:24 +08:00
5idereal
1cb6940590 update zh-tw translation 2022-10-28 15:50:00 +08:00
Louis Lam
0f8ad288f3 Merge pull request #2255 from b-reich/update-german-translation
Add german translations
2022-10-27 16:47:11 +08:00
Adam Stachowicz
434174d350 I18n PL update (#2264)
* [PL] Only formatting by ESLint for now

* Translate new i18n keys to polish + small grammar/typo fixes

* ESLint again after npm install...
2022-10-27 16:46:32 +08:00
minhhn3
3d1237ed53 fix: resolve conflict 2022-10-26 20:50:34 +07:00
minhhn3
b459408b10 fix: resolve conflict 2022-10-26 20:41:21 +07:00
Benjamin Reich
f04fe4d230 add new german translations 2022-10-25 10:50:58 +02:00
Louis Lam
e579610426 Merge pull request #2265 from Saibamen/patch-1
Add info about `npm install` to translators
2022-10-25 14:32:43 +08:00
Louis Lam
b115d3f8b9 Merge pull request #2266 from Saibamen/fix_linters
Fix 'dayjs' is never used warning
2022-10-25 14:23:59 +08:00
Adam Stachowicz
134b3b8ac1 Fix 'dayjs' is never used warning 2022-10-25 01:27:25 +02:00
Adam Stachowicz
e7e7751e7b Correct order 2022-10-24 23:38:38 +02:00
Adam Stachowicz
5cd58e6fa3 Add info about npm install to translators
Without this, you can have wrong indentation from ESLint
2022-10-24 23:36:21 +02:00
Marcin Kiszka
08763b700a Added support for SMSEagle device API notifications 2022-10-24 12:45:56 +02:00
Marcin Kiszka
781f855921 [empty commit] pull request for Added support for SMSEagle device API notifications 2022-10-24 12:44:29 +02:00
Louis Lam
9e81fe120f Merge pull request #2235 from dave9123/patch-3
Update id-ID.js
2022-10-20 15:57:39 +08:00
Dave
c0e67b6de9 Update id-ID.js 2022-10-17 20:09:25 +07:00
Louis Lam
a17084f75d Merge pull request #2229 from falentio/fix-id-lang
fix typos in id lang
2022-10-16 16:25:35 +08:00
Louis Lam
e4fe7b802a Update bg-BG.js #2228 2022-10-16 16:24:39 +08:00
falentio
5761bc9b90 fix typos in id lang 2022-10-16 13:49:25 +07:00
MrEddX
92ea019fd4 Update bg-BG.js
- Added new  fields
- Translated new fields
2022-10-16 07:21:23 +03:00
Cyril59310
a774b37369 Update FR language + fixed daytime error (#2226)
* Update FR language

* fix a daytime error + add for translation

* Update language file FR + fixed daytime error
2022-10-16 01:42:28 +08:00
Louis Lam
06755f249d Update to 1.19.0-beta.0 2022-10-15 21:02:56 +08:00
Louis Lam
64f84eb118 Update Details.vue's button styles 2022-10-15 21:01:48 +08:00
Louis Lam
afe12ccf24 A complete maintenance planning system (#1213)
A complete maintenance planning system from karelkryda/master
2022-10-15 20:54:28 +08:00
Louis Lam
6f4424de28 Remove unused language keys 2022-10-15 20:44:02 +08:00
Louis Lam
24cb212a37 Fix recurring 2022-10-15 20:15:50 +08:00
Louis Lam
d8a676abb6 Implement recurring day of month and day of week 2022-10-15 18:49:09 +08:00
Louis Lam
0b8d4cdaac Generate Next Timeslot for recurring interval 2022-10-15 17:17:26 +08:00
Louis Lam
268cbdbf8d Merge remote-tracking branch 'origin/master' into maintenance
# Conflicts:
#	server/server.js
#	src/components/settings/General.vue
2022-10-15 15:57:39 +08:00
Louis Lam
b60dde0b2d Update SQLite 2022-10-15 15:18:54 +08:00
Louis Lam
aecf95864e Add index for maintenance tables 2022-10-14 13:26:41 +08:00
Louis Lam
c662d259b0 Firefox Better Support #2206 2022-10-13 19:28:02 +08:00
Matthew Nickson
f459ea845c Added #2182 Add support for custom radius ports (#2197)
This commit adds support for the port to be specified when using the
radius monitor type. A check has been implemented to ensure that a null
value is not passed to the radius check function as could occur with
monitors that were created before this change was introduced. The
default port of 1812 is displayed when the user selects the radius
monitor in much the same way as the DNS port is handled. The port was
not included in the hostname in the form hostname:port in order to avoid
issues with IPv6 addresses and monitors that had been created before
this change was implemented.

Signed-off-by: Matthew Nickson <mnickson@sidingsmedia.com>

Signed-off-by: Matthew Nickson <mnickson@sidingsmedia.com>
2022-10-13 00:32:05 +08:00
Louis Lam
b24c75eec5 Merge pull request #2162 from UltraWelfare/fix-entry-page-redirect
Fixed entry route not redirecting correctly.
2022-10-13 00:28:34 +08:00
MagicFun1241
cb5f90aa89 Update Russian locale (#2218)
* Update ru-RU.js

* Remove duplicates

* Remove duplicates x2

* Revert to previous version for one translation

* Removed conflicting lines

* Remove conflicting 'Reverse Proxy' key
2022-10-13 00:27:50 +08:00
Louis Lam
edacff123b Add UTC in the serverTimezone dropdown 2022-10-12 22:13:07 +08:00
Louis Lam
2faf866e9e Implement generateTimeslot() for recurring interval type 2022-10-12 17:02:16 +08:00
Louis Lam
8cc3e4b7c1 Revert 2022-10-11 22:28:00 +08:00
Louis Lam
7b9766091e Revert testing 2022-10-11 22:18:09 +08:00
Louis Lam
d95e722658 Init dayjs for backend.spec.js 2022-10-11 22:09:18 +08:00
Louis Lam
39b6725163 Update maintenance tables 2022-10-11 21:48:43 +08:00
Louis Lam
dfb75c8afb Update status page's maintenance message 2022-10-11 20:56:48 +08:00
Louis Lam
e07aa982c3 WIP 2022-10-11 18:23:17 +08:00
Christian Meis
1e8a16504b Make icon optional for ntfy notificaation provider. Add Icon header to ntfy request only, if icon is actually defined. 2022-10-11 11:15:33 +02:00
Alexander Borzov
180d881ac1 Update ru-RU.js (#2217) 2022-10-11 15:35:08 +08:00
Louis Lam
2271ac4a5a Add info.serverTimezoneOffset and improve some styles 2022-10-11 14:52:47 +08:00
Louis Lam
f6bbd1ca67 Merge remote-tracking branch 'origin/master' into maintenance 2022-10-11 14:13:08 +08:00
Louis Lam
d5c02fc627 Update Maintenance list order by status 2022-10-11 01:59:47 +08:00
Louis Lam
c84de4d259 WIP: Add maintenance status 2022-10-11 01:45:30 +08:00
Louis Lam
c1ccaa7a9f WIP 2022-10-10 20:48:11 +08:00
Louis Lam
539683f8e9 Merge remote-tracking branch 'origin/master' into maintenance 2022-10-10 16:50:25 +08:00
Louis Lam
a577fba848 Change DateTime Range using serverTimezone 2022-10-10 02:28:03 +08:00
Louis Lam
a36f24d827 Add configurable server timezone 2022-10-09 20:59:58 +08:00
Louis Lam
b007681e67 Merge remote-tracking branch 'origin/master' into karelkryda_master
# Conflicts:
#	server/model/monitor.js
#	server/model/status_page.js
#	src/languages/en.js
2022-10-09 19:26:00 +08:00
Louis Lam
ad1a7c255f Drop exports.entryPage fully 2022-10-08 23:56:58 +08:00
janhartje
b879428a03 feat(notification): add additional Header to webhook 2022-10-05 17:48:07 +02:00
janhartje
3c5de1c889 Merge branch 'master' of https://github.com/louislam/uptime-kuma into feat/add-auth-header-to-webhook-notification-#1919 2022-10-05 16:44:13 +02:00
George Tsomlektsis
3e699f8ac3 Fix linting errors. 2022-10-03 18:01:52 +03:00
George Tsomlektsis
b0d6b5b13d Fixed entry route not redirecting correctly when the status entry page changes slug. 2022-10-03 17:48:34 +03:00
Matthew Nickson
f9be918246 Add support for MySQL/MariaDB databases #1817
This commit adds support for monitoring MySQL and MariaDB database
servers. The mysql2 package was choosen over mysql as it provides a
promise wrapper and is reportedly faster than the original mysql package
whilst still maintaining the same API.

Signed-off-by: Matthew Nickson <mnickson@sidingsmedia.com>
2022-10-02 01:52:53 +01:00
Matthew Nickson
314ae38f91 Changed name of SQL Server to avoid confusion
It appears that SQL Server causes some confusion among users as they
believe that it means any SQL database, not the Microsoft product SQL
Server. To avoid this issue, the display value has been changed to
Microsoft SQL Server. No backend changes have been made and it is still
stored as sqlserver in the database. This is only a frontent change.

Signed-off-by: Matthew Nickson <mnickson@sidingsmedia.com>
2022-10-01 21:35:33 +01:00
Christian Meis
c03d911657 Update src/languages/en.js
Co-authored-by: Matthew Nickson <mnickson@sidingsmedia.com>
2022-09-28 11:39:13 +02:00
Christian Meis
e12642cf21 Fix double quotes in fallback for no icon url in ntfy notification provider settings 2022-09-28 10:24:56 +02:00
Christian Meis
618d904001 [empty commit] pull request for icon support in ntfy notification provider (fixes #2135) 2022-09-28 10:17:02 +02:00
Christian Meis
6f86236b63 Add support for icon to ntfy notification provider (requires minimum ntfy server version 1.28.0 and Android app 1.14.0, no iOS support as of today) 2022-09-28 10:13:18 +02:00
Louis Lam
204339fbed Make two functions to convert ISO 8601 <=> YYYY-MM-DD hh:mm:ss 2022-09-28 00:48:15 +08:00
Louis Lam
b1465c0282 - Maintenance standardize datetime format to YYYY-MM-DD hh:mm:ss
- Import dayjs extensions one time only
- Maintenance activeCondition centralize
2022-09-28 00:20:17 +08:00
Louis Lam
4002b9f577 [WIP] Checking maintenance time using maintenance_timeslot table 2022-09-27 20:44:44 +08:00
Louis Lam
3f63cb246b [WIP] Handle timezone offset for timeRange 2022-09-25 19:38:28 +08:00
Louis Lam
f11dfc8f43 [WIP] Add/Edit Maintenance with new UI and recurring 2022-09-24 19:18:24 +08:00
Louis Lam
9d99c39f30 Update Maintenance UI for recurring 2022-09-24 02:33:29 +08:00
Louis Lam
617ba49e6c Fix race condition of selectedStatusPagesOptions 2022-09-18 22:40:53 +08:00
Louis Lam
7853c2cc38 Update Maintenance UI 2022-09-18 22:34:05 +08:00
Louis Lam
f61c1c47aa Update Maintenance UI 2022-09-18 02:13:29 +08:00
Louis Lam
9fe07742ea Linting 2022-09-18 02:07:32 +08:00
Louis Lam
a29eae3213 Update Maintenance UI 2022-09-18 02:02:18 +08:00
Louis Lam
80698a58b8 Tidy up 2022-09-17 22:09:09 +08:00
Louis Lam
bb883e6fa0 Move maintenance under /maintenance 2022-09-17 22:00:11 +08:00
Louis Lam
120e578398 Move maintenance code to maintenance-socket-handler.js 2022-09-17 16:58:08 +08:00
Louis Lam
7017c2e625 Move maintenance code to maintenance-socket-handler.js 2022-09-17 16:54:21 +08:00
Louis Lam
2f67d26702 Merge manually, as this part had been moved 2022-09-17 16:20:10 +08:00
Louis Lam
90761cf831 Merge remote-tracking branch 'origin/master' into karelkryda_master
# Conflicts:
#	server/database.js
#	server/model/monitor.js
#	server/routers/api-router.js
#	server/server.js
#	src/components/HeartbeatBar.vue
#	src/components/MonitorList.vue
#	src/icon.js
#	src/layouts/Layout.vue
#	src/mixins/datetime.js
#	src/mixins/socket.js
#	src/router.js
#	src/util.js
2022-09-17 16:12:57 +08:00
Louis Lam
68875c3091 Fix merging issue 2022-09-13 22:22:01 +08:00
Louis Lam
f35d7c0a1a Merge remote-tracking branch 'origin/master' into feat/add-gRPC-protocol
# Conflicts:
#	package-lock.json
2022-09-13 22:19:41 +08:00
minhhn3
3a90d246a4 fix: wrong type 2022-08-20 22:45:11 +07:00
minhhn3
6bb79597e8 fix: resolve merge conflict 2022-08-13 13:26:05 +07:00
minhhn3
34ab6142db fix: remove new space line 2022-08-08 19:38:43 +07:00
minhhn3
2232236a7a [empty commit] pull request for add gRPC protocol 2022-08-03 13:39:31 +07:00
Minh Hoàng
dcecd10c88 Feat/add gRPC protocol (#1)
* feat: added monitor with gRPC

Co-authored-by: minhhn3 <minhhn3@vng.com.vn>
2022-08-03 12:00:39 +07:00
Jan Hartje
af07c7f050 feat(notification): add Authorization Header option to backend 2022-07-18 16:04:27 +00:00
Jan Hartje
95dba6dcaf feat(notification): add Authorization Header option to frontend 2022-07-18 16:04:18 +00:00
Jan Hartje
90c2bf7c94 [empty commit] pull request for #1919 2022-07-18 15:56:53 +00:00
Karel Krýda
fa777c5bc0 Update server/server.js
Co-authored-by: Matthew Nickson <mnickson@sidingsmedia.com>
2022-05-30 15:32:42 +02:00
Karel Krýda
6d0683b055 Update server/routers/api-router.js
Co-authored-by: Matthew Nickson <mnickson@sidingsmedia.com>
2022-05-30 15:32:19 +02:00
Karel Krýda
25262cfb91 Update server/model/monitor.js
Co-authored-by: Matthew Nickson <mnickson@sidingsmedia.com>
2022-05-30 15:31:45 +02:00
Louis Lam
7a46b44d25 Merge remote-tracking branch 'origin/master' into karelkryda_master
# Conflicts:
#	src/components/HeartbeatBar.vue
2022-05-18 19:49:54 +08:00
Karel Krýda
42f931f6cf Merge branch 'master' into master 2022-05-09 10:28:14 +02:00
Karel Krýda
2fe5c090aa small fixes 2022-05-08 20:50:08 +02:00
Karel Krýda
ed218e73bb UI improvements 2022-05-08 20:03:24 +02:00
Karel Krýda
9a35386841 Merge branch 'master' into master 2022-05-06 11:24:21 +02:00
Karel Krýda
2b14bdae62 Merge branch 'master' into master 2022-05-01 12:40:34 +02:00
Karel Krýda
31b90d12a4 Added the ability to choose on which status pages maintenance information should be displayed 2022-04-30 17:17:22 +02:00
Karel Krýda
b4ffcc5555 Added JSDoc 2022-04-30 15:50:05 +02:00
Karel Krýda
57368c8c6c More modern look of maintenance information on status page (same design as for the new incident system) 2022-04-30 15:32:56 +02:00
Karel Krýda
11ef22edec Fixed remaining lint errors 2022-04-30 15:13:13 +02:00
Karel Krýda
f78d01d770 Resolve lint errors 2022-04-30 14:57:08 +02:00
Karel Krýda
7532acc95d Resolve conflicts 2022-04-30 14:33:54 +02:00
Karel Krýda
ed84e56a85 Merge remote-tracking branch 'origin_kuma/master'
# Conflicts:
#	package-lock.json
#	server/database.js
#	server/model/monitor.js
#	server/routers/api-router.js
#	server/server.js
#	src/components/MonitorList.vue
#	src/components/PingChart.vue
#	src/icon.js
#	src/pages/DashboardHome.vue
#	src/pages/StatusPage.vue
#	src/router.js
#	src/util.js
2022-04-30 13:40:34 +02:00
Karel Krýda
b49e5d5c39 The SQL query to determine if the monitor is under maintenance is now in its own method. 2022-01-25 19:07:27 +01:00
Karel Krýda
e7b2832967 The start and end dates of the maintenance are now stored in UTC, which allows it to be converted between time zones 2022-01-24 22:33:15 +01:00
Karel Krýda
5fda1f0f59 minor fixes (missing commas, spaces, translations) 2022-01-23 20:33:39 +01:00
Karel Krýda
0d3414c6d6 A complete maintenance planning system has been created 2022-01-23 15:22:00 +01:00
91 changed files with 6591 additions and 2492 deletions

25
db/patch-grpc-monitor.sql Normal file
View File

@@ -0,0 +1,25 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
ALTER TABLE monitor
ADD grpc_url VARCHAR(255) default null;
ALTER TABLE monitor
ADD grpc_protobuf TEXT default null;
ALTER TABLE monitor
ADD grpc_body TEXT default null;
ALTER TABLE monitor
ADD grpc_metadata TEXT default null;
ALTER TABLE monitor
ADD grpc_method VARCHAR(255) default null;
ALTER TABLE monitor
ADD grpc_service_name VARCHAR(255) default null;
ALTER TABLE monitor
ADD grpc_enable_tls BOOLEAN default 0 not null;
COMMIT;

View File

@@ -0,0 +1,83 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
-- Just for someone who tested maintenance before (patch-maintenance-table.sql)
DROP TABLE IF EXISTS maintenance_status_page;
DROP TABLE IF EXISTS monitor_maintenance;
DROP TABLE IF EXISTS maintenance;
DROP TABLE IF EXISTS maintenance_timeslot;
-- maintenance
CREATE TABLE [maintenance] (
[id] INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
[title] VARCHAR(150) NOT NULL,
[description] TEXT NOT NULL,
[user_id] INTEGER REFERENCES [user]([id]) ON DELETE SET NULL ON UPDATE CASCADE,
[active] BOOLEAN NOT NULL DEFAULT 1,
[strategy] VARCHAR(50) NOT NULL DEFAULT 'single',
[start_date] DATETIME,
[end_date] DATETIME,
[start_time] TIME,
[end_time] TIME,
[weekdays] VARCHAR2(250) DEFAULT '[]',
[days_of_month] TEXT DEFAULT '[]',
[interval_day] INTEGER
);
CREATE INDEX [manual_active] ON [maintenance] (
[strategy],
[active]
);
CREATE INDEX [active] ON [maintenance] ([active]);
CREATE INDEX [maintenance_user_id] ON [maintenance] ([user_id]);
-- maintenance_status_page
CREATE TABLE maintenance_status_page (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
status_page_id INTEGER NOT NULL,
maintenance_id INTEGER NOT NULL,
CONSTRAINT FK_maintenance FOREIGN KEY (maintenance_id) REFERENCES maintenance (id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT FK_status_page FOREIGN KEY (status_page_id) REFERENCES status_page (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX [status_page_id_index]
ON [maintenance_status_page]([status_page_id]);
CREATE INDEX [maintenance_id_index]
ON [maintenance_status_page]([maintenance_id]);
-- maintenance_timeslot
CREATE TABLE [maintenance_timeslot] (
[id] INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
[maintenance_id] INTEGER NOT NULL CONSTRAINT [FK_maintenance] REFERENCES [maintenance]([id]) ON DELETE CASCADE ON UPDATE CASCADE,
[start_date] DATETIME NOT NULL,
[end_date] DATETIME,
[generated_next] BOOLEAN DEFAULT 0
);
CREATE INDEX [maintenance_id] ON [maintenance_timeslot] ([maintenance_id] DESC);
CREATE INDEX [active_timeslot_index] ON [maintenance_timeslot] (
[maintenance_id] DESC,
[start_date] DESC,
[end_date] DESC
);
CREATE INDEX [generated_next_index] ON [maintenance_timeslot] ([generated_next]);
-- monitor_maintenance
CREATE TABLE monitor_maintenance (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
monitor_id INTEGER NOT NULL,
maintenance_id INTEGER NOT NULL,
CONSTRAINT FK_maintenance FOREIGN KEY (maintenance_id) REFERENCES maintenance (id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT FK_monitor FOREIGN KEY (monitor_id) REFERENCES monitor (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX [maintenance_id_index2] ON [monitor_maintenance]([maintenance_id]);
CREATE INDEX [monitor_id_index] ON [monitor_maintenance]([monitor_id]);
COMMIT;

View File

@@ -4,5 +4,5 @@ WORKDIR /app
# Install apprise, iputils for non-root ping, setpriv
RUN apk add --no-cache iputils setpriv dumb-init python3 py3-cryptography py3-pip py3-six py3-yaml py3-click py3-markdown py3-requests py3-requests-oauthlib && \
pip3 --no-cache-dir install apprise==1.0.0 && \
pip3 --no-cache-dir install apprise==1.2.0 && \
rm -rf /root/.cache

View File

@@ -11,7 +11,7 @@ WORKDIR /app
RUN apt update && \
apt --yes --no-install-recommends install python3 python3-pip python3-cryptography python3-six python3-yaml python3-click python3-markdown python3-requests python3-requests-oauthlib \
sqlite3 iputils-ping util-linux dumb-init && \
pip3 --no-cache-dir install apprise==1.0.0 && \
pip3 --no-cache-dir install apprise==1.2.0 && \
rm -rf /var/lib/apt/lists/* && \
apt --yes autoremove

View File

@@ -5,7 +5,7 @@ const util = require("../src/util");
util.polyfill();
const oldVersion = pkg.version;
const newVersion = oldVersion + "-nightly";
const newVersion = oldVersion + "-nightly-" + util.genSecret(8);
console.log("Old Version: " + oldVersion);
console.log("New Version: " + newVersion);

5049
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "uptime-kuma",
"version": "1.18.5",
"version": "1.19.0-beta.1",
"license": "MIT",
"repository": {
"type": "git",
@@ -63,10 +63,11 @@
"cypress-open": "concurrently -k -r \"node test/prepare-test-server.js && node server/server.js --port=3002 --data-dir=./data/test/\" \"cypress open --config-file ./config/cypress.config.js\""
},
"dependencies": {
"@louislam/sqlite3": "~15.0.6",
"@grpc/grpc-js": "~1.7.3",
"@louislam/sqlite3": "15.1.2",
"args-parser": "~1.3.0",
"axios": "~0.27.0",
"axios-ntlm": "~1.3.0",
"axios-ntlm": "1.3.0",
"badge-maker": "~3.3.1",
"bcryptjs": "~2.4.3",
"bree": "~7.1.5",
@@ -93,6 +94,7 @@
"limiter": "~2.1.0",
"mqtt": "~4.3.7",
"mssql": "~8.1.4",
"mysql2": "~2.3.3",
"node-cloudflared-tunnel": "~1.0.9",
"node-radius-client": "~1.0.0",
"nodemailer": "~6.6.5",
@@ -102,9 +104,10 @@
"pg-connection-string": "~2.5.0",
"prom-client": "~13.2.0",
"prometheus-api-metrics": "~3.2.1",
"protobufjs": "~7.1.1",
"redbean-node": "0.1.4",
"socket.io": "~4.4.1",
"socket.io-client": "~4.4.1",
"socket.io": "~4.5.3",
"socket.io-client": "~4.5.3",
"socks-proxy-agent": "6.1.1",
"tar": "~6.1.11",
"tcp-ping": "~0.1.1",
@@ -123,13 +126,14 @@
"@vitejs/plugin-legacy": "~2.1.0",
"@vitejs/plugin-vue": "~3.1.0",
"@vue/compiler-sfc": "~3.2.36",
"@vuepic/vue-datepicker": "~3.4.8",
"aedes": "^0.46.3",
"babel-plugin-rewire": "~1.2.0",
"bootstrap": "5.1.3",
"chart.js": "~3.6.2",
"chartjs-adapter-dayjs": "~1.0.0",
"concurrently": "^7.1.0",
"core-js": "~3.18.3",
"core-js": "~3.26.1",
"cross-env": "~7.0.3",
"cypress": "^10.1.0",
"delay": "^5.0.0",

View File

@@ -25,7 +25,7 @@ exports.startInterval = () => {
let checkBeta = await setting("checkBeta");
if (checkBeta && res.data.beta) {
if (compareVersions.compare(res.data.beta, res.data.beta, ">")) {
if (compareVersions.compare(res.data.beta, res.data.slow, ">")) {
exports.latestVersion = res.data.beta;
return;
}

View File

@@ -4,7 +4,8 @@
const { TimeLogger } = require("../src/util");
const { R } = require("redbean-node");
const { UptimeKumaServer } = require("./uptime-kuma-server");
const io = UptimeKumaServer.getInstance().io;
const server = UptimeKumaServer.getInstance();
const io = server.io;
const { setting } = require("./util-server");
const checkVersion = require("./check-version");
@@ -121,7 +122,9 @@ async function sendInfo(socket) {
socket.emit("info", {
version: checkVersion.version,
latestVersion: checkVersion.latestVersion,
primaryBaseURL: await setting("primaryBaseURL")
primaryBaseURL: await setting("primaryBaseURL"),
serverTimezone: await server.getTimezone(),
serverTimezoneOffset: server.getTimezoneOffset(),
});
}

View File

@@ -62,8 +62,10 @@ class Database {
"patch-add-clickable-status-page-link.sql": true,
"patch-add-sqlserver-monitor.sql": true,
"patch-add-other-auth.sql": { parents: [ "patch-monitor-basic-auth.sql" ] },
"patch-grpc-monitor.sql": true,
"patch-add-radius-monitor.sql": true,
"patch-monitor-add-resend-interval.sql": true,
"patch-maintenance-table2.sql": true,
};
/**

View File

@@ -1,8 +1,3 @@
const dayjs = require("dayjs");
const utc = require("dayjs/plugin/utc");
let timezone = require("dayjs/plugin/timezone");
dayjs.extend(utc);
dayjs.extend(timezone);
const { BeanModel } = require("redbean-node/dist/bean-model");
/**
@@ -10,6 +5,7 @@ const { BeanModel } = require("redbean-node/dist/bean-model");
* 0 = DOWN
* 1 = UP
* 2 = PENDING
* 3 = MAINTENANCE
*/
class Heartbeat extends BeanModel {

215
server/model/maintenance.js Normal file
View File

@@ -0,0 +1,215 @@
const { BeanModel } = require("redbean-node/dist/bean-model");
const { parseTimeObject, parseTimeFromTimeObject, utcToLocal, localToUTC, log } = require("../../src/util");
const { timeObjectToUTC, timeObjectToLocal } = require("../util-server");
const { R } = require("redbean-node");
const dayjs = require("dayjs");
class Maintenance extends BeanModel {
/**
* Return an object that ready to parse to JSON for public
* Only show necessary data to public
* @returns {Object}
*/
async toPublicJSON() {
let dateRange = [];
if (this.start_date) {
dateRange.push(utcToLocal(this.start_date));
if (this.end_date) {
dateRange.push(utcToLocal(this.end_date));
}
}
let timeRange = [];
let startTime = timeObjectToLocal(parseTimeObject(this.start_time));
timeRange.push(startTime);
let endTime = timeObjectToLocal(parseTimeObject(this.end_time));
timeRange.push(endTime);
let obj = {
id: this.id,
title: this.title,
description: this.description,
strategy: this.strategy,
intervalDay: this.interval_day,
active: !!this.active,
dateRange: dateRange,
timeRange: timeRange,
weekdays: (this.weekdays) ? JSON.parse(this.weekdays) : [],
daysOfMonth: (this.days_of_month) ? JSON.parse(this.days_of_month) : [],
timeslotList: [],
};
const timeslotList = await this.getTimeslotList();
for (let timeslot of timeslotList) {
obj.timeslotList.push(await timeslot.toPublicJSON());
}
if (!Array.isArray(obj.weekdays)) {
obj.weekdays = [];
}
if (!Array.isArray(obj.daysOfMonth)) {
obj.daysOfMonth = [];
}
// Maintenance Status
if (!obj.active) {
obj.status = "inactive";
} else if (obj.strategy === "manual") {
obj.status = "under-maintenance";
} else if (obj.timeslotList.length > 0) {
let currentTimestamp = dayjs().unix();
for (let timeslot of obj.timeslotList) {
if (dayjs.utc(timeslot.startDate).unix() <= currentTimestamp && dayjs.utc(timeslot.endDate).unix() >= currentTimestamp) {
log.debug("timeslot", "Timeslot ID: " + timeslot.id);
log.debug("timeslot", "currentTimestamp:" + currentTimestamp);
log.debug("timeslot", "timeslot.start_date:" + dayjs.utc(timeslot.startDate).unix());
log.debug("timeslot", "timeslot.end_date:" + dayjs.utc(timeslot.endDate).unix());
obj.status = "under-maintenance";
break;
}
}
if (!obj.status) {
obj.status = "scheduled";
}
} else if (obj.timeslotList.length === 0) {
obj.status = "ended";
} else {
obj.status = "unknown";
}
return obj;
}
/**
* Only get future or current timeslots only
* @returns {Promise<[]>}
*/
async getTimeslotList() {
return R.convertToBeans("maintenance_timeslot", await R.getAll(`
SELECT maintenance_timeslot.*
FROM maintenance_timeslot, maintenance
WHERE maintenance_timeslot.maintenance_id = maintenance.id
AND maintenance.id = ?
AND ${Maintenance.getActiveAndFutureMaintenanceSQLCondition()}
`, [
this.id
]));
}
/**
* Return an object that ready to parse to JSON
* @param {string} timezone If not specified, the timeRange will be in UTC
* @returns {Object}
*/
async toJSON(timezone = null) {
return this.toPublicJSON(timezone);
}
getDayOfWeekList() {
log.debug("timeslot", "List: " + this.weekdays);
return JSON.parse(this.weekdays).sort(function (a, b) {
return a - b;
});
}
getDayOfMonthList() {
return JSON.parse(this.days_of_month).sort(function (a, b) {
return a - b;
});
}
getStartDateTime() {
let startOfTheDay = dayjs.utc(this.start_date).format("HH:mm");
log.debug("timeslot", "startOfTheDay: " + startOfTheDay);
// Start Time
let startTimeSecond = dayjs.utc(this.start_time, "HH:mm").diff(dayjs.utc(startOfTheDay, "HH:mm"), "second");
log.debug("timeslot", "startTime: " + startTimeSecond);
// Bake StartDate + StartTime = Start DateTime
return dayjs.utc(this.start_date).add(startTimeSecond, "second");
}
getDuration() {
let duration = dayjs.utc(this.end_time, "HH:mm").diff(dayjs.utc(this.start_time, "HH:mm"), "second");
// Add 24hours if it is across day
if (duration < 0) {
duration += 24 * 3600;
}
return duration;
}
static jsonToBean(bean, obj) {
if (obj.id) {
bean.id = obj.id;
}
// Apply timezone offset to timeRange, as it cannot apply automatically.
if (obj.timeRange[0]) {
timeObjectToUTC(obj.timeRange[0]);
if (obj.timeRange[1]) {
timeObjectToUTC(obj.timeRange[1]);
}
}
bean.title = obj.title;
bean.description = obj.description;
bean.strategy = obj.strategy;
bean.interval_day = obj.intervalDay;
bean.active = obj.active;
if (obj.dateRange[0]) {
bean.start_date = localToUTC(obj.dateRange[0]);
if (obj.dateRange[1]) {
bean.end_date = localToUTC(obj.dateRange[1]);
}
}
bean.start_time = parseTimeFromTimeObject(obj.timeRange[0]);
bean.end_time = parseTimeFromTimeObject(obj.timeRange[1]);
bean.weekdays = JSON.stringify(obj.weekdays);
bean.days_of_month = JSON.stringify(obj.daysOfMonth);
return bean;
}
/**
* SQL conditions for active maintenance
* @returns {string}
*/
static getActiveMaintenanceSQLCondition() {
return `
(maintenance_timeslot.start_date <= DATETIME('now')
AND maintenance_timeslot.end_date >= DATETIME('now')
AND maintenance.active = 1)
OR
(maintenance.strategy = 'manual' AND active = 1)
`;
}
/**
* SQL conditions for active and future maintenance
* @returns {string}
*/
static getActiveAndFutureMaintenanceSQLCondition() {
return `
((maintenance_timeslot.end_date >= DATETIME('now')
AND maintenance.active = 1)
OR
(maintenance.strategy = 'manual' AND active = 1))
`;
}
}
module.exports = Maintenance;

View File

@@ -0,0 +1,189 @@
const { BeanModel } = require("redbean-node/dist/bean-model");
const { R } = require("redbean-node");
const dayjs = require("dayjs");
const { log, utcToLocal, SQL_DATETIME_FORMAT_WITHOUT_SECOND, localToUTC } = require("../../src/util");
const { UptimeKumaServer } = require("../uptime-kuma-server");
class MaintenanceTimeslot extends BeanModel {
async toPublicJSON() {
const serverTimezoneOffset = UptimeKumaServer.getInstance().getTimezoneOffset();
const obj = {
id: this.id,
startDate: this.start_date,
endDate: this.end_date,
startDateServerTimezone: utcToLocal(this.start_date, SQL_DATETIME_FORMAT_WITHOUT_SECOND),
endDateServerTimezone: utcToLocal(this.end_date, SQL_DATETIME_FORMAT_WITHOUT_SECOND),
serverTimezoneOffset,
};
return obj;
}
async toJSON() {
return await this.toPublicJSON();
}
/**
* @param {Maintenance} maintenance
* @param {dayjs} minDate (For recurring type only) Generate a next timeslot from this date.
* @param {boolean} removeExist Remove existing timeslot before create
* @returns {Promise<MaintenanceTimeslot>}
*/
static async generateTimeslot(maintenance, minDate = null, removeExist = false) {
if (removeExist) {
await R.exec("DELETE FROM maintenance_timeslot WHERE maintenance_id = ? ", [
maintenance.id
]);
}
if (maintenance.strategy === "manual") {
log.debug("maintenance", "No need to generate timeslot for manual type");
} else if (maintenance.strategy === "single") {
let bean = R.dispense("maintenance_timeslot");
bean.maintenance_id = maintenance.id;
bean.start_date = maintenance.start_date;
bean.end_date = maintenance.end_date;
bean.generated_next = true;
return await R.store(bean);
} else if (maintenance.strategy === "recurring-interval") {
// Prevent dead loop, in case interval_day is not set
if (!maintenance.interval_day || maintenance.interval_day <= 0) {
maintenance.interval_day = 1;
}
return await this.handleRecurringType(maintenance, minDate, (startDateTime) => {
return startDateTime.add(maintenance.interval_day, "day");
}, () => {
return true;
});
} else if (maintenance.strategy === "recurring-weekday") {
let dayOfWeekList = maintenance.getDayOfWeekList();
log.debug("timeslot", dayOfWeekList);
if (dayOfWeekList.length <= 0) {
log.debug("timeslot", "No weekdays selected?");
return null;
}
const isValid = (startDateTime) => {
log.debug("timeslot", "nextDateTime: " + startDateTime);
let day = startDateTime.local().day();
log.debug("timeslot", "nextDateTime.day(): " + day);
return dayOfWeekList.includes(day);
};
return await this.handleRecurringType(maintenance, minDate, (startDateTime) => {
while (true) {
startDateTime = startDateTime.add(1, "day");
if (isValid(startDateTime)) {
return startDateTime;
}
}
}, isValid);
} else if (maintenance.strategy === "recurring-day-of-month") {
let dayOfMonthList = maintenance.getDayOfMonthList();
if (dayOfMonthList.length <= 0) {
log.debug("timeslot", "No day selected?");
return null;
}
const isValid = (startDateTime) => {
let day = parseInt(startDateTime.local().format("D"));
log.debug("timeslot", "day: " + day);
// Check 1-31
if (dayOfMonthList.includes(day)) {
return startDateTime;
}
// Check "lastDay1","lastDay2"...
let daysInMonth = startDateTime.daysInMonth();
let lastDayList = [];
// Small first, e.g. 28 > 29 > 30 > 31
for (let i = 4; i >= 1; i--) {
if (dayOfMonthList.includes("lastDay" + i)) {
lastDayList.push(daysInMonth - i + 1);
}
}
log.debug("timeslot", lastDayList);
return lastDayList.includes(day);
};
return await this.handleRecurringType(maintenance, minDate, (startDateTime) => {
while (true) {
startDateTime = startDateTime.add(1, "day");
if (isValid(startDateTime)) {
return startDateTime;
}
}
}, isValid);
} else {
throw new Error("Unknown maintenance strategy");
}
}
/**
* Generate a next timeslot for all recurring types
* @param maintenance
* @param minDate
* @param {function} nextDayCallback The logic how to get the next possible day
* @param {function} isValidCallback Check the day whether is matched the current strategy
* @returns {Promise<null|MaintenanceTimeslot>}
*/
static async handleRecurringType(maintenance, minDate, nextDayCallback, isValidCallback) {
let bean = R.dispense("maintenance_timeslot");
let duration = maintenance.getDuration();
let startDateTime = maintenance.getStartDateTime();
let endDateTime;
// Keep generating from the first possible date, until it is ok
while (true) {
log.debug("timeslot", "startDateTime: " + startDateTime.format());
// Handling out of effective date range
if (startDateTime.diff(dayjs.utc(maintenance.end_date)) > 0) {
log.debug("timeslot", "Out of effective date range");
return null;
}
endDateTime = startDateTime.add(duration, "second");
// If endDateTime is out of effective date range, use the end datetime from effective date range
if (endDateTime.diff(dayjs.utc(maintenance.end_date)) > 0) {
endDateTime = dayjs.utc(maintenance.end_date);
}
// If minDate is set, the endDateTime must be bigger than it.
// And the endDateTime must be bigger current time
// Is valid under current recurring strategy
if (
(!minDate || endDateTime.diff(minDate) > 0) &&
endDateTime.diff(dayjs()) > 0 &&
isValidCallback(startDateTime)
) {
break;
}
startDateTime = nextDayCallback(startDateTime);
}
bean.maintenance_id = maintenance.id;
bean.start_date = localToUTC(startDateTime);
bean.end_date = localToUTC(endDateTime);
bean.generated_next = false;
return await R.store(bean);
}
}
module.exports = MaintenanceTimeslot;

View File

@@ -1,13 +1,9 @@
const https = require("https");
const dayjs = require("dayjs");
const utc = require("dayjs/plugin/utc");
let timezone = require("dayjs/plugin/timezone");
dayjs.extend(utc);
dayjs.extend(timezone);
const axios = require("axios");
const { Prometheus } = require("../prometheus");
const { log, UP, DOWN, PENDING, flipStatus, TimeLogger } = require("../../src/util");
const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mqttAsync, setSetting, httpNtlm, radius } = require("../util-server");
const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, TimeLogger } = require("../../src/util");
const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, mqttAsync, setSetting, httpNtlm, radius, grpcQuery } = require("../util-server");
const { R } = require("redbean-node");
const { BeanModel } = require("redbean-node/dist/bean-model");
const { Notification } = require("../notification");
@@ -18,12 +14,14 @@ const apicache = require("../modules/apicache");
const { UptimeKumaServer } = require("../uptime-kuma-server");
const { CacheableDnsHttpAgent } = require("../cacheable-dns-http-agent");
const { DockerHost } = require("../docker");
const Maintenance = require("./maintenance");
/**
* status:
* 0 = DOWN
* 1 = UP
* 2 = PENDING
* 3 = MAINTENANCE
*/
class Monitor extends BeanModel {
@@ -37,6 +35,7 @@ class Monitor extends BeanModel {
id: this.id,
name: this.name,
sendUrl: this.sendUrl,
maintenance: await Monitor.isUnderMaintenance(this.id),
};
if (this.sendUrl) {
@@ -90,26 +89,23 @@ class Monitor extends BeanModel {
dns_resolve_type: this.dns_resolve_type,
dns_resolve_server: this.dns_resolve_server,
dns_last_result: this.dns_last_result,
pushToken: this.pushToken,
docker_container: this.docker_container,
docker_host: this.docker_host,
proxyId: this.proxy_id,
notificationIDList,
tags: tags,
mqttUsername: this.mqttUsername,
mqttPassword: this.mqttPassword,
maintenance: await Monitor.isUnderMaintenance(this.id),
mqttTopic: this.mqttTopic,
mqttSuccessMessage: this.mqttSuccessMessage,
databaseConnectionString: this.databaseConnectionString,
databaseQuery: this.databaseQuery,
authMethod: this.authMethod,
authWorkstation: this.authWorkstation,
authDomain: this.authDomain,
radiusUsername: this.radiusUsername,
radiusPassword: this.radiusPassword,
grpcUrl: this.grpcUrl,
grpcProtobuf: this.grpcProtobuf,
grpcMethod: this.grpcMethod,
grpcServiceName: this.grpcServiceName,
grpcEnableTls: this.getGrpcEnableTls(),
radiusCalledStationId: this.radiusCalledStationId,
radiusCallingStationId: this.radiusCallingStationId,
radiusSecret: this.radiusSecret,
};
if (includeSensitiveData) {
@@ -117,12 +113,23 @@ class Monitor extends BeanModel {
...data,
headers: this.headers,
body: this.body,
grpcBody: this.grpcBody,
grpcMetadata: this.grpcMetadata,
basic_auth_user: this.basic_auth_user,
basic_auth_pass: this.basic_auth_pass,
pushToken: this.pushToken,
databaseConnectionString: this.databaseConnectionString,
radiusUsername: this.radiusUsername,
radiusPassword: this.radiusPassword,
radiusSecret: this.radiusSecret,
mqttUsername: this.mqttUsername,
mqttPassword: this.mqttPassword,
authWorkstation: this.authWorkstation,
authDomain: this.authDomain,
};
}
data.includeSensitiveData = includeSensitiveData;
return data;
}
@@ -167,6 +174,14 @@ class Monitor extends BeanModel {
return Boolean(this.upsideDown);
}
/**
* Parse to boolean
* @returns {boolean}
*/
getGrpcEnableTls() {
return Boolean(this.grpcEnableTls);
}
/**
* Get accepted status codes
* @returns {Object}
@@ -230,7 +245,10 @@ class Monitor extends BeanModel {
}
try {
if (this.type === "http" || this.type === "keyword") {
if (await Monitor.isUnderMaintenance(this.id)) {
bean.msg = "Monitor under maintenance";
bean.status = MAINTENANCE;
} else if (this.type === "http" || this.type === "keyword") {
// Do not do any queries/high loading things before the "bean.ping"
let startTime = dayjs().valueOf();
@@ -249,17 +267,22 @@ class Monitor extends BeanModel {
log.debug("monitor", `[${this.name}] Prepare Options for axios`);
// Axios Options
const options = {
url: this.url,
method: (this.method || "get").toLowerCase(),
...(this.body ? { data: JSON.parse(this.body) } : {}),
timeout: this.interval * 1000 * 0.8,
headers: {
// Fix #2253
// Read more: https://stackoverflow.com/questions/1759956/curl-error-18-transfer-closed-with-outstanding-read-data-remaining
"Accept-Encoding": "gzip, deflate",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
"User-Agent": "Uptime-Kuma/" + version,
...(this.headers ? JSON.parse(this.headers) : {}),
...(basicAuthHeader),
},
decompress: true,
maxRedirects: this.maxredirects,
validateStatus: (status) => {
return checkStatusCode(status, this.getAcceptedStatuscodes());
@@ -524,16 +547,66 @@ class Monitor extends BeanModel {
bean.msg = "";
bean.status = UP;
bean.ping = dayjs().valueOf() - startTime;
} else if (this.type === "grpc-keyword") {
let startTime = dayjs().valueOf();
const options = {
grpcUrl: this.grpcUrl,
grpcProtobufData: this.grpcProtobuf,
grpcServiceName: this.grpcServiceName,
grpcEnableTls: this.grpcEnableTls,
grpcMethod: this.grpcMethod,
grpcBody: this.grpcBody,
keyword: this.keyword
};
const response = await grpcQuery(options);
bean.ping = dayjs().valueOf() - startTime;
log.debug("monitor:", `gRPC response: ${JSON.stringify(response)}`);
let responseData = response.data;
if (responseData.length > 50) {
responseData = response.substring(0, 47) + "...";
}
if (response.code !== 1) {
bean.status = DOWN;
bean.msg = `Error in send gRPC ${response.code} ${response.errorMessage}`;
} else {
if (response.data.toString().includes(this.keyword)) {
bean.status = UP;
bean.msg = `${responseData}, keyword [${this.keyword}] is found`;
} else {
log.debug("monitor:", `GRPC response [${response.data}] + ", but keyword [${this.keyword}] is not in [" + ${response.data} + "]"`);
bean.status = DOWN;
bean.msg = `, but keyword [${this.keyword}] is not in [" + ${responseData} + "]`;
}
}
} else if (this.type === "postgres") {
let startTime = dayjs().valueOf();
await postgresQuery(this.databaseConnectionString, this.databaseQuery);
bean.msg = "";
bean.status = UP;
bean.ping = dayjs().valueOf() - startTime;
} else if (this.type === "mysql") {
let startTime = dayjs().valueOf();
await mysqlQuery(this.databaseConnectionString, this.databaseQuery);
bean.msg = "";
bean.status = UP;
bean.ping = dayjs().valueOf() - startTime;
} else if (this.type === "radius") {
let startTime = dayjs().valueOf();
// Handle monitors that were created before the
// update and as such don't have a value for
// this.port.
let port;
if (this.port == null) {
port = 1812;
} else {
port = this.port;
}
try {
const resp = await radius(
this.hostname,
@@ -541,7 +614,8 @@ class Monitor extends BeanModel {
this.radiusPassword,
this.radiusCalledStationId,
this.radiusCallingStationId,
this.radiusSecret
this.radiusSecret,
port
);
if (resp.code) {
bean.msg = resp.code;
@@ -594,8 +668,12 @@ class Monitor extends BeanModel {
if (isImportant) {
bean.important = true;
if (Monitor.isImportantForNotification(isFirstBeat, previousBeat?.status, bean.status)) {
log.debug("monitor", `[${this.name}] sendNotification`);
await Monitor.sendNotification(isFirstBeat, this, bean);
} else {
log.debug("monitor", `[${this.name}] will not sendNotification because it is (or was) under maintenance`);
}
// Reset down count
bean.downCount = 0;
@@ -604,6 +682,8 @@ class Monitor extends BeanModel {
log.debug("monitor", `[${this.name}] apicache clear`);
apicache.clear();
UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id);
} else {
bean.important = false;
@@ -627,6 +707,8 @@ class Monitor extends BeanModel {
beatInterval = this.retryInterval;
}
log.warn("monitor", `Monitor #${this.id} '${this.name}': Pending: ${bean.msg} | Max retries: ${this.maxretries} | Retry: ${retries} | Retry Interval: ${beatInterval} seconds | Type: ${this.type}`);
} else if (bean.status === MAINTENANCE) {
log.warn("monitor", `Monitor #${this.id} '${this.name}': Under Maintenance | Type: ${this.type}`);
} else {
log.warn("monitor", `Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Interval: ${beatInterval} seconds | Type: ${this.type} | Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`);
}
@@ -837,7 +919,7 @@ class Monitor extends BeanModel {
-- SUM all uptime duration, also trim off the beat out of time window
SUM(
CASE
WHEN (status = 1)
WHEN (status = 1 OR status = 3)
THEN
CASE
WHEN (JULIANDAY(\`time\`) - JULIANDAY(?)) * 86400 < duration
@@ -908,11 +990,49 @@ class Monitor extends BeanModel {
// DOWN -> PENDING = this case not exists
// DOWN -> DOWN = not important
// * DOWN -> UP = important
let isImportant = isFirstBeat ||
// MAINTENANCE -> MAINTENANCE = not important
// * MAINTENANCE -> UP = important
// * MAINTENANCE -> DOWN = important
// * DOWN -> MAINTENANCE = important
// * UP -> MAINTENANCE = important
return isFirstBeat ||
(previousBeatStatus === DOWN && currentBeatStatus === MAINTENANCE) ||
(previousBeatStatus === UP && currentBeatStatus === MAINTENANCE) ||
(previousBeatStatus === MAINTENANCE && currentBeatStatus === DOWN) ||
(previousBeatStatus === MAINTENANCE && currentBeatStatus === UP) ||
(previousBeatStatus === UP && currentBeatStatus === DOWN) ||
(previousBeatStatus === DOWN && currentBeatStatus === UP) ||
(previousBeatStatus === PENDING && currentBeatStatus === DOWN);
}
/**
* Is this beat important for notifications?
* @param {boolean} isFirstBeat Is this the first beat of this monitor?
* @param {const} previousBeatStatus Status of the previous beat
* @param {const} currentBeatStatus Status of the current beat
* @returns {boolean} True if is an important beat else false
*/
static isImportantForNotification(isFirstBeat, previousBeatStatus, currentBeatStatus) {
// * ? -> ANY STATUS = important [isFirstBeat]
// UP -> PENDING = not important
// * UP -> DOWN = important
// UP -> UP = not important
// PENDING -> PENDING = not important
// * PENDING -> DOWN = important
// PENDING -> UP = not important
// DOWN -> PENDING = this case not exists
// DOWN -> DOWN = not important
// * DOWN -> UP = important
// MAINTENANCE -> MAINTENANCE = not important
// MAINTENANCE -> UP = not important
// * MAINTENANCE -> DOWN = important
// DOWN -> MAINTENANCE = not important
// UP -> MAINTENANCE = not important
return isFirstBeat ||
(previousBeatStatus === MAINTENANCE && currentBeatStatus === DOWN) ||
(previousBeatStatus === UP && currentBeatStatus === DOWN) ||
(previousBeatStatus === DOWN && currentBeatStatus === UP) ||
(previousBeatStatus === PENDING && currentBeatStatus === DOWN);
return isImportant;
}
/**
@@ -1049,6 +1169,26 @@ class Monitor extends BeanModel {
monitorID
]);
}
/**
* Check if monitor is under maintenance
* @param {number} monitorID ID of monitor to check
* @returns {Promise<boolean>}
*/
static async isUnderMaintenance(monitorID) {
let activeCondition = Maintenance.getActiveMaintenanceSQLCondition();
const maintenance = await R.getRow(`
SELECT COUNT(*) AS count
FROM monitor_maintenance mm
JOIN maintenance
ON mm.maintenance_id = maintenance.id
AND mm.monitor_id = ?
LEFT JOIN maintenance_timeslot
ON maintenance_timeslot.maintenance_id = maintenance.id
WHERE ${activeCondition}
LIMIT 1`, [ monitorID ]);
return maintenance.count !== 0;
}
}
module.exports = Monitor;

View File

@@ -3,6 +3,7 @@ const { R } = require("redbean-node");
const cheerio = require("cheerio");
const { UptimeKumaServer } = require("../uptime-kuma-server");
const jsesc = require("jsesc");
const Maintenance = require("./maintenance");
class StatusPage extends BeanModel {
@@ -37,7 +38,7 @@ class StatusPage extends BeanModel {
*/
static async renderHTML(indexHTML, statusPage) {
const $ = cheerio.load(indexHTML);
const description155 = statusPage.description?.substring(0, 155);
const description155 = statusPage.description?.substring(0, 155) ?? "";
$("title").text(statusPage.title);
$("meta[name=description]").attr("content", description155);
@@ -90,6 +91,8 @@ class StatusPage extends BeanModel {
incident = incident.toPublicJSON();
}
let maintenanceList = await StatusPage.getMaintenanceList(statusPage.id);
// Public Group List
const publicGroupList = [];
const showTags = !!statusPage.show_tags;
@@ -107,7 +110,8 @@ class StatusPage extends BeanModel {
return {
config: await statusPage.toPublicJSON(),
incident,
publicGroupList
publicGroupList,
maintenanceList,
};
}
@@ -266,6 +270,36 @@ class StatusPage extends BeanModel {
}
}
/**
* Get list of maintenances
* @param {number} statusPageId ID of status page to get maintenance for
* @returns {Object} Object representing maintenances sanitized for public
*/
static async getMaintenanceList(statusPageId) {
try {
const publicMaintenanceList = [];
let activeCondition = Maintenance.getActiveMaintenanceSQLCondition();
let maintenanceBeanList = R.convertToBeans("maintenance", await R.getAll(`
SELECT maintenance.*
FROM maintenance, maintenance_status_page msp, maintenance_timeslot
WHERE msp.maintenance_id = maintenance.id
AND maintenance_timeslot.maintenance_id = maintenance.id
AND msp.status_page_id = ?
AND ${activeCondition}
ORDER BY maintenance.end_date
`, [ statusPageId ]));
for (const bean of maintenanceBeanList) {
publicMaintenanceList.push(await bean.toPublicJSON());
}
return publicMaintenanceList;
} catch (error) {
return [];
}
}
}
module.exports = StatusPage;

View File

@@ -20,6 +20,11 @@ class Ntfy extends NotificationProvider {
"priority": notification.ntfyPriority || 4,
"title": "Uptime-Kuma",
};
if (notification.ntfyIcon) {
data.icon = notification.ntfyIcon;
}
await axios.post(`${notification.ntfyserverurl}`, data, { headers: headers });
return okMsg;

View File

@@ -19,26 +19,26 @@ class Pushbullet extends NotificationProvider {
}
};
if (heartbeatJSON == null) {
let testdata = {
let data = {
"type": "note",
"title": "Uptime Kuma Alert",
"body": "Testing Successful.",
"body": msg,
};
await axios.post(pushbulletUrl, testdata, config);
await axios.post(pushbulletUrl, data, config);
} else if (heartbeatJSON["status"] === DOWN) {
let downdata = {
let downData = {
"type": "note",
"title": "UptimeKuma Alert: " + monitorJSON["name"],
"body": "[🔴 Down] " + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"],
};
await axios.post(pushbulletUrl, downdata, config);
await axios.post(pushbulletUrl, downData, config);
} else if (heartbeatJSON["status"] === UP) {
let updata = {
let upData = {
"type": "note",
"title": "UptimeKuma Alert: " + monitorJSON["name"],
"body": "[✅ Up] " + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"],
};
await axios.post(pushbulletUrl, updata, config);
await axios.post(pushbulletUrl, upData, config);
}
return okMsg;
} catch (error) {

View File

@@ -0,0 +1,71 @@
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
class SMSEagle extends NotificationProvider {
name = "SMSEagle";
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
let okMsg = "Sent Successfully.";
try {
let config = {
headers: {
"Content-Type": "application/json",
}
};
let postData;
let sendMethod;
let recipientType;
let encoding = (notification.smseagleEncoding) ? "1" : "0";
let priority = (notification.smseaglePriority) ? notification.smseaglePriority : "0";
if (notification.smseagleRecipientType === "smseagle-contact") {
recipientType = "contactname";
sendMethod = "sms.send_tocontact";
}
if (notification.smseagleRecipientType === "smseagle-group") {
recipientType = "groupname";
sendMethod = "sms.send_togroup";
}
if (notification.smseagleRecipientType === "smseagle-to") {
recipientType = "to";
sendMethod = "sms.send_sms";
}
let params = {
access_token: notification.smseagleToken,
[recipientType]: notification.smseagleRecipient,
message: msg,
responsetype: "extended",
unicode: encoding,
highpriority: priority
};
postData = {
method: sendMethod,
params: params
};
let resp = await axios.post(notification.smseagleUrl + "/jsonrpc/sms", postData, config);
if ((JSON.stringify(resp.data)).indexOf("message_id") === -1) {
let error = "";
if (resp.data.result && resp.data.result.error_text) {
error = `SMSEagle API returned error: ${JSON.stringify(resp.data.result.error_text)}`;
} else {
error = "SMSEagle API returned an unexpected response";
}
throw new Error(error);
}
return okMsg;
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}
module.exports = SMSEagle;

View File

@@ -16,20 +16,29 @@ class Webhook extends NotificationProvider {
msg,
};
let finalData;
let config = {};
let config = {
headers: {}
};
if (notification.webhookContentType === "form-data") {
finalData = new FormData();
finalData.append("data", JSON.stringify(data));
config = {
headers: finalData.getHeaders(),
};
config.headers = finalData.getHeaders();
} else {
finalData = data;
}
if (notification.webhookAdditionalHeaders) {
try {
config.headers = {
...config.headers,
...JSON.parse(notification.webhookAdditionalHeaders)
};
} catch (err) {
throw "Additional Headers is not a valid JSON";
}
}
await axios.post(notification.webhookURL, finalData, config);
return okMsg;

View File

@@ -32,6 +32,7 @@ const RocketChat = require("./notification-providers/rocket-chat");
const SerwerSMS = require("./notification-providers/serwersms");
const Signal = require("./notification-providers/signal");
const Slack = require("./notification-providers/slack");
const SMSEagle = require("./notification-providers/smseagle");
const SMTP = require("./notification-providers/smtp");
const Squadcast = require("./notification-providers/squadcast");
const Stackfield = require("./notification-providers/stackfield");
@@ -89,6 +90,7 @@ class Notification {
new Signal(),
new SMSManager(),
new Slack(),
new SMSEagle(),
new SMTP(),
new Squadcast(),
new Stackfield(),

View File

@@ -7,7 +7,7 @@ const { UptimeKumaServer } = require("./uptime-kuma-server");
class Proxy {
static SUPPORTED_PROXY_PROTOCOLS = [ "http", "https", "socks", "socks5", "socks4" ];
static SUPPORTED_PROXY_PROTOCOLS = [ "http", "https", "socks", "socks5", "socks5h", "socks4" ];
/**
* Saves and updates given proxy entity
@@ -126,6 +126,7 @@ class Proxy {
break;
case "socks":
case "socks5":
case "socks5h":
case "socks4":
agent = new SocksProxyAgent({
...httpAgentOptions,

View File

@@ -4,7 +4,7 @@ const { R } = require("redbean-node");
const apicache = require("../modules/apicache");
const Monitor = require("../model/monitor");
const dayjs = require("dayjs");
const { UP, DOWN, flipStatus, log } = require("../../src/util");
const { UP, MAINTENANCE, DOWN, flipStatus, log } = require("../../src/util");
const StatusPage = require("../model/status_page");
const { UptimeKumaServer } = require("../uptime-kuma-server");
const { makeBadge } = require("badge-maker");
@@ -67,6 +67,11 @@ router.get("/api/push/:pushToken", async (request, response) => {
duration = dayjs(bean.time).diff(dayjs(previousHeartbeat.time), "second");
}
if (await Monitor.isUnderMaintenance(monitor.id)) {
msg = "Monitor under maintenance";
status = MAINTENANCE;
}
log.debug("router", `/api/push/ called at ${dayjs().format("YYYY-MM-DD HH:mm:ss.SSS")}`);
log.debug("router", "PreviousStatus: " + previousStatus);
log.debug("router", "Current Status: " + status);
@@ -87,7 +92,7 @@ router.get("/api/push/:pushToken", async (request, response) => {
ok: true,
});
if (bean.important) {
if (Monitor.isImportantForNotification(isFirstBeat, previousStatus, status)) {
await Monitor.sendNotification(isFirstBeat, monitor, bean);
}

View File

@@ -5,6 +5,12 @@
*/
console.log("Welcome to Uptime Kuma");
// As the log function need to use dayjs, it should be very top
const dayjs = require("dayjs");
dayjs.extend(require("dayjs/plugin/utc"));
dayjs.extend(require("dayjs/plugin/timezone"));
dayjs.extend(require("dayjs/plugin/customParseFormat"));
// Check Node.js Version
const nodeVersion = parseInt(process.versions.node.split(".")[0]);
const requiredVersion = 14;
@@ -33,6 +39,7 @@ log.info("server", "Importing Node libraries");
const fs = require("fs");
log.info("server", "Importing 3rd-party libraries");
log.debug("server", "Importing express");
const express = require("express");
const expressStaticGzip = require("express-static-gzip");
@@ -127,6 +134,8 @@ 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");
const { dockerSocketHandler } = require("./socket-handlers/docker-socket-handler");
const { maintenanceSocketHandler } = require("./socket-handlers/maintenance-socket-handler");
const { Settings } = require("./settings");
app.use(express.json());
@@ -154,8 +163,9 @@ let needSetup = false;
(async () => {
Database.init(args);
await initDatabase(testMode);
await server.initAfterDatabaseReady();
exports.entryPage = await setting("entryPage");
server.entryPage = await Settings.get("entryPage");
await StatusPage.loadDomainMappingList();
log.info("server", "Adding route");
@@ -176,14 +186,15 @@ let needSetup = false;
log.debug("entry", `Request Domain: ${hostname}`);
const uptimeKumaEntryPage = server.entryPage;
if (hostname in StatusPage.domainMappingList) {
log.debug("entry", "This is a status page domain");
let slug = StatusPage.domainMappingList[hostname];
await StatusPage.handleStatusPageResponse(response, server.indexHTML, slug);
} else if (exports.entryPage && exports.entryPage.startsWith("statusPage-")) {
response.redirect("/status/" + exports.entryPage.replace("statusPage-", ""));
} else if (uptimeKumaEntryPage && uptimeKumaEntryPage.startsWith("statusPage-")) {
response.redirect("/status/" + uptimeKumaEntryPage.replace("statusPage-", ""));
} else {
response.redirect("/dashboard");
@@ -192,6 +203,7 @@ let needSetup = false;
if (isDev) {
app.post("/test-webhook", async (request, response) => {
log.debug("test", request.headers);
log.debug("test", request.body);
response.send("OK");
});
@@ -200,7 +212,7 @@ let needSetup = false;
// Robots.txt
app.get("/robots.txt", async (_request, response) => {
let txt = "User-agent: *\nDisallow:";
if (! await setting("searchEngineIndex")) {
if (!await setting("searchEngineIndex")) {
txt += " /";
}
response.setHeader("Content-Type", "text/plain");
@@ -695,6 +707,12 @@ let needSetup = false;
bean.authMethod = monitor.authMethod;
bean.authWorkstation = monitor.authWorkstation;
bean.authDomain = monitor.authDomain;
bean.grpcUrl = monitor.grpcUrl;
bean.grpcProtobuf = monitor.grpcProtobuf;
bean.grpcMethod = monitor.grpcMethod;
bean.grpcBody = monitor.grpcBody;
bean.grpcMetadata = monitor.grpcMetadata;
bean.grpcEnableTls = monitor.grpcEnableTls;
bean.radiusUsername = monitor.radiusUsername;
bean.radiusPassword = monitor.radiusPassword;
bean.radiusCalledStationId = monitor.radiusCalledStationId;
@@ -1055,10 +1073,15 @@ let needSetup = false;
socket.on("getSettings", async (callback) => {
try {
checkLogin(socket);
const data = await getSettings("general");
if (!data.serverTimezone) {
data.serverTimezone = await server.getTimezone();
}
callback({
ok: true,
data: await getSettings("general"),
data: data,
});
} catch (e) {
@@ -1084,7 +1107,12 @@ let needSetup = false;
}
await setSettings("general", data);
exports.entryPage = data.entryPage;
server.entryPage = data.entryPage;
// Also need to apply timezone globally
if (data.serverTimezone) {
await server.setTimezone(data.serverTimezone);
}
callback({
ok: true,
@@ -1092,6 +1120,7 @@ let needSetup = false;
});
sendInfo(socket);
server.sendMaintenanceList(socket);
} catch (e) {
callback({
@@ -1450,6 +1479,7 @@ let needSetup = false;
databaseSocketHandler(socket);
proxySocketHandler(socket);
dockerSocketHandler(socket);
maintenanceSocketHandler(socket);
log.debug("server", "added all socket handlers");
@@ -1552,6 +1582,7 @@ async function afterLogin(socket, user) {
socket.join(user.id);
let monitorList = await server.sendMonitorList(socket);
server.sendMaintenanceList(socket);
sendNotificationList(socket);
sendProxyList(socket);
sendDockerHostList(socket);
@@ -1697,6 +1728,8 @@ async function shutdownFunction(signal) {
log.info("server", "Shutdown requested");
log.info("server", "Called signal: " + signal);
await server.stop();
log.info("server", "Stopping all monitors");
for (let id in server.monitorList) {
let monitor = server.monitorList[id];

View File

@@ -0,0 +1,311 @@
const { checkLogin } = require("../util-server");
const { log } = require("../../src/util");
const { R } = require("redbean-node");
const apicache = require("../modules/apicache");
const { UptimeKumaServer } = require("../uptime-kuma-server");
const Maintenance = require("../model/maintenance");
const server = UptimeKumaServer.getInstance();
const MaintenanceTimeslot = require("../model/maintenance_timeslot");
/**
* Handlers for Maintenance
* @param {Socket} socket Socket.io instance
*/
module.exports.maintenanceSocketHandler = (socket) => {
// Add a new maintenance
socket.on("addMaintenance", async (maintenance, callback) => {
try {
checkLogin(socket);
log.debug("maintenance", maintenance);
let bean = Maintenance.jsonToBean(R.dispense("maintenance"), maintenance);
bean.user_id = socket.userID;
let maintenanceID = await R.store(bean);
await MaintenanceTimeslot.generateTimeslot(bean);
await server.sendMaintenanceList(socket);
callback({
ok: true,
msg: "Added Successfully.",
maintenanceID,
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
// Edit a maintenance
socket.on("editMaintenance", async (maintenance, callback) => {
try {
checkLogin(socket);
let bean = await R.findOne("maintenance", " id = ? ", [ maintenance.id ]);
if (bean.user_id !== socket.userID) {
throw new Error("Permission denied.");
}
Maintenance.jsonToBean(bean, maintenance);
await R.store(bean);
await MaintenanceTimeslot.generateTimeslot(bean, null, true);
await server.sendMaintenanceList(socket);
callback({
ok: true,
msg: "Saved.",
maintenanceID: bean.id,
});
} catch (e) {
console.error(e);
callback({
ok: false,
msg: e.message,
});
}
});
// Add a new monitor_maintenance
socket.on("addMonitorMaintenance", async (maintenanceID, monitors, callback) => {
try {
checkLogin(socket);
await R.exec("DELETE FROM monitor_maintenance WHERE maintenance_id = ?", [
maintenanceID
]);
for await (const monitor of monitors) {
let bean = R.dispense("monitor_maintenance");
bean.import({
monitor_id: monitor.id,
maintenance_id: maintenanceID
});
await R.store(bean);
}
apicache.clear();
callback({
ok: true,
msg: "Added Successfully.",
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
// Add a new monitor_maintenance
socket.on("addMaintenanceStatusPage", async (maintenanceID, statusPages, callback) => {
try {
checkLogin(socket);
await R.exec("DELETE FROM maintenance_status_page WHERE maintenance_id = ?", [
maintenanceID
]);
for await (const statusPage of statusPages) {
let bean = R.dispense("maintenance_status_page");
bean.import({
status_page_id: statusPage.id,
maintenance_id: maintenanceID
});
await R.store(bean);
}
apicache.clear();
callback({
ok: true,
msg: "Added Successfully.",
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("getMaintenance", async (maintenanceID, callback) => {
try {
checkLogin(socket);
log.debug("maintenance", `Get Maintenance: ${maintenanceID} User ID: ${socket.userID}`);
let bean = await R.findOne("maintenance", " id = ? AND user_id = ? ", [
maintenanceID,
socket.userID,
]);
callback({
ok: true,
maintenance: await bean.toJSON(),
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("getMaintenanceList", async (callback) => {
try {
checkLogin(socket);
await server.sendMaintenanceList(socket);
callback({
ok: true,
});
} catch (e) {
console.error(e);
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("getMonitorMaintenance", async (maintenanceID, callback) => {
try {
checkLogin(socket);
log.debug("maintenance", `Get Monitors for Maintenance: ${maintenanceID} User ID: ${socket.userID}`);
let monitors = await R.getAll("SELECT monitor.id, monitor.name FROM monitor_maintenance mm JOIN monitor ON mm.monitor_id = monitor.id WHERE mm.maintenance_id = ? ", [
maintenanceID,
]);
callback({
ok: true,
monitors,
});
} catch (e) {
console.error(e);
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("getMaintenanceStatusPage", async (maintenanceID, callback) => {
try {
checkLogin(socket);
log.debug("maintenance", `Get Status Pages for Maintenance: ${maintenanceID} User ID: ${socket.userID}`);
let statusPages = await R.getAll("SELECT status_page.id, status_page.title FROM maintenance_status_page msp JOIN status_page ON msp.status_page_id = status_page.id WHERE msp.maintenance_id = ? ", [
maintenanceID,
]);
callback({
ok: true,
statusPages,
});
} catch (e) {
console.error(e);
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("deleteMaintenance", async (maintenanceID, callback) => {
try {
checkLogin(socket);
log.debug("maintenance", `Delete Maintenance: ${maintenanceID} User ID: ${socket.userID}`);
if (maintenanceID in server.maintenanceList) {
delete server.maintenanceList[maintenanceID];
}
await R.exec("DELETE FROM maintenance WHERE id = ? AND user_id = ? ", [
maintenanceID,
socket.userID,
]);
callback({
ok: true,
msg: "Deleted Successfully.",
});
await server.sendMaintenanceList(socket);
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("pauseMaintenance", async (maintenanceID, callback) => {
try {
checkLogin(socket);
log.debug("maintenance", `Pause Maintenance: ${maintenanceID} User ID: ${socket.userID}`);
await R.exec("UPDATE maintenance SET active = 0 WHERE id = ? ", [
maintenanceID,
]);
callback({
ok: true,
msg: "Paused Successfully.",
});
await server.sendMaintenanceList(socket);
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("resumeMaintenance", async (maintenanceID, callback) => {
try {
checkLogin(socket);
log.debug("maintenance", `Resume Maintenance: ${maintenanceID} User ID: ${socket.userID}`);
await R.exec("UPDATE maintenance SET active = 1 WHERE id = ? ", [
maintenanceID,
]);
callback({
ok: true,
msg: "Resume Successfully",
});
await server.sendMaintenanceList(socket);
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
};

View File

@@ -9,6 +9,8 @@ const Database = require("./database");
const util = require("util");
const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent");
const { Settings } = require("./settings");
const dayjs = require("dayjs");
// DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()`
/**
* `module.exports` (alias: `server`) should be inside this class, in order to avoid circular dependency issue.
@@ -26,6 +28,13 @@ class UptimeKumaServer {
* @type {{}}
*/
monitorList = {};
/**
* Main maintenance list
* @type {{}}
*/
maintenanceList = {};
entryPage = "dashboard";
app = undefined;
httpServer = undefined;
@@ -37,6 +46,8 @@ class UptimeKumaServer {
*/
indexHTML = "";
generateMaintenanceTimeslotsInterval = undefined;
static getInstance(args) {
if (UptimeKumaServer.instance == null) {
UptimeKumaServer.instance = new UptimeKumaServer(args);
@@ -77,6 +88,16 @@ class UptimeKumaServer {
this.io = new Server(this.httpServer);
}
async initAfterDatabaseReady() {
process.env.TZ = await this.getTimezone();
dayjs.tz.setDefault(process.env.TZ);
log.debug("DEBUG", "Timezone: " + process.env.TZ);
log.debug("DEBUG", "Current Time: " + dayjs.tz().format());
await this.generateMaintenanceTimeslots();
this.generateMaintenanceTimeslotsInterval = setInterval(this.generateMaintenanceTimeslots, 60 * 1000);
}
async sendMonitorList(socket) {
let list = await this.getMonitorJSONList(socket.userID);
this.io.to(socket.userID).emit("monitorList", list);
@@ -104,6 +125,40 @@ class UptimeKumaServer {
return result;
}
/**
* Send maintenance list to client
* @param {Socket} socket Socket.io instance to send to
* @returns {Object}
*/
async sendMaintenanceList(socket) {
return await this.sendMaintenanceListByUserID(socket.userID);
}
async sendMaintenanceListByUserID(userID) {
let list = await this.getMaintenanceJSONList(userID);
this.io.to(userID).emit("maintenanceList", list);
return list;
}
/**
* Get a list of maintenances for the given user.
* @param {string} userID - The ID of the user to get maintenances for.
* @returns {Promise<Object>} A promise that resolves to an object with maintenance IDs as keys and maintenances objects as values.
*/
async getMaintenanceJSONList(userID) {
let result = {};
let maintenanceList = await R.find("maintenance", " user_id = ? ORDER BY end_date DESC, title", [
userID,
]);
for (let maintenance of maintenanceList) {
result[maintenance.id] = await maintenance.toJSON();
}
return result;
}
/**
* Write error to log file
* @param {any} error The error to write
@@ -147,8 +202,49 @@ class UptimeKumaServer {
return clientIP.replace(/^.*:/, "");
}
}
async getTimezone() {
let timezone = await Settings.get("serverTimezone");
if (timezone) {
return timezone;
} else if (process.env.TZ) {
return process.env.TZ;
} else {
return dayjs.tz.guess();
}
}
getTimezoneOffset() {
return dayjs().format("Z");
}
async setTimezone(timezone) {
await Settings.set("serverTimezone", timezone, "general");
process.env.TZ = timezone;
dayjs.tz.setDefault(timezone);
}
async generateMaintenanceTimeslots() {
let list = await R.find("maintenance_timeslot", " generated_next = 0 AND start_date <= DATETIME('now') ");
for (let maintenanceTimeslot of list) {
let maintenance = await maintenanceTimeslot.maintenance;
await MaintenanceTimeslot.generateTimeslot(maintenance, maintenanceTimeslot.end_date, false);
maintenanceTimeslot.generated_next = true;
await R.store(maintenanceTimeslot);
}
}
async stop() {
clearTimeout(this.generateMaintenanceTimeslotsInterval);
}
}
module.exports = {
UptimeKumaServer
};
// Must be at the end
const MaintenanceTimeslot = require("./model/maintenance_timeslot");

View File

@@ -13,14 +13,18 @@ const { badgeConstants } = require("./config");
const mssql = require("mssql");
const { Client } = require("pg");
const postgresConParse = require("pg-connection-string").parse;
const mysql = require("mysql2");
const { NtlmClient } = require("axios-ntlm");
const { Settings } = require("./settings");
const grpc = require("@grpc/grpc-js");
const protojs = require("protobufjs");
const radiusClient = require("node-radius-client");
const {
dictionaries: {
rfc2865: { file, attributes },
},
} = require("node-radius-utils");
const dayjs = require("dayjs");
// From ping-lite
exports.WIN = /^win/.test(process.platform);
@@ -291,6 +295,39 @@ exports.postgresQuery = function (connectionString, query) {
});
};
/**
* Run a query on MySQL/MariaDB
* @param {string} connectionString The database connection string
* @param {string} query The query to validate the database with
* @returns {Promise<(string[]|Object[]|Object)>}
*/
exports.mysqlQuery = function (connectionString, query) {
return new Promise((resolve, reject) => {
const connection = mysql.createConnection(connectionString);
connection.promise().query(query)
.then(res => {
resolve(res);
})
.catch(err => {
reject(err);
})
.finally(() => {
connection.end();
});
});
};
/**
* Query radius server
* @param {string} hostname Hostname of radius server
* @param {string} username Username to use
* @param {string} password Password to use
* @param {string} calledStationId ID of called station
* @param {string} callingStationId ID of calling station
* @param {string} secret Secret to use
* @param {number} [port=1812] Port to contact radius server on
* @returns {Promise<any>}
*/
exports.radius = function (
hostname,
username,
@@ -298,9 +335,11 @@ exports.radius = function (
calledStationId,
callingStationId,
secret,
port = 1812,
) {
const client = new radiusClient({
host: hostname,
hostPort: port,
dictionaries: [ file ],
});
@@ -645,3 +684,112 @@ module.exports.send403 = (res, msg = "") => {
"msg": msg,
});
};
function timeObjectConvertTimezone(obj, timezone, timeObjectToUTC = true) {
let offsetString;
if (timezone) {
offsetString = dayjs().tz(timezone).format("Z");
} else {
offsetString = dayjs().format("Z");
}
let hours = parseInt(offsetString.substring(1, 3));
let minutes = parseInt(offsetString.substring(4, 6));
if (
(timeObjectToUTC && offsetString.startsWith("+")) ||
(!timeObjectToUTC && offsetString.startsWith("-"))
) {
hours *= -1;
minutes *= -1;
}
obj.hours += hours;
obj.minutes += minutes;
// Handle out of bound
if (obj.minutes < 0) {
obj.minutes += 60;
obj.hours--;
} else if (obj.minutes > 60) {
obj.minutes -= 60;
obj.hours++;
}
if (obj.hours < 0) {
obj.hours += 24;
} else if (obj.hours > 24) {
obj.hours -= 24;
}
return obj;
}
/**
*
* @param {object} obj
* @param {string} timezone
* @returns {object}
*/
module.exports.timeObjectToUTC = (obj, timezone = undefined) => {
return timeObjectConvertTimezone(obj, timezone, true);
};
/**
*
* @param {object} obj
* @param {string} timezone
* @returns {object}
*/
module.exports.timeObjectToLocal = (obj, timezone = undefined) => {
return timeObjectConvertTimezone(obj, timezone, false);
};
/**
* Create gRPC client stib
* @param {Object} options from gRPC client
*/
module.exports.grpcQuery = async (options) => {
const { grpcUrl, grpcProtobufData, grpcServiceName, grpcEnableTls, grpcMethod, grpcBody } = options;
const protocObject = protojs.parse(grpcProtobufData);
const protoServiceObject = protocObject.root.lookupService(grpcServiceName);
const Client = grpc.makeGenericClientConstructor({});
const credentials = grpcEnableTls ? grpc.credentials.createSsl() : grpc.credentials.createInsecure();
const client = new Client(
grpcUrl,
credentials
);
const grpcService = protoServiceObject.create(function (method, requestData, cb) {
const fullServiceName = method.fullName;
const serviceFQDN = fullServiceName.split(".");
const serviceMethod = serviceFQDN.pop();
const serviceMethodClientImpl = `/${serviceFQDN.slice(1).join(".")}/${serviceMethod}`;
log.debug("monitor", `gRPC method ${serviceMethodClientImpl}`);
client.makeUnaryRequest(
serviceMethodClientImpl,
arg => arg,
arg => arg,
requestData,
cb);
}, false, false);
return new Promise((resolve, _) => {
return grpcService[`${grpcMethod}`](JSON.parse(grpcBody), function (err, response) {
const responseData = JSON.stringify(response);
if (err) {
return resolve({
code: err.code,
errorMessage: err.details,
data: ""
});
} else {
log.debug("monitor:", `gRPC response: ${response}`);
return resolve({
code: 1,
errorMessage: "",
data: responseData
});
}
});
});
};

View File

@@ -22,6 +22,19 @@ textarea.form-control {
width: 10px;
}
.bg-maintenance {
color: white !important;
background-color: $maintenance !important;
}
.bg-dark {
color: white;
}
.text-maintenance {
color: $maintenance !important;
}
.list-group {
border-radius: 0.75rem;
@@ -107,6 +120,19 @@ optgroup {
}
}
.btn-normal {
$bg-color: #F5F5F5;
background-color: $bg-color;
border-color: $bg-color;
&:hover {
$hover-color: darken($bg-color, 3%);
background-color: $hover-color;
border-color: $hover-color;
}
}
.btn-warning {
color: white;
@@ -256,6 +282,20 @@ optgroup {
color: white;
}
.btn-normal {
$bg-color: $dark-header-bg;
color: $dark-font-color;
background-color: $bg-color;
border-color: $bg-color;
&:hover {
$hover-color: darken($bg-color, 3%);
background-color: $hover-color;
border-color: $hover-color;
}
}
.btn-warning {
color: $dark-font-color2;
@@ -323,6 +363,7 @@ optgroup {
&.bg-info,
&.bg-warning,
&.bg-danger,
&.bg-maintenance,
&.bg-light {
color: $dark-font-color2;
}

View File

@@ -1,6 +1,7 @@
$primary: #5cdd8b;
$danger: #dc3545;
$warning: #f8a306;
$maintenance: #1747f5;
$link-color: #111;
$border-radius: 50rem;

View File

@@ -0,0 +1,39 @@
@import "@vuepic/vue-datepicker/dist/main.css";
@import "vars.scss";
// Must use #{ }
// Remark: https://stackoverflow.com/questions/50202991/unable-to-set-scss-variable-to-css-variable
.dp__theme_dark {
--dp-background-color: #{$dark-bg2};
--dp-text-color: #{$dark-font-color};
--dp-hover-color: #484848;
--dp-hover-text-color: #ffffff;
--dp-hover-icon-color: #959595;
--dp-primary-color: #{#5cdd8b};
--dp-primary-text-color: #ffffff;
--dp-secondary-color: #494949;
--dp-border-color: #{$dark-border-color};
--dp-menu-border-color: #2d2d2d;
--dp-border-color-hover: #{$dark-border-color};
--dp-disabled-color: #212121;
--dp-scroll-bar-background: #212121;
--dp-scroll-bar-color: #484848;
--dp-success-color: #{$primary};
--dp-success-color-disabled: #428f59;
--dp-icon-color: #959595;
--dp-danger-color: #e53935;
--dp-highlight-color: rgba(0, 92, 178, 0.2);
}
.dp__input {
border-radius: $border-radius;
}
// Fix: Full width of text input when using "inline textInput inlineWithInput" mode
.dp__main > div[aria-label="Datepicker input"] {
width: 100%;
}
.dp__main > div[aria-label="Datepicker menu"]:nth-child(2) {
margin-top: 20px;
}

View File

@@ -3,14 +3,6 @@
</template>
<script>
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import timezone from "dayjs/plugin/timezone"; // dependent on utc plugin
import utc from "dayjs/plugin/utc";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(relativeTime);
export default {
props: {
/** Value of date time */

View File

@@ -5,7 +5,7 @@
v-for="(beat, index) in shortBeatList"
:key="index"
class="beat"
:class="{ 'empty' : (beat === 0), 'down' : (beat.status === 0), 'pending' : (beat.status === 2) }"
:class="{ 'empty' : (beat === 0), 'down' : (beat.status === 0), 'pending' : (beat.status === 2), 'maintenance' : (beat.status === 3) }"
:style="beatStyle"
:title="getBeatTitle(beat)"
/>
@@ -211,6 +211,10 @@ export default {
background-color: $warning;
}
&.maintenance {
background-color: $maintenance;
}
&:not(.empty):hover {
transition: all ease-in-out 0.15s;
opacity: 0.8;

View File

@@ -42,7 +42,7 @@ export default {
/** Should the field auto complete */
autocomplete: {
type: String,
default: undefined,
default: "new-password",
},
/** Is the input required? */
required: {

View File

@@ -0,0 +1,44 @@
<template>
<div>
<div v-if="maintenance.strategy === 'manual'" class="timeslot">
{{ $t("Manual") }}
</div>
<div v-else-if="maintenance.timeslotList.length > 0" class="timeslot">
{{ maintenance.timeslotList[0].startDateServerTimezone }}
<span class="to">-</span>
{{ maintenance.timeslotList[0].endDateServerTimezone }}
(UTC{{ maintenance.timeslotList[0].serverTimezoneOffset }})
</div>
</div>
</template>
<script>
export default {
props: {
maintenance: {
type: Object,
required: true
},
},
};
</script>
<style lang="scss">
.timeslot {
margin-top: 5px;
display: inline-block;
font-size: 14px;
background-color: rgba(255, 255, 255, 0.5);
border-radius: 20px;
padding: 0 10px;
.to {
margin: 0 6px;
}
.dark & {
color: white;
background-color: rgba(255, 255, 255, 0.1);
}
}
</style>

View File

@@ -206,6 +206,16 @@ export default {
.search-icon {
padding: 10px;
color: #c0c0c0;
// Clear filter button (X)
svg[data-icon="times"] {
cursor: pointer;
transition: all ease-in-out 0.1s;
&:hover {
color: white;
}
}
}
.search-input {

View File

@@ -16,18 +16,14 @@
</div>
</template>
<script lang="ts">
<script lang="js">
import { BarController, BarElement, Chart, Filler, LinearScale, LineController, LineElement, PointElement, TimeScale, Tooltip } from "chart.js";
import "chartjs-adapter-dayjs";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { LineChart } from "vue-chart-3";
import { useToast } from "vue-toastification";
import { DOWN, log } from "../util.ts";
import { DOWN, PENDING, MAINTENANCE, log } from "../util.ts";
dayjs.extend(utc);
dayjs.extend(timezone);
const toast = useToast();
Chart.register(LineController, BarController, LineElement, PointElement, TimeScale, BarElement, LinearScale, Tooltip, Filler);
@@ -163,7 +159,8 @@ export default {
},
chartData() {
let pingData = []; // Ping Data for Line Chart, y-axis contains ping time
let downData = []; // Down Data for Bar Chart, y-axis is 1 if target is down, 0 if target is up
let downData = []; // Down Data for Bar Chart, y-axis is 1 if target is down (red color), under maintenance (blue color) or pending (orange color), 0 if target is up
let colorData = []; // Color Data for Bar Chart
let heartbeatList = this.heartbeatList ||
(this.monitorId in this.$root.heartbeatList && this.$root.heartbeatList[this.monitorId]) ||
@@ -185,8 +182,9 @@ export default {
});
downData.push({
x,
y: beat.status === DOWN ? 1 : 0,
y: (beat.status === DOWN || beat.status === MAINTENANCE || beat.status === PENDING) ? 1 : 0,
});
colorData.push((beat.status === MAINTENANCE) ? "rgba(23,71,245,0.41)" : ((beat.status === PENDING) ? "rgba(245,182,23,0.41)" : "#DC354568"));
});
return {
@@ -205,7 +203,7 @@ export default {
type: "bar",
data: downData,
borderColor: "#00000000",
backgroundColor: "#DC354568",
backgroundColor: colorData,
yAxisID: "y1",
barThickness: "flex",
barPercentage: 1,

View File

@@ -17,6 +17,7 @@
<option value="http">HTTP</option>
<option value="socks">SOCKS</option>
<option value="socks5">SOCKS v5</option>
<option value="socks5h">SOCKS v5 (+DNS)</option>
<option value="socks4">SOCKS v4</option>
</select>
</div>

View File

@@ -225,4 +225,8 @@ export default {
}
}
.bg-maintenance {
background-color: $maintenance;
}
</style>

View File

@@ -26,6 +26,10 @@ export default {
return "warning";
}
if (this.status === 3) {
return "maintenance";
}
return "secondary";
},
@@ -42,6 +46,10 @@ export default {
return this.$t("Pending");
}
if (this.status === 3) {
return this.$t("statusMaintenance");
}
return this.$t("Unknown");
},
},

View File

@@ -25,6 +25,10 @@ export default {
computed: {
uptime() {
if (this.type === "maintenance") {
return this.$t("statusMaintenance");
}
let key = this.monitor.id + "_" + this.type;
if (this.$root.uptimeList[key] !== undefined) {
@@ -35,6 +39,10 @@ export default {
},
color() {
if (this.type === "maintenance" || this.monitor.maintenance) {
return "maintenance";
}
if (this.lastHeartBeat.status === 0) {
return "danger";
}

View File

@@ -6,7 +6,7 @@
</i18n-t>
<input id="clicksendsms-login" v-model="$parent.notification.clicksendsmsLogin" type="text" class="form-control" required>
<label for="clicksendsms-key" class="form-label">{{ $t("API Key") }}</label>
<HiddenInput id="clicksendsms-key" v-model="$parent.notification.clicksendsmsPassword" :required="true" autocomplete="one-time-code"></HiddenInput>
<HiddenInput id="clicksendsms-key" v-model="$parent.notification.clicksendsmsPassword" :required="true" autocomplete="new-password"></HiddenInput>
</div>
<div class="mb-3">
<div class="form-text">

View File

@@ -11,7 +11,7 @@
<div class="mb-3">
<label for="goalert-token" class="form-label">{{ $t("Token") }}</label>
<HiddenInput id="goalert-token" v-model="$parent.notification.goAlertToken" autocomplete="one-time-code" :required="true"></HiddenInput>
<HiddenInput id="goalert-token" v-model="$parent.notification.goAlertToken" autocomplete="new-password" :required="true"></HiddenInput>
<div class="form-text">
{{ $t("goAlertIntegrationKeyInfo") }}

View File

@@ -1,7 +1,7 @@
<template>
<div class="mb-3">
<label for="gotify-application-token" class="form-label">{{ $t("Application Token") }}</label>
<HiddenInput id="gotify-application-token" v-model="$parent.notification.gotifyapplicationToken" :required="true" autocomplete="one-time-code"></HiddenInput>
<HiddenInput id="gotify-application-token" v-model="$parent.notification.gotifyapplicationToken" :required="true" autocomplete="new-password"></HiddenInput>
</div>
<div class="mb-3">
<label for="gotify-server-url" class="form-label">{{ $t("Server URL") }}</label>

View File

@@ -1,7 +1,7 @@
<template>
<div class="mb-3">
<label for="line-channel-access-token" class="form-label">{{ $t("Channel access token") }}</label>
<HiddenInput id="line-channel-access-token" v-model="$parent.notification.lineChannelAccessToken" :required="true" autocomplete="one-time-code"></HiddenInput>
<HiddenInput id="line-channel-access-token" v-model="$parent.notification.lineChannelAccessToken" :required="true" autocomplete="new-password"></HiddenInput>
</div>
<i18n-t tag="div" keypath="lineDevConsoleTo" class="form-text">
<b>{{ $t("Basic Settings") }}</b>

View File

@@ -9,7 +9,7 @@
</div>
<div class="mb-3">
<label for="access-token" class="form-label">{{ $t("Access Token") }}</label><span style="color: red;"><sup>*</sup></span>
<HiddenInput id="access-token" v-model="$parent.notification.accessToken" :required="true" autocomplete="one-time-code" :maxlength="500"></HiddenInput>
<HiddenInput id="access-token" v-model="$parent.notification.accessToken" :required="true" autocomplete="new-password" :maxlength="500"></HiddenInput>
</div>
<div class="form-text">

View File

@@ -27,6 +27,10 @@
<HiddenInput id="ntfy-password" v-model="$parent.notification.ntfypassword" autocomplete="new-password"></HiddenInput>
</div>
</div>
<div class="mb-3">
<label for="ntfy-icon" class="form-label">{{ $t("IconUrl") }}</label>
<input id="ntfy-icon" v-model="$parent.notification.ntfyIcon" type="text" class="form-control">
</div>
</template>
<script>

View File

@@ -11,7 +11,7 @@
</div>
<div class="mb-3">
<label for="octopush-key" class="form-label">{{ $t("octopushAPIKey") }}</label>
<HiddenInput id="octopush-key" v-model="$parent.notification.octopushAPIKey" :required="true" autocomplete="one-time-code"></HiddenInput>
<HiddenInput id="octopush-key" v-model="$parent.notification.octopushAPIKey" :required="true" autocomplete="new-password"></HiddenInput>
<label for="octopush-login" class="form-label">{{ $t("octopushLogin") }}</label>
<input id="octopush-login" v-model="$parent.notification.octopushLogin" type="text" class="form-control" required>
</div>

View File

@@ -3,7 +3,7 @@
<label for="promosms-login" class="form-label">{{ $t("promosmsLogin") }}</label>
<input id="promosms-login" v-model="$parent.notification.promosmsLogin" type="text" class="form-control" required>
<label for="promosms-key" class="form-label">{{ $t("promosmsPassword") }}</label>
<HiddenInput id="promosms-key" v-model="$parent.notification.promosmsPassword" :required="true" autocomplete="one-time-code"></HiddenInput>
<HiddenInput id="promosms-key" v-model="$parent.notification.promosmsPassword" :required="true" autocomplete="new-password"></HiddenInput>
</div>
<div class="mb-3">
<label for="promosms-type-sms" class="form-label">{{ $t("SMS Type") }}</label>

View File

@@ -1,7 +1,7 @@
<template>
<div class="mb-3">
<label for="pushdeer-key" class="form-label">{{ $t("PushDeer Key") }}</label>
<HiddenInput id="pushdeer-key" v-model="$parent.notification.pushdeerKey" :required="true" autocomplete="one-time-code" placeholder="PDUxxxx"></HiddenInput>
<HiddenInput id="pushdeer-key" v-model="$parent.notification.pushdeerKey" :required="true" autocomplete="new-password" placeholder="PDUxxxx"></HiddenInput>
</div>
<i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">

View File

@@ -1,7 +1,7 @@
<template>
<div class="mb-3">
<label for="pushbullet-access-token" class="form-label">{{ $t("Access Token") }}</label>
<HiddenInput id="pushbullet-access-token" v-model="$parent.notification.pushbulletAccessToken" :required="true" autocomplete="one-time-code"></HiddenInput>
<HiddenInput id="pushbullet-access-token" v-model="$parent.notification.pushbulletAccessToken" :required="true" autocomplete="new-password"></HiddenInput>
</div>
<i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">

View File

@@ -1,9 +1,9 @@
<template>
<div class="mb-3">
<label for="pushover-user" class="form-label">{{ $t("User Key") }}<span style="color: red;"><sup>*</sup></span></label>
<HiddenInput id="pushover-user" v-model="$parent.notification.pushoveruserkey" :required="true" autocomplete="one-time-code"></HiddenInput>
<HiddenInput id="pushover-user" v-model="$parent.notification.pushoveruserkey" :required="true" autocomplete="new-password"></HiddenInput>
<label for="pushover-app-token" class="form-label">{{ $t("Application Token") }}<span style="color: red;"><sup>*</sup></span></label>
<HiddenInput id="pushover-app-token" v-model="$parent.notification.pushoverapptoken" :required="true" autocomplete="one-time-code"></HiddenInput>
<HiddenInput id="pushover-app-token" v-model="$parent.notification.pushoverapptoken" :required="true" autocomplete="new-password"></HiddenInput>
<label for="pushover-device" class="form-label">{{ $t("Device") }}</label>
<input id="pushover-device" v-model="$parent.notification.pushoverdevice" type="text" class="form-control">
<label for="pushover-device" class="form-label">{{ $t("Message Title") }}</label>

View File

@@ -1,13 +1,13 @@
<template>
<div class="mb-3">
<label for="pushy-app-token" class="form-label">{{ $t("pushyAPIKey") }}</label>
<HiddenInput id="pushy-app-token" v-model="$parent.notification.pushyAPIKey" :required="true" autocomplete="one-time-code"></HiddenInput>
<HiddenInput id="pushy-app-token" v-model="$parent.notification.pushyAPIKey" :required="true" autocomplete="new-password"></HiddenInput>
</div>
<div class="mb-3">
<label for="pushy-user-key" class="form-label">{{ $t("pushyToken") }}</label>
<div class="input-group mb-3">
<HiddenInput id="pushy-user-key" v-model="$parent.notification.pushyToken" :required="true" autocomplete="one-time-code"></HiddenInput>
<HiddenInput id="pushy-user-key" v-model="$parent.notification.pushyToken" :required="true" autocomplete="new-password"></HiddenInput>
</div>
</div>
<i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">

View File

@@ -0,0 +1,40 @@
<template>
<div class="mb-3">
<label for="smseagle-url" class="form-label">{{ $t("smseagleUrl") }}</label>
<input id="smseagle-url" v-model="$parent.notification.smseagleUrl" type="text" minlength="7" class="form-control" placeholder="http://127.0.0.1" required>
</div>
<div class="mb-3">
<label for="smseagle-token" class="form-label">{{ $t("smseagleToken") }}</label>
<HiddenInput id="smseagle-token" v-model="$parent.notification.smseagleToken" :required="true"></HiddenInput>
</div>
<div class="mb-3">
<label for="smseagle-recipient-type" class="form-label">{{ $t("smseagleRecipientType") }}</label>
<select id="smseagle-recipient-type" v-model="$parent.notification.smseagleRecipientType" class="form-select">
<option value="smseagle-to" selected>{{ $t("smseagleTo") }}</option>
<option value="smseagle-group">{{ $t("smseagleGroup") }}</option>
<option value="smseagle-contact">{{ $t("smseagleContact") }}</option>
</select>
</div>
<div class="mb-3">
<label for="smseagle-recipient" class="form-label">{{ $t("smseagleRecipient") }}</label>
<input id="smseagle-recipient" v-model="$parent.notification.smseagleRecipient" type="text" class="form-control" required>
</div>
<div class="mb-3">
<label for="smseagle-priority" class="form-label">{{ $t("smseaglePriority") }}</label>
<input id="smseagle-priority" v-model="$parent.notification.smseaglePriority" type="number" class="form-control" min="0" max="9" step="1" placeholder="0">
</div>
<div class="mb-3 form-check form-switch">
<label for="smseagle-encoding" class="form-label">{{ $t("smseagleEncoding") }}</label>
<input id="smseagle-encoding" v-model="$parent.notification.smseagleEncoding" type="checkbox" class="form-check-input">
</div>
</template>
<script>
import HiddenInput from "../HiddenInput.vue";
export default {
components: {
HiddenInput,
},
};
</script>

View File

@@ -34,7 +34,7 @@
<div class="mb-3">
<label for="password" class="form-label">{{ $t("Password") }}</label>
<HiddenInput id="password" v-model="$parent.notification.smtpPassword" :required="false" autocomplete="one-time-code"></HiddenInput>
<HiddenInput id="password" v-model="$parent.notification.smtpPassword" :required="false" autocomplete="new-password"></HiddenInput>
</div>
<div class="mb-3">

View File

@@ -1,7 +1,7 @@
<template>
<div class="mb-3">
<label for="serverchan-sendkey" class="form-label">{{ $t("SendKey") }}</label>
<HiddenInput id="serverchan-sendkey" v-model="$parent.notification.serverChanSendKey" :required="true" autocomplete="one-time-code"></HiddenInput>
<HiddenInput id="serverchan-sendkey" v-model="$parent.notification.serverChanSendKey" :required="true" autocomplete="new-password"></HiddenInput>
</div>
</template>

View File

@@ -5,7 +5,7 @@
</div>
<div class="mb-3">
<label for="serwersms-key" class="form-label">{{ $t('serwersmsAPIPassword') }}</label>
<HiddenInput id="serwersms-key" v-model="$parent.notification.serwersmsPassword" :required="true" autocomplete="one-time-code"></HiddenInput>
<HiddenInput id="serwersms-key" v-model="$parent.notification.serwersmsPassword" :required="true" autocomplete="new-password"></HiddenInput>
</div>
<div class="mb-3">
<label for="serwersms-phone-number" class="form-label">{{ $t("serwersmsPhoneNumber") }}</label>

View File

@@ -1,7 +1,7 @@
<template>
<div class="mb-3">
<label for="push-api-key" class="form-label">{{ $t("API Key") }}</label>
<HiddenInput id="push-api-key" v-model="$parent.notification.pushAPIKey" :required="true" autocomplete="one-time-code"></HiddenInput>
<HiddenInput id="push-api-key" v-model="$parent.notification.pushAPIKey" :required="true" autocomplete="new-password"></HiddenInput>
</div>
<i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">

View File

@@ -1,7 +1,7 @@
<template>
<div class="mb-3">
<label for="telegram-bot-token" class="form-label">{{ $t("Bot Token") }}</label>
<HiddenInput id="telegram-bot-token" v-model="$parent.notification.telegramBotToken" :required="true" autocomplete="one-time-code"></HiddenInput>
<HiddenInput id="telegram-bot-token" v-model="$parent.notification.telegramBotToken" :required="true" autocomplete="new-password"></HiddenInput>
<i18n-t tag="div" keypath="wayToGetTelegramToken" class="form-text">
<a href="https://t.me/BotFather" target="_blank">https://t.me/BotFather</a>
</i18n-t>

View File

@@ -1,22 +1,32 @@
<template>
<div class="mb-3">
<label for="webhook-url" class="form-label">{{ $t("Post URL") }}</label>
<input id="webhook-url" v-model="$parent.notification.webhookURL" type="url" pattern="https?://.+" class="form-control" required>
<input
id="webhook-url"
v-model="$parent.notification.webhookURL"
type="url"
pattern="https?://.+"
class="form-control"
required
/>
</div>
<div class="mb-3">
<label for="webhook-content-type" class="form-label">{{ $t("Content Type") }}</label>
<select id="webhook-content-type" v-model="$parent.notification.webhookContentType" class="form-select" required>
<option value="json">
application/json
</option>
<option value="form-data">
multipart/form-data
</option>
<label for="webhook-content-type" class="form-label">{{
$t("Content Type")
}}</label>
<select
id="webhook-content-type"
v-model="$parent.notification.webhookContentType"
class="form-select"
required
>
<option value="json">application/json</option>
<option value="form-data">multipart/form-data</option>
</select>
<div class="form-text">
<p>{{ $t("webhookJsonDesc", ["\"application/json\""]) }}</p>
<p>{{ $t("webhookJsonDesc", ['"application/json"']) }}</p>
<i18n-t tag="p" keypath="webhookFormDataDesc">
<template #multipart>"multipart/form-data"</template>
<template #decodeFunction>
@@ -25,4 +35,44 @@
</i18n-t>
</div>
</div>
<div class="mb-3">
<i18n-t
tag="label"
class="form-label"
for="additionalHeaders"
keypath="webhookAdditionalHeadersTitle"
>
</i18n-t>
<textarea
id="additionalHeaders"
v-model="$parent.notification.webhookAdditionalHeaders"
class="form-control"
:placeholder="headersPlaceholder"
></textarea>
<div class="form-text">
<i18n-t tag="p" keypath="webhookAdditionalHeadersDesc"> </i18n-t>
</div>
</div>
</template>
<script>
export default {
computed: {
headersPlaceholder() {
return this.$t("Example:", [
`
{
"HeaderName": "HeaderValue"
}`,
]);
},
},
};
</script>
<style lang="scss" scoped>
textarea {
min-height: 200px;
}
</style>

View File

@@ -33,6 +33,7 @@ import Signal from "./Signal.vue";
import SMSManager from "./SMSManager.vue";
import Slack from "./Slack.vue";
import Squadcast from "./Squadcast.vue";
import SMSEagle from "./SMSEagle.vue";
import Stackfield from "./Stackfield.vue";
import STMP from "./SMTP.vue";
import Teams from "./Teams.vue";
@@ -83,6 +84,7 @@ const NotificationFormList = {
"SMSManager": SMSManager,
"slack": Slack,
"squadcast": Squadcast,
"SMSEagle": SMSEagle,
"smtp": STMP,
"stackfield": Stackfield,
"teams": Teams,

View File

@@ -1,10 +1,10 @@
<template>
<div>
<form class="my-4" @submit.prevent="saveGeneral">
<!-- Timezone -->
<form class="my-4" autocomplete="off" @submit.prevent="saveGeneral">
<!-- Client side Timezone -->
<div class="mb-4">
<label for="timezone" class="form-label">
{{ $t("Timezone") }}
{{ $t("Display Timezone") }}
</label>
<select id="timezone" v-model="$root.userTimezone" class="form-select">
<option value="auto">
@@ -20,6 +20,23 @@
</select>
</div>
<!-- Server Timezone -->
<div class="mb-4">
<label for="timezone" class="form-label">
{{ $t("Server Timezone") }}
</label>
<select id="timezone" v-model="settings.serverTimezone" class="form-select">
<option value="UTC">UTC</option>
<option
v-for="(timezone, index) in timezoneList"
:key="index"
:value="timezone.value"
>
{{ timezone.name }}
</option>
</select>
</div>
<!-- Search Engine -->
<div class="mb-4">
<label class="form-label">
@@ -105,6 +122,7 @@
name="primaryBaseURL"
placeholder="https://"
pattern="https?://.+"
autocomplete="new-password"
/>
<button class="btn btn-outline-primary" type="button" @click="autoGetPrimaryBaseURL">
{{ $t("Auto Get") }}
@@ -122,7 +140,7 @@
<HiddenInput
id="steamAPIKey"
v-model="settings.steamAPIKey"
autocomplete="one-time-code"
autocomplete="new-password"
/>
<div class="form-text">
{{ $t("steamApiKeyDescription") }}
@@ -145,11 +163,7 @@
<script>
import HiddenInput from "../../components/HiddenInput.vue";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import { timezoneList } from "../../util-frontend";
dayjs.extend(utc);
dayjs.extend(timezone);
export default {
components: {

View File

@@ -41,7 +41,7 @@
<HiddenInput
id="cloudflareTunnelToken"
v-model="cloudflareTunnelToken"
autocomplete="one-time-code"
autocomplete="new-password"
:readonly="running"
/>
<div class="form-text">

View File

@@ -41,6 +41,9 @@ import {
faUndo,
faPlusCircle,
faAngleDown,
faWrench,
faHeartbeat,
faFilter,
} from "@fortawesome/free-solid-svg-icons";
library.add(
@@ -82,6 +85,9 @@ library.add(
faPlusCircle,
faAngleDown,
faLink,
faWrench,
faHeartbeat,
faFilter,
);
export { FontAwesomeIcon };

View File

@@ -1,13 +1,14 @@
# How to translate
1. Fork this repo.
2. Run `npm run update-language-files --language=<code>` where `<code>`
2. Run `npm install`
3. Run `npm run update-language-files --language=<code>` where `<code>`
is a valid ISO language code:
http://www.lingoes.net/en/translator/langcode.htm. You can also use
this command to check if there are new strings to
translate for your language.
3. Your language file should be filled in. You can translate now.
4. Add it into `languageList` constant.
5. Make a [pull request](https://github.com/louislam/uptime-kuma/pulls) when you have done.
4. Your language file should be filled in. You can translate now.
5. Add it into `languageList` constant.
6. Make a [pull request](https://github.com/louislam/uptime-kuma/pulls) when you have done.
If you do not have programming skills, let me know in [the issues section](https://github.com/louislam/uptime-kuma/issues). I will assist you. 😏

View File

@@ -582,4 +582,66 @@ export default {
goAlert: "GoAlert",
backupOutdatedWarning: "Отпаднало: Тъй като са добавени много функции, тази опция за архивиране не е достатъчно поддържана и не може да генерира или възстанови пълен архив.",
backupRecommend: "Моля, архивирайте дяла или папката (./data/) директно вместо това.",
Maintenance: "Поддръжка",
statusMaintenance: "Поддръжка",
"Schedule maintenance": "Планиране на поддръжка",
"Affected Monitors": "Засегнати монитори",
"Pick Affected Monitors...": "Изберете засегнати монитори...",
"Start of maintenance": "Стартирай поддръжка",
"All Status Pages": "Всички статус страници",
"Select status pages...": "Изберете статус страници...",
recurringIntervalMessage: "Изпълнявай ежедневно | Изпълнявай всеки {0} дни",
affectedMonitorsDescription: "Изберете монитори, засегнати от текущата поддръжка",
affectedStatusPages: "Покажи това съобщение за поддръжка на избрани статус страници",
atLeastOneMonitor: "Изберете поне един засегнат монитор",
deleteMaintenanceMsg: "Сигурни ли сте, че желаете да изтриете тази поддръжка?",
Optional: "По желание",
squadcast: "Squadcast",
SendKey: "SendKey",
"SMSManager API Docs": "SMSManager API Документация ",
"Gateway Type": "Тип на шлюза",
SMSManager: "SMSManager",
"You can divide numbers with": "Може да разделяте числата с",
or: "или",
recurringInterval: "Интервал",
Recurring: "Повтаряне",
strategyManual: "Активен/Неактивен ръчно",
warningTimezone: "Използва се часовата зона на сървъра",
weekdayShortMon: "Пон",
weekdayShortTue: "Вт",
weekdayShortWed: "Ср",
weekdayShortThu: "Чет",
weekdayShortFri: "Пет",
weekdayShortSat: "Съб",
weekdayShortSun: "Нед",
dayOfWeek: "Ден",
dayOfMonth: "Дата",
lastDay: "Последен ден",
lastDay1: "Последен ден от месеца",
lastDay2: "2-ри последен ден на месеца",
lastDay3: "3-ти последен ден на месеца",
lastDay4: "4-ти последен ден на месеца",
"No Maintenance": "Няма поддръжка",
pauseMaintenanceMsg: "Сигурни ли сте, че желаете да направите пауза?",
"maintenanceStatus-under-maintenance": "В режим поддръжка",
"maintenanceStatus-inactive": "Неактивна",
"maintenanceStatus-scheduled": "Планирана",
"maintenanceStatus-ended": "Приключена",
"maintenanceStatus-unknown": "Неизвестна",
"Display Timezone": "Покажи часова зона",
"Server Timezone": "Часова зона на сървъра",
statusPageMaintenanceEndDate: "Край",
enableGRPCTls: "Разреши изпращане на gRPC заявка с TLS връзка",
grpcMethodDescription: "Името на метода се форматира в \"cammelCase\", например sayHello, check, и т.н.",
smseagle: "SMSEagle",
smseagleTo: "Тел. номер(а)",
smseagleGroup: "Име на група/и от тел. указател",
smseagleContact: "Име(на) от тел. указател",
smseagleRecipientType: "Получател тип",
smseagleRecipient: "Получател(и) (при повече от един разделете със запетая)",
smseagleToken: "API токен за достъп",
smseagleUrl: "Вашият SMSEagle URL на устройството",
smseagleEncoding: "Изпрати като Unicode",
smseaglePriority: "Приоритет на съобщението (0-9, по подразбиране = 0)",
IconUrl: "Икона URL адрес",
};

View File

@@ -576,4 +576,59 @@ export default {
"Then choose an action, for example switch the scene to where an RGB light is red.": "Dann eine Aktion wählen, zum Beispiel eine Scene wählen in der ein RGB Licht rot ist.",
"Frontend Version": "Frontend Version",
"Frontend Version do not match backend version!": "Die Frontend Version stimmt nicht mit der backend version überein!",
Maintenance: "Wartung",
statusMaintenance: "Wartung",
"Schedule maintenance": "Geplante Wartung",
"Affected Monitors": "Betroffene Monitore",
"Pick Affected Monitors...": "Wähle betroffene Monitore...",
"Start of maintenance": "Beginn der Wartung",
"All Status Pages": "Alle Status Seiten",
"Select status pages...": "Wähle Status Seiten...",
recurringIntervalMessage: "einmal pro Tag ausgeführt | Wird alle {0} Tage ausgführt",
affectedMonitorsDescription: "Wähle alle Monitore die von der Wartung betroffen sind",
affectedStatusPages: "Zeige diese Nachricht auf ausgewählten Status Seiten",
atLeastOneMonitor: "Wähle mindestens einen Monitor",
deleteMaintenanceMsg: "Möchtest du diese Wartung löschen?",
"Base URL": "Basis URL",
goAlertInfo: "GoAlert ist eine Open-Source Applikation für Rufbereitschaft Planung, automaitsche Esklaltion und Benachrichtigung (z.B. SMS oder Telefonanrufe). Beauftragen Sie automatisch die richtige Person, auf die richtige Art und Weise und zum richtigen Zeitpunkt! {0}",
goAlertIntegrationKeyInfo: "Bekomm einenen gernerischen API Schlüssel in folgeden Format \"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\". Normalerweise der Wert des Token aus der URL.",
goAlert: "GoAlert",
backupOutdatedWarning: "Veraltet: Eine menge Neuerungen sind eingeflossen und diese Funktion wurde etwas vernachlässigt worden. Es kann kein vollständiges Backup erstellt oder eingspielt werden.",
backupRecommend: "Bitte Backup das Volume oder den Ordner (./ data /) selbst.",
Optional: "Optional",
squadcast: "Squadcast",
SendKey: "SendKey",
"SMSManager API Docs": "SMSManager API Dokumente",
"Gateway Type": "Gateway Type",
SMSManager: "SMSManager",
"You can divide numbers with": "Du kannst Zahlen teilen mit",
or: "oder",
recurringInterval: "Intervall",
Recurring: "Wiederkehrend",
strategyManual: "Active/Inactive Manually",
warningTimezone: "Es wird die Zeitzone des Servers genutzt",
weekdayShortMon: "Mo",
weekdayShortTue: "Di",
weekdayShortWed: "Mi",
weekdayShortThu: "Do",
weekdayShortFri: "Fr",
weekdayShortSat: "Sa",
weekdayShortSun: "So",
dayOfWeek: "Tag der Woche",
dayOfMonth: "Tag im Monat",
lastDay: "Letzter Tag",
lastDay1: "Letzter Tag im Monat",
lastDay2: "Vorletzer Tag im Monat",
lastDay3: "3. letzter Tag im Monat",
lastDay4: "4. letzter Tag im Monat",
"No Maintenance": "Keine Wartung",
pauseMaintenanceMsg: "Möchtest du wirklich pausieren?",
"maintenanceStatus-under-maintenance": "Unter Wartung",
"maintenanceStatus-inactive": "Inaktiv",
"maintenanceStatus-scheduled": "Geplant",
"maintenanceStatus-ended": "Ende",
"maintenanceStatus-unknown": "Unbekannt",
"Display Timezone": "Zeitzone anzeigen",
"Server Timezone": "Server Zeitzone",
statusPageMaintenanceEndDate: "Ende",
};

View File

@@ -8,12 +8,27 @@ export default {
ignoreTLSError: "Ignore TLS/SSL error for HTTPS websites",
upsideDownModeDescription: "Flip the status upside down. If the service is reachable, it is DOWN.",
maxRedirectDescription: "Maximum number of redirects to follow. Set to 0 to disable redirects.",
enableGRPCTls: "Allow to send gRPC request with TLS connection",
grpcMethodDescription: "Method name is convert to cammelCase format such as sayHello, check, etc.",
acceptedStatusCodesDescription: "Select status codes which are considered as a successful response.",
Maintenance: "Maintenance",
statusMaintenance: "Maintenance",
"Schedule maintenance": "Schedule maintenance",
"Affected Monitors": "Affected Monitors",
"Pick Affected Monitors...": "Pick Affected Monitors...",
"Start of maintenance": "Start of maintenance",
"All Status Pages": "All Status Pages",
"Select status pages...": "Select status pages...",
recurringIntervalMessage: "Run once every day | Run once every {0} days",
affectedMonitorsDescription: "Select monitors that are affected by current maintenance",
affectedStatusPages: "Show this maintenance message on selected status pages",
atLeastOneMonitor: "Select at least one affected monitor",
passwordNotMatchMsg: "The repeat password does not match.",
notificationDescription: "Notifications must be assigned to a monitor to function.",
keywordDescription: "Search keyword in plain HTML or JSON response. The search is case-sensitive.",
pauseDashboardHome: "Pause",
deleteMonitorMsg: "Are you sure want to delete this monitor?",
deleteMaintenanceMsg: "Are you sure want to delete this maintenance?",
deleteNotificationMsg: "Are you sure want to delete this notification for all monitors?",
dnsPortDescription: "DNS server port. Defaults to 53. You can change the port at any time.",
resolverserverDescription: "Cloudflare is the default server. You can change the resolver server anytime.",
@@ -206,6 +221,8 @@ export default {
"Content Type": "Content Type",
webhookJsonDesc: "{0} is good for any modern HTTP servers such as Express.js",
webhookFormDataDesc: "{multipart} is good for PHP. The JSON will need to be parsed with {decodeFunction}",
webhookAdditionalHeadersTitle: "Additional Headers",
webhookAdditionalHeadersDesc: "Sets additional headers sent with the webhook.",
smtp: "Email (SMTP)",
secureOptionNone: "None / STARTTLS (25, 587)",
secureOptionTLS: "TLS (465)",
@@ -365,6 +382,16 @@ export default {
serwersmsAPIPassword: "API Password",
serwersmsPhoneNumber: "Phone number",
serwersmsSenderName: "SMS Sender Name (registered via customer portal)",
smseagle: "SMSEagle",
smseagleTo: "Phone number(s)",
smseagleGroup: "Phonebook group name(s)",
smseagleContact: "Phonebook contact name(s)",
smseagleRecipientType: "Recipient type",
smseagleRecipient: "Recipient(s) (multiple must be separated with comma)",
smseagleToken: "API Access token",
smseagleUrl: "Your SMSEagle device URL",
smseagleEncoding: "Send as Unicode",
smseaglePriority: "Message priority (0-9, default = 0)",
stackfield: "Stackfield",
Customize: "Customize",
"Custom Footer": "Custom Footer",
@@ -590,4 +617,33 @@ export default {
SMSManager: "SMSManager",
"You can divide numbers with": "You can divide numbers with",
"or": "or",
recurringInterval: "Interval",
"Recurring": "Recurring",
strategyManual: "Active/Inactive Manually",
warningTimezone: "It is using the server's timezone",
weekdayShortMon: "Mon",
weekdayShortTue: "Tue",
weekdayShortWed: "Wed",
weekdayShortThu: "Thu",
weekdayShortFri: "Fri",
weekdayShortSat: "Sat",
weekdayShortSun: "Sun",
dayOfWeek: "Day of Week",
dayOfMonth: "Day of Month",
lastDay: "Last Day",
lastDay1: "Last Day of Month",
lastDay2: "2nd Last Day of Month",
lastDay3: "3rd Last Day of Month",
lastDay4: "4th Last Day of Month",
"No Maintenance": "No Maintenance",
pauseMaintenanceMsg: "Are you sure want to pause?",
"maintenanceStatus-under-maintenance": "Under Maintenance",
"maintenanceStatus-inactive": "Inactive",
"maintenanceStatus-scheduled": "Scheduled",
"maintenanceStatus-ended": "Ended",
"maintenanceStatus-unknown": "Unknown",
"Display Timezone": "Display Timezone",
"Server Timezone": "Server Timezone",
statusPageMaintenanceEndDate: "End",
IconUrl: "Icon URL",
};

View File

@@ -531,4 +531,57 @@ export default {
backupRecommend: "Veuillez sauvegarder le volume ou le dossier de données (./data/) directement à la place.",
Optional: "Optionnel",
squadcast: "Squadcast",
Maintenance: "Maintenance",
statusMaintenance: "Maintenance",
"Schedule maintenance": "Planifier la maintenance",
"Affected Monitors": "Moniteurs concernés",
"Pick Affected Monitors...": "Sélectionnez les moniteurs concernés...",
"Start of maintenance": "Début de la maintenance",
"All Status Pages": "Toutes les pages d'état",
"Select status pages...": "Sélectionnez les pages d'état...",
recurringIntervalMessage: "Exécuter une fois par jour | Exécuter une fois tous les {0} jours",
affectedMonitorsDescription: "Sélectionnez les moniteurs concernés par la maintenance en cours",
affectedStatusPages: "Afficher ce message de maintenance sur les pages d'état sélectionnées",
atLeastOneMonitor: "Sélectionnez au moins un moniteur concerné",
deleteMaintenanceMsg: "Voulez-vous vraiment supprimer cette maintenance ?",
pushyAPIKey: "Clé API secrète",
pushyToken: "Jeton d'appareil",
"You can divide numbers with": "Vous pouvez diviser des nombres avec",
or: "ou",
recurringInterval: "Intervalle",
Recurring: "Récurrent",
"Single Maintenance Window": "Fenêtre de maintenance unique",
"Maintenance Time Window of a Day": "Fenêtre de temps de maintenance",
"Effective Date Range": "Plage de dates d'effet",
strategyManual: "activer/desactiver manuellement",
warningTimezone: "Il utilise le fuseau horaire du serveur",
weekdayShortMon: "Lun",
weekdayShortTue: "Mar",
weekdayShortWed: "Mer",
weekdayShortThu: "Jeu",
weekdayShortFri: "Ven",
weekdayShortSat: "Sam",
weekdayShortSun: "Dim",
dayOfWeek: "Jour de la semaine",
dayOfMonth: "Jour du mois",
lastDay: "Dernier jour",
lastDay1: "Dernier jour du mois",
lastDay2: "2ème dernier jour du mois",
lastDay3: "3ème dernier jour du mois",
lastDay4: "4ème dernier jour du mois",
"No Maintenance": "Aucune Maintenance",
pauseMaintenanceMsg: "Voulez-vous vraiment mettre en pause ?",
"maintenanceStatus-under-maintenance": "En maintenance",
"maintenanceStatus-inactive": "Inactif",
"maintenanceStatus-scheduled": "Programmé",
"maintenanceStatus-ended": "Terminé",
"maintenanceStatus-unknown": "Inconnue",
"Display Timezone": "Afficher le fuseau horaire",
"Server Timezone": "Fuseau horaire du serveur",
"Date and Time": "Date et heure",
"DateTime Range": "Plage de dates et d'heures",
Strategy: "Stratégie",
statusPageMaintenanceEndDate: "Fin",
"Free Mobile User Identifier": "Identifiant d'utilisateur Free Mobile",
"Free Mobile API Key": "Clé API Free Mobile",
};

View File

@@ -27,8 +27,8 @@ export default {
confirmImportMsg: "Apakah Anda yakin untuk mengimpor cadangan? Pastikan Anda telah memilih opsi impor yang tepat.",
twoFAVerifyLabel: "Silakan ketik token Anda untuk memverifikasi bahwa 2FA berfungsi",
tokenValidSettingsMsg: "Token benar! Anda sekarang dapat menyimpan pengaturan 2FA.",
confirmEnableTwoFAMsg: "Apakah anda yakin ingin mengaktifkan 2FA?",
confirmDisableTwoFAMsg: "Apakah anda yakin ingin menonaktifkan 2FA?",
confirmEnableTwoFAMsg: "Apakah Anda yakin ingin mengaktifkan 2FA?",
confirmDisableTwoFAMsg: "Apakah Anda yakin ingin menonaktifkan 2FA?",
Settings: "Pengaturan",
Dashboard: "Dasbor",
"New Update": "Pembaruan Baru",
@@ -126,7 +126,7 @@ export default {
"Resolver Server": "Resolver Server",
"Resource Record Type": "Resource Record Type",
"Last Result": "Hasil Terakhir",
"Create your admin account": "Buat akun admin anda",
"Create your admin account": "Buat akun admin Anda",
"Repeat Password": "Ulangi Sandi",
"Import Backup": "Impor Cadangan",
"Export Backup": "Ekspor Cadangan",
@@ -568,7 +568,7 @@ export default {
"Long-Lived Access Token can be created by clicking on your profile name (bottom left) and scrolling to the bottom then click Create Token. ": "Token Akses Berumur Panjang dapat dibuat dengan mengklik nama profil Anda (kiri bawah) dan menggulir ke bawah lalu klik Buat Token. ",
"Notification Service": "Layanan Pemberitahuan",
"default: notify all devices": "bawaan: notifikasi seluruh perangkat",
"A listof Notification Services can be found in Home Assistant under \"Developer Tools > Services\" search for \"notification\" to find your device/phone name.": "Daftar Layanan Pemberitahuan dapat ditemukan di Home Assistant pada \"Developer Tools > Services\" cari \"notification\" lalu cari nama perangkat Anda.",
"A list of Notification Services can be found in Home Assistant under \"Developer Tools > Services\" search for \"notification\" to find your device/phone name.": "Daftar Layanan Pemberitahuan dapat ditemukan di Home Assistant pada \"Developer Tools > Services\" cari \"notification\" lalu cari nama perangkat Anda.",
"Automations can optionally be triggered in Home Assistant:": "Otomatisasi dapat dipicu secara opsional di Home Assistant:",
"Trigger type:": "Tipe Trigger/Pemicu:",
"Event type:": "Tipe event:",

View File

@@ -125,7 +125,7 @@ export default {
Export: "Eksportuj",
Import: "Importuj",
respTime: "Czas odp. (ms)",
notAvailableShort: "N/A",
notAvailableShort: "N/D",
"Default enabled": "Włącz domyślnie",
"Apply on all existing monitors": "Zastosuj do istniejących monitorów",
Create: "Stwórz",
@@ -181,7 +181,7 @@ export default {
"Edit Status Page": "Edytuj stronę statusu",
"Go to Dashboard": "Idź do panelu",
"Status Page": "Strona statusu",
"Status Pages": "Strona statusu",
"Status Pages": "Strony statusów",
defaultNotificationName: "Moje powiadomienie {notification} ({number})",
here: "tutaj",
Required: "Wymagane",
@@ -359,6 +359,16 @@ export default {
serwersmsAPIPassword: "Hasło API",
serwersmsPhoneNumber: "Numer telefonu",
serwersmsSenderName: "Nazwa nadawcy (zatwierdzona w panelu klienta)",
smseagle: "SMSEagle",
smseagleTo: "Numer/y telefonu",
smseagleGroup: "Grupa/y z Książki adresowej",
smseagleContact: "Kontakt/y z Książki adresowej",
smseagleRecipientType: "Typ odbiorcy",
smseagleRecipient: "Odbiorca/y (wiele musi być oddzielone przecinkami)",
smseagleToken: "Klucz dostępu API",
smseagleUrl: "URL Twojego urządzenia SMSEagle",
smseagleEncoding: "Wyślij jako Unicode",
smseaglePriority: "Priorytet wiadomości (0-9, domyślnie = 0)",
stackfield: "Stackfield",
Customize: "Dostosuj",
"Custom Footer": "Niestandardowa stopka",
@@ -435,7 +445,7 @@ export default {
"HTTP Basic Auth": "Podstawowa autoryzacja HTTP",
"New Status Page": "Nowa strona statusu",
"Page Not Found": "Strona nie została znaleziona",
"Reverse Proxy": "Odwrotne Proxy",
"Reverse Proxy": "Zwrotny serwer proxy",
Backup: "Backup",
About: "O skrypcie",
wayToGetCloudflaredURL: "(Pobierz cloudflared z {0})",
@@ -447,7 +457,7 @@ export default {
"For example: nginx, Apache and Traefik.": "Na przykład: nginx, Apache i Traefik.",
"Please read": "Przeczytaj proszę",
"Subject:": "Temat:",
"Valid To:": "Ważdny do:",
"Valid To:": "Ważny do:",
"Days Remaining:": "Pozostało dni:",
"Issuer:": "Wydawca:",
"Fingerprint:": "Odcisk palca:",
@@ -467,4 +477,168 @@ export default {
"Domain Names": "Domeny",
signedInDisp: "Zalogowany jako {0}",
signedInDispDisabled: "Autoryzacja wyłączona.",
resendEveryXTimes: "Wysyłaj ponownie co {0} razy",
resendDisabled: "Ponowne wysyłanie jest wyłączone",
Maintenance: "Konserwacja",
statusMaintenance: "Konserwacja",
"Schedule maintenance": "Planowanie konserwacji",
"Affected Monitors": "Monitory dotknięte problemem",
"Pick Affected Monitors...": "Wybierz monitory, których to dotyczy...",
"Start of maintenance": "Rozpoczęcie konserwacji",
"All Status Pages": "Wszystkie strony statusu",
"Select status pages...": "Wybierz strony statusu...",
recurringIntervalMessage: "Uruchom raz dziennie | Uruchom raz na {0} dni",
affectedMonitorsDescription: "Wybierz monitory, których dotyczy bieżąca konserwacja",
affectedStatusPages: "Pokaż ten komunikat o konserwacji na wybranych stronach statusu",
atLeastOneMonitor: "Wybierz co najmniej jeden monitor, którego dotyczy problem",
deleteMaintenanceMsg: "Czy na pewno chcesz usunąć tę konserwację?",
dnsPortDescription: "Port serwera DNS. Domyślnie 53. Możesz zmienić port w dowolnym momencie.",
"Resend Notification if Down X times consequently": "Wyślij ponownie powiadomienie, jeśli nie działa X razy pod rząd",
error: "błąd",
critical: "krytyczny",
wayToGetPagerDutyKey: "Możesz to uzyskać, przechodząc do Service -> Service Directory -> (wybierz usługę) -> Integrations -> Add integration. Tutaj możesz wyszukać \"Events API V2\". Więcej informacji {0}",
"Integration Key": "Klucz integracji",
"Integration URL": "Adres URL integracji",
"Auto resolve or acknowledged": "Automatycznie rozwiązany lub potwierdzony",
"do nothing": "nie rób nic",
"auto acknowledged": "auto potwierdzony",
"auto resolve": "automatycznie rozwiązany",
"Bark Group": "Grupa Bark",
"Bark Sound": "Dźwięk Bark",
"HTTP Headers": "Nagłówki HTTP",
"Trust Proxy": "Ufaj proxy",
HomeAssistant: "Home Assistant",
RadiusSecret: "Sekretny klucz Radius",
RadiusSecretDescription: "Współdzielony sekretny klucz pomiędzy klientem a serwerem",
RadiusCalledStationId: "Id stacji wywoływanej",
RadiusCalledStationIdDescription: "Identyfikator wywoływanego urządzenia",
RadiusCallingStationId: "Id stacji wywoławczej",
RadiusCallingStationIdDescription: "Identyfikator urządzenia wywołującego",
"Certificate Expiry Notification": "Powiadomienie o wygaśnięciu certyfikatu",
"API Username": "Nazwa użytkownika API",
"API Key": "Klucz API",
"Recipient Number": "Numer odbiorcy",
"From Name/Number": "Od nazwa/numer",
"Leave blank to use a shared sender number.": "Pozostaw puste, aby użyć wspólnego numeru nadawcy.",
"Octopush API Version": "Wersja API Octopush",
"Legacy Octopush-DM": "Starsze Octopush-DM",
endpoint: "punkt końcowy",
octopushAPIKey: "\"API key\" z poświadczeń HTTP API w panelu sterowania",
octopushLogin: "\"Login\" z poświadczeń HTTP API w panelu sterowania",
promosmsLogin: "Nazwa logowania API",
promosmsPassword: "Hasło API",
"pushoversounds pushover": "Pushover (domyślny)",
"pushoversounds bike": "Bike",
"pushoversounds bugle": "Bugle",
"pushoversounds cashregister": "Cash Register",
"pushoversounds classical": "Classical",
"pushoversounds cosmic": "Cosmic",
"pushoversounds falling": "Falling",
"pushoversounds gamelan": "Gamelan",
"pushoversounds incoming": "Incoming",
"pushoversounds intermission": "Intermission",
"pushoversounds magic": "Magic",
"pushoversounds mechanical": "Mechanical",
"pushoversounds pianobar": "Piano Bar",
"pushoversounds siren": "Siren",
"pushoversounds spacealarm": "Space Alarm",
"pushoversounds tugboat": "Tug Boat",
"pushoversounds alien": "Alien Alarm (długie)",
"pushoversounds climb": "Climb (długie)",
"pushoversounds persistent": "Persistent (długie)",
"pushoversounds echo": "Pushover Echo (długie)",
"pushoversounds updown": "Up Down (długie)",
"pushoversounds vibrate": "Tylko wibracje",
"pushoversounds none": "Brak (cisza)",
pushyAPIKey: "Tajny klucz API",
pushyToken: "Token urządzenia",
"Show update if available": "Pokaż aktualizację, jeśli jest dostępna",
"Also check beta release": "Sprawdź również wydanie beta",
"Using a Reverse Proxy?": "Używasz odwróconego proxy?",
"Check how to config it for WebSocket": "Sprawdź jak go skonfigurować dla WebSocket",
"Steam Game Server": "Serwer gry Steam",
"Most likely causes:": "Najbardziej prawdopodobne przyczyny:",
"The resource is no longer available.": "Zasób nie jest już dostępny.",
"There might be a typing error in the address.": "W adresie może być błąd w pisowni.",
"What you can try:": "Co możesz spróbować:",
"Retype the address.": "Ponownie wpisz adres.",
"Go back to the previous page.": "Wróć do poprzedniej strony.",
"Coming Soon": "Wkrótce",
wayToGetClickSendSMSToken: "Możesz uzyskać nazwę użytkownika API i klucz API z {0}.",
"Connection String": "Ciąg połączenia",
Query: "Zapytanie",
settingsCertificateExpiry: "Wygaśnięcie certyfikatu TLS",
certificationExpiryDescription: "Monitory HTTPS uruchamiają powiadomienia o wygaśnięciu certyfikatu TLS w:",
"Setup Docker Host": "Konfiguracja hosta Docker",
"Connection Type": "Typ połączenia",
"Docker Daemon": "Demon Dockera",
deleteDockerHostMsg: "Czy na pewno chcesz usunąć ten host Dockera dla wszystkich monitorów?",
socket: "Gniazdo",
tcp: "TCP / HTTP",
"Docker Container": "Kontener Dockera",
"Container Name / ID": "Nazwa kontenera / ID",
"Docker Host": "Host Dockera",
"Docker Hosts": "Hosty Dockera",
"ntfy Topic": "Temat ntfy",
Domain: "Domena",
Workstation: "Stacja robocza",
disableCloudflaredNoAuthMsg: "Jesteś w trybie No Auth, hasło nie jest wymagane.",
trustProxyDescription: "Zaufaj nagłówkom 'X-Forwarded-*'. Jeśli chcesz uzyskać poprawne IP klienta, a twój Uptime Kuma jest za Nginx lub Apache, powinieneś to włączyć.",
wayToGetLineNotifyToken: "Możesz uzyskać token dostępu z {0}",
Examples: "Przykłady",
"Home Assistant URL": "URL Home Assistant",
"Long-Lived Access Token": "Długotrwały token dostępu",
"Long-Lived Access Token can be created by clicking on your profile name (bottom left) and scrolling to the bottom then click Create Token. ": "Długotrwały token dostępu można utworzyć klikając na nazwę swojego profilu (na dole po lewej stronie) i przewijając do dołu, a następnie klikając Create Token. ",
"Notification Service": "Usługa powiadamiania",
"default: notify all devices": "domyślnie: powiadamiaj wszystkie urządzenia",
"A list of Notification Services can be found in Home Assistant under \"Developer Tools > Services\" search for \"notification\" to find your device/phone name.": "Listę usług powiadamiania można znaleźć w Home Assistant pod \"Developer Tools > Services\" wyszukaj \"notification\", aby znaleźć nazwę swojego urządzenia/telefonu.",
"Automations can optionally be triggered in Home Assistant:": "Automaty mogą być opcjonalnie uruchamiane w Home Assistant:",
"Trigger type:": "Typ wyzwalacza:",
"Event type:": "Typ zdarzenia:",
"Event data:": "Dane o zdarzeniu:",
"Then choose an action, for example switch the scene to where an RGB light is red.": "Następnie wybierz akcję, na przykład przełącz scenę na taką, w której światło RGB jest czerwone.",
"Frontend Version": "Wersja frontu",
"Frontend Version do not match backend version!": "Wersja frontu nie pasuje do wersji backendu!",
"Base URL": "Bazowy adres URL",
goAlertInfo: "GoAlert to aplikacja open source do planowania, automatycznych eskalacji i powiadomień (jak SMS lub połączenia głosowe). Automatycznie angażuj właściwą osobę, we właściwy sposób i we właściwym czasie! {0}",
goAlertIntegrationKeyInfo: "Pobierz generyczny klucz integracyjny API dla usługi, którego wartość skopiowanego tokena URL jest zwykle w formacie \"aaaaaaaa-bbb-cccc-dddd-eeeeee\".",
goAlert: "GoAlert",
backupOutdatedWarning: "Przestarzałe: ponieważ dodano wiele funkcji i funkcja tworzenia kopii zapasowych nie jest wystarczająco utrzymywana, nie może generować ani przywracać pełnej kopii zapasowej.",
backupRecommend: "Zamiast tego należy wykonać bezpośrednią kopię zapasową woluminu lub folderu danych (./data/).",
Optional: "Opcjonalne",
squadcast: "Squadcast",
SendKey: "SendKey",
"SMSManager API Docs": "Dokumentacja API SMSManager ",
"Gateway Type": "Typ bramy",
SMSManager: "SMSManager",
"You can divide numbers with": "Możesz dzielić liczby przez",
or: "lub",
recurringInterval: "odstęp czasu",
Recurring: "powtarzający się",
strategyManual: "Aktywowany/dezaktywowany ręcznie",
warningTimezone: "Używa strefy czasowej serwera",
weekdayShortMon: "pon",
weekdayShortTue: "wt",
weekdayShortWed: "śr",
weekdayShortThu: "czw",
weekdayShortFri: "pt",
weekdayShortSat: "sob",
weekdayShortSun: "niedz",
dayOfWeek: "Dzień tygodnia",
dayOfMonth: "Dzień miesiąca",
lastDay: "Ostatni dzień",
lastDay1: "Ostatni dzień miesiąca",
lastDay2: "2. ostatni dzień miesiąca",
lastDay3: "3. ostatni dzień miesiąca",
lastDay4: "4. ostatni dzień miesiąca",
"No Maintenance": "Brak konserwacji",
pauseMaintenanceMsg: "Jesteś pewien, że chcesz zatrzymać?",
"maintenanceStatus-under-maintenance": "Podczas konserwacji",
"maintenanceStatus-inactive": "Nieaktywny",
"maintenanceStatus-scheduled": "Zaplanowany",
"maintenanceStatus-ended": "Zakończony",
"maintenanceStatus-unknown": "Nieznany",
"Display Timezone": "Wyświetlana strefa czasowa",
"Server Timezone": "Strefa czasowa serwera",
statusPageMaintenanceEndDate: "Koniec",
};

View File

@@ -393,6 +393,12 @@ export default {
alertaAlertState: "Состояние алерта",
alertaRecoverState: "Состояние восстановления",
Proxies: "Прокси",
"Setup Proxy": "Настройка Прокси",
"Proxy Protocol": "Протокол Прокси",
"Proxy Server": "Прокси",
"Proxy server has authentication": "Прокси имеет аутентификацию",
"Reverse Proxy": "Обратный прокси",
"No Proxy": "Без прокси",
default: "По умолчанию",
enabled: "Включено",
setAsDefault: "Установлено по умолчанию",
@@ -400,4 +406,176 @@ export default {
proxyDescription: "Прокси должны быть привязаны к монитору, чтобы работать.",
enableProxyDescription: "Этот прокси не будет влиять на запросы монитора, пока не будет активирован. Вы можете контролировать временное отключение прокси для всех мониторов через статус активации.",
setAsDefaultProxyDescription: "Этот прокси будет по умолчанию включен для новых мониторов. Вы всё ещё можете отдельно отключать прокси в каждом мониторе.",
Invalid: "Недействительный",
AccessKeyId: "AccessKey ID",
SecretAccessKey: "AccessKey Secret",
PhoneNumbers: "PhoneNumbers",
TemplateCode: "TemplateCode",
SignName: "SignName",
"Sms template must contain parameters: ": "Шаблон СМС должен содержать параметры: ",
"Bark Endpoint": "Bark Endpoint",
"Bark Group": "Bark Group",
"Bark Sound": "Bark Sound",
WebHookUrl: "WebHookUrl",
SecretKey: "SecretKey",
"For safety, must use secret key": "В целях безопасности необходимо использовать секретный ключ",
"Device Token": "Токен устройства",
Platform: "Платформа",
iOS: "iOS",
Android: "Android",
Huawei: "Huawei",
High: "High",
Retry: "Повторить",
Topic: "Тема",
"WeCom Bot Key": "WeCom Bot Key",
User: "Пользователь",
Installed: "Установлено",
"Not installed": "Не установлено",
Running: "Запускается",
"Not running": "Не запускается",
"Remove Token": "Удалить токен",
Start: "Запустить",
Stop: "Остановить",
"Uptime Kuma": "Uptime Kuma",
Slug: "Slug",
"Accept characters:": "Принимаемые символы:",
startOrEndWithOnly: "Начинается или кончается только {0}",
"No consecutive dashes": "Без последовательных тире",
"The slug is already taken. Please choose another slug.": "The slug is already taken. Please choose another slug.",
"Page Not Found": "Страница не найдена",
wayToGetCloudflaredURL: "(Скачать cloudflared с {0})",
cloudflareWebsite: "Cloudflare Website",
"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.",
"HTTP Headers": "HTTP заголовки",
"Trust Proxy": "Доверять прокси",
"Other Software": "Другое программное обеспечение",
"For example: nginx, Apache and Traefik.": "К примеру: nginx, Apache и Traefik.",
"Please read": "Пожалуйста, прочитайте",
"Subject:": "Тема:",
"Valid To:": "Действителен до:",
"Days Remaining:": "Дней осталось:",
"Issuer:": "Издатель:",
"Fingerprint:": "Отпечаток:",
"No status pages": "Нет статусных страниц",
"Domain Name Expiry Notification": "Уведомление об истечении срока действия доменного имени",
Proxy: "Прокси",
"Date Created": "Дата создания",
HomeAssistant: "Home Assistant",
onebotHttpAddress: "OneBot HTTP Address",
onebotMessageType: "OneBot Message Type",
onebotGroupMessage: "Группа",
onebotPrivateMessage: "Private",
onebotUserOrGroupId: "Группа/ID пользователя",
onebotSafetyTips: "В целях безопасности необходимо установить токен доступа",
"PushDeer Key": "PushDeer Key",
"Footer Text": "Текст нижнего колонтитула",
"Show Powered By": "Показывать на чем создано",
"Domain Names": "Доменные имена",
signedInDisp: "Вы вошли как {0}",
signedInDispDisabled: "Аутентификация отключена.",
RadiusSecret: "Секрет Radius",
RadiusSecretDescription: "Общий секрет между клиентом и сервером",
RadiusCalledStationId: "Идентификатор вызываемой станции",
RadiusCalledStationIdDescription: "Идентификатор вызываемого устройства",
RadiusCallingStationId: "Идентификатор вызывающей станции",
RadiusCallingStationIdDescription: "Идентификатор вызывающего устройства",
"Certificate Expiry Notification": "Уведомление об истечении срока действия сертификата",
"API Username": "Имя пользователя API",
"API Key": "API ключ",
"Recipient Number": "Номер получателя",
"From Name/Number": "Имя/номер отправителя",
"Leave blank to use a shared sender number.": "Оставьте пустым, чтобы использовать общий номер отправителя.",
"Octopush API Version": "Версия API Octopush",
"Legacy Octopush-DM": "Legacy Octopush-DM",
endpoint: "endpoint",
octopushAPIKey: "\"API key\" из учетных данных HTTP API в панели управления",
octopushLogin: "\"Login\" из учетных данных HTTP API в панели управления",
promosmsLogin: "Логин API",
promosmsPassword: "Пароль API",
"pushoversounds pushover": "Pushover (default)",
"pushoversounds bike": "Bike",
"pushoversounds bugle": "Bugle",
"pushoversounds cashregister": "Cash Register",
"pushoversounds classical": "Classical",
"pushoversounds cosmic": "Cosmic",
"pushoversounds falling": "Falling",
"pushoversounds gamelan": "Gamelan",
"pushoversounds incoming": "Incoming",
"pushoversounds intermission": "Intermission",
"pushoversounds magic": "Magic",
"pushoversounds mechanical": "Mechanical",
"pushoversounds pianobar": "Piano Bar",
"pushoversounds siren": "Siren",
"pushoversounds spacealarm": "Space Alarm",
"pushoversounds tugboat": "Tug Boat",
"pushoversounds alien": "Alien Alarm (long)",
"pushoversounds climb": "Climb (long)",
"pushoversounds persistent": "Persistent (long)",
"pushoversounds echo": "Pushover Echo (long)",
"pushoversounds updown": "Up Down (long)",
"pushoversounds vibrate": "Vibrate Only",
"pushoversounds none": "None (silent)",
pushyAPIKey: "Secret API Key",
pushyToken: "Токен устройства",
"Using a Reverse Proxy?": "Используете обратный прокси?",
"Check how to config it for WebSocket": "Проверьте, как настроить его для WebSocket",
"Steam Game Server": "Steam Game Server",
"Most likely causes:": "Наиболее вероятные причины:",
"The resource is no longer available.": "Ресурс больше не доступен.",
"There might be a typing error in the address.": "В адресе может быть опечатка.",
"What you can try:": "Что вы можете попробовать:",
"Retype the address.": "Повторите адрес.",
"Go back to the previous page.": "Вернуться на предыдущую страницу.",
"Coming Soon": "Скоро",
wayToGetClickSendSMSToken: "Вы можете получить имя пользователя API и ключ API из {0} .",
"Connection String": "Строка подключения",
Query: "Запрос",
settingsCertificateExpiry: "Истекание TLS сертификата",
certificationExpiryDescription: "HTTPS Мониторы инициируют уведомление, когда срок действия сертификата TLS истечет:",
"Setup Docker Host": "Настроить Docker Host",
"Connection Type": "Тип соединения",
"Docker Daemon": "Docker Daemon",
deleteDockerHostMsg: "Are you sure want to delete this docker host for all monitors?",
socket: "Socket",
tcp: "TCP / HTTP",
"Docker Container": "Docker контейнер",
"Container Name / ID": "Название контейнера / ID",
"Docker Host": "Docker Host",
"Docker Hosts": "Docker Hosts",
"ntfy Topic": "ntfy Topic",
Domain: "Домен",
Workstation: "Workstation",
disableCloudflaredNoAuthMsg: "Вы находитесь в режиме без авторизации, пароль не требуется.",
trustProxyDescription: "Доверять заголовкам 'X-Forwarded-*'. Если вы хотите получить правильный IP-адрес клиента, а ваш Uptime Kuma находится под Nginx или Apache, вам следует включить этот параметр.",
wayToGetLineNotifyToken: "Вы можете получить токен доступа в {0}",
Examples: "Примеры",
"Home Assistant URL": "Home Assistant URL",
"Long-Lived Access Token": "Токен доступа с длительным сроком службы",
"Long-Lived Access Token can be created by clicking on your profile name (bottom left) and scrolling to the bottom then click Create Token. ": "Long-Lived Access Token can be created by clicking on your profile name (bottom left) and scrolling to the bottom then click Create Token. ",
"Notification Service": "Служба уведомлений",
"default: notify all devices": "по стандарту: уведомлять все устройства",
"A list of Notification Services can be found in Home Assistant under \"Developer Tools > Services\" search for \"notification\" to find your device/phone name.": "A list of Notification Services can be found in Home Assistant under \"Developer Tools > Services\" search for \"notification\" to find your device/phone name.",
"Automations can optionally be triggered in Home Assistant:": "При желании автоматизацию можно активировать в Home Assistant.:",
"Trigger type:": "Тип триггера:",
"Event type:": "Тип события:",
"Event data:": "Данные события:",
"Then choose an action, for example switch the scene to where an RGB light is red.": "Затем выберите действие, например, переключите сцену на красный индикатор RGB..",
"Frontend Version": "Версия интерфейса",
"Frontend Version do not match backend version!": "Версия интерфейса не соответствует версии серверной части!",
"Base URL": "Базовый URL",
goAlertInfo: "GoAlert is a An open source application for on-call scheduling, automated escalations and notifications (like SMS or voice calls). Automatically engage the right person, the right way, and at the right time! {0}",
goAlertIntegrationKeyInfo: "Получить общий ключ интеграции API для сервиса в этом формате \"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\" обычно значение параметра токена скопированного URL.",
goAlert: "GoAlert",
backupOutdatedWarning: "Устарело: поскольку добавлено множество функций, а эта функция резервного копирования немного не поддерживается, она не может создать или восстановить полную резервную копию.",
backupRecommend: "Сделайте резервную копию тома или папки с данными (./data/) напрямую.",
"Optional": "Необязательно",
squadcast: "Squadcast",
SendKey: "SendKey",
"SMSManager API Docs": "Документация к API SMSManager ",
"Gateway Type": "Тип шлюза",
SMSManager: "SMSManager",
"You can divide numbers with": "Вы можете делить числа с",
"or": "или",
};

View File

@@ -2,7 +2,7 @@ export default {
languageName: "简体中文",
checkEverySecond: "检测频率 {0} 秒",
retryCheckEverySecond: "重试间隔 {0} 秒",
retriesDescription: "服务被标记为故障并发送通知之前最大重试次数",
retriesDescription: "服务被标记为故障并发送通知之前最大重试次数",
ignoreTLSError: "忽略 HTTPS 站点的 TLS/SSL 错误",
upsideDownModeDescription: "反转状态监控,如果服务可访问,则认为是故障。",
maxRedirectDescription: "允许的最大重定向次数。设置为 0 禁用重定向。",

View File

@@ -380,4 +380,6 @@ export default {
proxyDescription: "必須將代理伺服器指派給監測器才能運作。",
enableProxyDescription: "此代理伺服器在啟用前不會在監測器上生效,您可以藉由控制啟用狀態來暫時對所有的監測器停用代理伺服器。",
setAsDefaultProxyDescription: "預設情況下,新監測器將啟用此代理伺服器。您仍可分別停用各監測器的代理伺服器。",
Maintenance: "維護",
statusMaintenance: "維護中",
};

View File

@@ -9,11 +9,24 @@ export default {
upsideDownModeDescription: "反轉顯示狀態。若服務可以連線,將顯示離線。",
maxRedirectDescription: "最大重新導向跟隨次數。設為 0 將停用重新導向。",
acceptedStatusCodesDescription: "選擇視為成功回應的狀態碼。",
Maintenance: "維護",
statusMaintenance: "維護",
"Schedule maintenance": "排程維護",
"Affected Monitors": "受影響的監測器",
"Pick Affected Monitors...": "挑選受影響的監測器...",
"Start of maintenance": "維護起始",
"All Status Pages": "所有狀態頁",
"Select status pages...": "選擇狀態頁...",
recurringIntervalMessage: "每日執行 | 每 {0} 天執行",
affectedMonitorsDescription: "選擇受目前維護影響的監測器",
affectedStatusPages: "在已選取的狀態頁中顯示此維護訊息",
atLeastOneMonitor: "至少選擇一個受影響的監測器",
passwordNotMatchMsg: "密碼不相符。",
notificationDescription: "必須將通知指派給監測器才能運作。",
keywordDescription: "HTML 或 JSON 回應的搜尋關鍵字。區分大小寫。",
pauseDashboardHome: "暫停",
deleteMonitorMsg: "您確定要刪除此監測器嗎?",
deleteMaintenanceMsg: "您確定要刪除此維護嗎?",
deleteNotificationMsg: "您確定要為所有監測器刪除此通知嗎?",
dnsPortDescription: "DNS 伺服器連接埠。預設為 53。您可以隨時變更連接埠。",
resolverserverDescription: "Cloudflare 為預設伺服器。您可以隨時更換解析伺服器。",
@@ -305,7 +318,7 @@ export default {
Method: "方法",
Body: "主體",
Headers: "標頭",
PushUrl: "Push URL",
PushUrl: "Push 網址",
HeadersInvalidFormat: "要求標頭不是有效的 JSON",
BodyInvalidFormat: "請求主體不是有效的 JSON",
"Monitor History": "監測器歷史紀錄",
@@ -582,4 +595,40 @@ export default {
goAlert: "GoAlert",
backupOutdatedWarning: "過時:由於新功能的增加,且未妥善維護,故此備份功能無法產生或復原完整備份。",
backupRecommend: "請直接備份磁碟區或 ./data/ 資料夾。",
"Optional": "選填",
squadcast: "Squadcast",
SendKey: "SendKey",
"SMSManager API Docs": "SMSManager API 文件 ",
"Gateway Type": "閘道類型",
SMSManager: "SMSManager",
"You can divide numbers with": "若要除數,您可以使用",
"or": "或是",
recurringInterval: "間隔",
"Recurring": "週期性",
strategyManual: "手動切換使用中/非使用中",
warningTimezone: "正在使用伺服器的時區",
weekdayShortMon: "一",
weekdayShortTue: "二",
weekdayShortWed: "三",
weekdayShortThu: "四",
weekdayShortFri: "五",
weekdayShortSat: "六",
weekdayShortSun: "日",
dayOfWeek: "每周特定一天",
dayOfMonth: "每月特定一天",
lastDay: "最後一天",
lastDay1: "每月的最後一天",
lastDay2: "每月的倒數第二天",
lastDay3: "每月的倒數第三天",
lastDay4: "每月的倒數第四天",
"No Maintenance": "無維護",
pauseMaintenanceMsg: "您確定要暫停嗎?",
"maintenanceStatus-under-maintenance": "維護中",
"maintenanceStatus-inactive": "非使用中",
"maintenanceStatus-scheduled": "已排程",
"maintenanceStatus-ended": "已結束",
"maintenanceStatus-unknown": "未知",
"Display Timezone": "顯示時區",
"Server Timezone": "伺服器時區",
statusPageMaintenanceEndDate: "結束",
};

View File

@@ -37,19 +37,32 @@
<div class="profile-pic">{{ $root.usernameFirstChar }}</div>
<font-awesome-icon icon="angle-down" />
</div>
<!-- Header's Dropdown Menu -->
<ul class="dropdown-menu">
<!-- Username -->
<li>
<i18n-t v-if="$root.username != null" tag="span" keypath="signedInDisp" class="dropdown-item-text">
<strong>{{ $root.username }}</strong>
</i18n-t>
<span v-if="$root.username == null" class="dropdown-item-text">{{ $t("signedInDispDisabled") }}</span>
</li>
<li><hr class="dropdown-divider"></li>
<!-- Functions -->
<li>
<router-link to="/settings" class="dropdown-item" :class="{ active: $route.path.includes('settings') }">
<router-link to="/maintenance" class="dropdown-item" :class="{ active: $route.path.includes('manage-maintenance') }">
<font-awesome-icon icon="wrench" /> {{ $t("Maintenance") }}
</router-link>
</li>
<li>
<router-link to="/settings/general" class="dropdown-item" :class="{ active: $route.path.includes('settings') }">
<font-awesome-icon icon="cog" /> {{ $t("Settings") }}
</router-link>
</li>
<li v-if="$root.loggedIn && $root.socket.token !== 'autoLogin'">
<button class="dropdown-item" @click="$root.logout">
<font-awesome-icon icon="sign-out-alt" />
@@ -304,5 +317,4 @@ main {
background-color: $dark-bg;
}
}
</style>

View File

@@ -5,6 +5,7 @@ import Toast from "vue-toastification";
import "vue-toastification/dist/index.css";
import App from "./App.vue";
import "./assets/app.scss";
import "./assets/vue-datepicker.scss";
import { i18n } from "./i18n";
import { FontAwesomeIcon } from "./icon.js";
import datetime from "./mixins/datetime";
@@ -15,6 +16,13 @@ import theme from "./mixins/theme";
import lang from "./mixins/lang";
import { router } from "./router";
import { appName } from "./util.ts";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(relativeTime);
const app = createApp({
mixins: [

View File

@@ -1,10 +1,4 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(relativeTime);
/**
* DateTime Mixin
@@ -18,6 +12,19 @@ export default {
},
methods: {
toUTC(value) {
return dayjs.tz(value, this.timezone).utc().format();
},
/**
* Used for <input type="datetime" />
* @param value
* @returns {string}
*/
toDateTimeInputFormat(value) {
return this.datetimeFormat(value, "YYYY-MM-DDTHH:mm");
},
/**
* Return a given value in the format YYYY-MM-DD HH:mm:ss
* @param {any} value Value to format as date time
@@ -27,6 +34,17 @@ export default {
return this.datetimeFormat(value, "YYYY-MM-DD HH:mm:ss");
},
datetimeMaintenance(value) {
const inputDate = new Date(value);
const now = new Date(Date.now());
if (inputDate.getFullYear() === now.getUTCFullYear() && inputDate.getMonth() === now.getUTCMonth() && inputDate.getDay() === now.getUTCDay()) {
return this.datetimeFormat(value, "HH:mm");
} else {
return this.datetimeFormat(value, "YYYY-MM-DD HH:mm");
}
},
/**
* Return a given value in the format YYYY-MM-DD
* @param {any} value Value to format as date
@@ -64,7 +82,7 @@ export default {
return dayjs.utc(value).tz(this.timezone).format(format);
}
return "";
}
},
},
computed: {

View File

@@ -33,6 +33,7 @@ export default {
allowLoginDialog: false, // Allowed to show login dialog, but "loggedIn" have to be true too. This exists because prevent the login dialog show 0.1s in first before the socket server auth-ed.
loggedIn: false,
monitorList: { },
maintenanceList: { },
heartbeatList: { },
importantHeartbeatList: { },
avgPingList: { },
@@ -57,7 +58,6 @@ export default {
},
created() {
window.addEventListener("resize", this.onResize);
this.initSocketIO();
},
@@ -129,6 +129,10 @@ export default {
this.monitorList = data;
});
socket.on("maintenanceList", (data) => {
this.maintenanceList = data;
});
socket.on("notificationList", (data) => {
this.notificationList = data;
});
@@ -445,6 +449,13 @@ export default {
socket.emit("getMonitorList", callback);
},
getMaintenanceList(callback) {
if (! callback) {
callback = () => { };
}
socket.emit("getMaintenanceList", callback);
},
/**
* Add a monitor
* @param {Object} monitor Object representing monitor to add
@@ -454,6 +465,26 @@ export default {
socket.emit("add", monitor, callback);
},
addMaintenance(maintenance, callback) {
socket.emit("addMaintenance", maintenance, callback);
},
addMonitorMaintenance(maintenanceID, monitors, callback) {
socket.emit("addMonitorMaintenance", maintenanceID, monitors, callback);
},
addMaintenanceStatusPage(maintenanceID, statusPages, callback) {
socket.emit("addMaintenanceStatusPage", maintenanceID, statusPages, callback);
},
getMonitorMaintenance(maintenanceID, callback) {
socket.emit("getMonitorMaintenance", maintenanceID, callback);
},
getMaintenanceStatusPage(maintenanceID, callback) {
socket.emit("getMaintenanceStatusPage", maintenanceID, callback);
},
/**
* Delete monitor by ID
* @param {number} monitorID ID of monitor to delete
@@ -463,6 +494,10 @@ export default {
socket.emit("deleteMonitor", monitorID, callback);
},
deleteMaintenance(maintenanceID, callback) {
socket.emit("deleteMaintenance", maintenanceID, callback);
},
/** Clear the hearbeat list */
clearData() {
console.log("reset heartbeat list");
@@ -550,7 +585,12 @@ export default {
for (let monitorID in this.lastHeartbeatList) {
let lastHeartBeat = this.lastHeartbeatList[monitorID];
if (! lastHeartBeat) {
if (this.monitorList[monitorID].maintenance) {
result[monitorID] = {
text: this.$t("statusMaintenance"),
color: "maintenance",
};
} else if (! lastHeartBeat) {
result[monitorID] = unknown;
} else if (lastHeartBeat.status === 1) {
result[monitorID] = {
@@ -579,6 +619,7 @@ export default {
let result = {
up: 0,
down: 0,
maintenance: 0,
unknown: 0,
pause: 0,
};
@@ -587,7 +628,9 @@ export default {
let beat = this.$root.lastHeartbeatList[monitorID];
let monitor = this.$root.monitorList[monitorID];
if (monitor && ! monitor.active) {
if (monitor && monitor.maintenance) {
result.maintenance++;
} else if (monitor && ! monitor.active) {
result.pause++;
} else if (beat) {
if (beat.status === 1) {

View File

@@ -46,6 +46,10 @@ export default {
}
return this.userTheme;
}
},
isDark() {
return this.theme === "dark";
}
},

View File

@@ -1,7 +1,7 @@
<template>
<div class="container-fluid">
<div class="row">
<div v-if="! $root.isMobile" class="col-12 col-md-5 col-xl-4">
<div v-if="!$root.isMobile" class="col-12 col-md-5 col-xl-4">
<div>
<router-link to="/add" class="btn btn-primary mb-3"><font-awesome-icon icon="plus" /> {{ $t("Add New Monitor") }}</router-link>
</div>

View File

@@ -15,6 +15,10 @@
<h3>{{ $t("Down") }}</h3>
<span class="num text-danger">{{ $root.stats.down }}</span>
</div>
<div class="col">
<h3>{{ $t("Maintenance") }}</h3>
<span class="num text-maintenance">{{ $root.stats.maintenance }}</span>
</div>
<div class="col">
<h3>{{ $t("Unknown") }}</h3>
<span class="num text-secondary">{{ $root.stats.unknown }}</span>

View File

@@ -20,19 +20,21 @@
</p>
<div class="functions">
<button v-if="monitor.active" class="btn btn-light" @click="pauseDialog">
<div class="btn-group" role="group">
<button v-if="monitor.active" class="btn btn-normal" @click="pauseDialog">
<font-awesome-icon icon="pause" /> {{ $t("Pause") }}
</button>
<button v-if="! monitor.active" class="btn btn-primary" @click="resumeMonitor">
<font-awesome-icon icon="play" /> {{ $t("Resume") }}
</button>
<router-link :to=" '/edit/' + monitor.id " class="btn btn-secondary">
<router-link :to=" '/edit/' + monitor.id " class="btn btn-normal">
<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>
</div>
<div class="shadow-box">
<div class="row">
@@ -392,11 +394,6 @@ export default {
@media (max-width: 550px) {
.functions {
text-align: center;
button, a {
margin-left: 10px !important;
margin-right: 10px !important;
}
}
.ping-chart-wrapper {
@@ -439,12 +436,6 @@ export default {
}
}
.functions {
button, a {
margin-right: 20px;
}
}
.shadow-box {
padding: 20px;
margin-top: 25px;

View File

@@ -0,0 +1,534 @@
<template>
<transition name="slide-fade" appear>
<div>
<h1 class="mb-3">{{ pageName }}</h1>
<form @submit.prevent="submit">
<div class="shadow-box">
<div class="row">
<div class="col-xl-10">
<!-- Title -->
<div class="mb-3">
<label for="name" class="form-label">{{ $t("Title") }}</label>
<input
id="name" v-model="maintenance.title" type="text" class="form-control"
required
>
</div>
<!-- Description -->
<div class="my-3">
<label for="description" class="form-label">{{ $t("Description") }}</label>
<textarea
id="description" v-model="maintenance.description" class="form-control"
></textarea>
</div>
<!-- Affected Monitors -->
<h2 class="mt-5">{{ $t("Affected Monitors") }}</h2>
{{ $t("affectedMonitorsDescription") }}
<div class="my-3">
<VueMultiselect
id="affected_monitors"
v-model="affectedMonitors"
:options="affectedMonitorsOptions"
track-by="id"
label="name"
:multiple="true"
:close-on-select="false"
:clear-on-select="false"
:preserve-search="true"
:placeholder="$t('Pick Affected Monitors...')"
:preselect-first="false"
:max-height="600"
:taggable="false"
></VueMultiselect>
</div>
<!-- Status pages to display maintenance info on -->
<h2 class="mt-5">{{ $t("Status Pages") }}</h2>
{{ $t("affectedStatusPages") }}
<div class="my-3">
<!-- Show on all pages -->
<div class="form-check mb-2">
<input
id="show-on-all-pages" v-model="showOnAllPages" class="form-check-input"
type="checkbox"
>
<label class="form-check-label" for="show-powered-by">{{
$t("All Status Pages")
}}</label>
</div>
<div v-if="!showOnAllPages">
<VueMultiselect
id="selected_status_pages"
v-model="selectedStatusPages"
:options="selectedStatusPagesOptions"
track-by="id"
label="name"
:multiple="true"
:close-on-select="false"
:clear-on-select="false"
:preserve-search="true"
:placeholder="$t('Select status pages...')"
:preselect-first="false"
:max-height="600"
:taggable="false"
></VueMultiselect>
</div>
</div>
<h2 class="mt-5">{{ $t("Date and Time") }}</h2>
<div> {{ $t("warningTimezone") }}: <mark>{{ $root.info.serverTimezone }} ({{ $root.info.serverTimezoneOffset }})</mark></div>
<!-- Strategy -->
<div class="my-3">
<label for="strategy" class="form-label">{{ $t("Strategy") }}</label>
<select id="strategy" v-model="maintenance.strategy" class="form-select">
<option value="manual">{{ $t("strategyManual") }}</option>
<option value="single">{{ $t("Single Maintenance Window") }}</option>
<option value="recurring-interval">{{ $t("Recurring") }} - {{ $t("recurringInterval") }}</option>
<option value="recurring-weekday">{{ $t("Recurring") }} - {{ $t("dayOfWeek") }}</option>
<option value="recurring-day-of-month">{{ $t("Recurring") }} - {{ $t("dayOfMonth") }}</option>
<option v-if="false" value="recurring-day-of-year">{{ $t("Recurring") }} - Day of Year</option>
</select>
</div>
<!-- Single Maintenance Window -->
<template v-if="maintenance.strategy === 'single'">
<!-- DateTime Range -->
<div class="my-3">
<label class="form-label">{{ $t("DateTime Range") }}</label>
<Datepicker
v-model="maintenance.dateRange"
:dark="$root.isDark"
range
:monthChangeOnScroll="false"
:minDate="minDate"
format="yyyy-MM-dd HH:mm"
modelType="yyyy-MM-dd HH:mm:ss"
/>
</div>
</template>
<!-- Recurring - Interval -->
<template v-if="maintenance.strategy === 'recurring-interval'">
<div class="my-3">
<label for="interval-day" class="form-label">
{{ $t("recurringInterval") }}
<template v-if="maintenance.intervalDay >= 1">
({{
$tc("recurringIntervalMessage", maintenance.intervalDay, [
maintenance.intervalDay
])
}})
</template>
</label>
<input id="interval-day" v-model="maintenance.intervalDay" type="number" class="form-control" required min="1" max="3650" step="1">
</div>
</template>
<!-- Recurring - Weekday -->
<template v-if="maintenance.strategy === 'recurring-weekday'">
<div class="my-3">
<label for="interval-day" class="form-label">
{{ $t("dayOfWeek") }}
</label>
<!-- Weekday Picker -->
<div class="weekday-picker">
<div v-for="(weekday, index) in weekdays" :key="index">
<label class="form-check-label" :for="weekday.id">{{ $t(weekday.langKey) }}</label>
<div class="form-check-inline"><input :id="weekday.id" v-model="maintenance.weekdays" type="checkbox" :value="weekday.value" class="form-check-input"></div>
</div>
</div>
</div>
</template>
<!-- Recurring - Day of month -->
<template v-if="maintenance.strategy === 'recurring-day-of-month'">
<div class="my-3">
<label for="interval-day" class="form-label">
{{ $t("dayOfMonth") }}
</label>
<!-- Day Picker -->
<div class="day-picker">
<div v-for="index in 31" :key="index">
<label class="form-check-label" :for="'day' + index">{{ index }}</label>
<div class="form-check-inline">
<input :id="'day' + index" v-model="maintenance.daysOfMonth" type="checkbox" :value="index" class="form-check-input">
</div>
</div>
</div>
<div class="mt-3 mb-2">{{ $t("lastDay") }}</div>
<div v-for="(lastDay, index) in lastDays" :key="index" class="form-check">
<input :id="lastDay.langKey" v-model="maintenance.daysOfMonth" type="checkbox" :value="lastDay.value" class="form-check-input">
<label class="form-check-label" :for="lastDay.langKey">
{{ $t(lastDay.langKey) }}
</label>
</div>
</div>
</template>
<!-- For any recurring types -->
<template v-if="maintenance.strategy === 'recurring-interval' || maintenance.strategy === 'recurring-weekday' || maintenance.strategy === 'recurring-day-of-month'">
<!-- Maintenance Time Window of a Day -->
<div class="my-3">
<label class="form-label">{{ $t("Maintenance Time Window of a Day") }}</label>
<Datepicker
v-model="maintenance.timeRange"
:dark="$root.isDark"
timePicker
disableTimeRangeValidation range
/>
</div>
<!-- Date Range -->
<div class="my-3">
<label class="form-label">{{ $t("Effective Date Range") }}</label>
<Datepicker
v-model="maintenance.dateRange"
:dark="$root.isDark"
range datePicker
:monthChangeOnScroll="false"
:minDate="minDate"
format="yyyy-MM-dd HH:mm:ss"
modelType="yyyy-MM-dd HH:mm:ss"
required
/>
</div>
</template>
<div class="mt-4 mb-1">
<button
id="monitor-submit-btn" class="btn btn-primary" type="submit"
:disabled="processing"
>
{{ $t("Save") }}
</button>
</div>
</div>
</div>
</div>
</form>
</div>
</transition>
</template>
<script>
import { useToast } from "vue-toastification";
import VueMultiselect from "vue-multiselect";
import dayjs from "dayjs";
import Datepicker from "@vuepic/vue-datepicker";
const toast = useToast();
export default {
components: {
VueMultiselect,
Datepicker
},
data() {
return {
processing: false,
maintenance: {},
affectedMonitors: [],
affectedMonitorsOptions: [],
showOnAllPages: false,
selectedStatusPages: [],
dark: (this.$root.theme === "dark"),
neverEnd: false,
minDate: this.$root.date(dayjs()) + " 00:00",
lastDays: [
{
langKey: "lastDay1",
value: "lastDay1",
},
{
langKey: "lastDay2",
value: "lastDay2",
},
{
langKey: "lastDay3",
value: "lastDay3",
},
{
langKey: "lastDay4",
value: "lastDay4",
}
],
weekdays: [
{
id: "weekday1",
langKey: "weekdayShortMon",
value: 1,
},
{
id: "weekday2",
langKey: "weekdayShortTue",
value: 2,
},
{
id: "weekday3",
langKey: "weekdayShortWed",
value: 3,
},
{
id: "weekday4",
langKey: "weekdayShortThu",
value: 4,
},
{
id: "weekday5",
langKey: "weekdayShortFri",
value: 5,
},
{
id: "weekday6",
langKey: "weekdayShortSat",
value: 6,
},
{
id: "weekday0",
langKey: "weekdayShortSun",
value: 0,
},
],
};
},
computed: {
selectedStatusPagesOptions() {
return Object.values(this.$root.statusPageList).map(statusPage => {
return {
id: statusPage.id,
name: statusPage.title
};
});
},
pageName() {
return this.$t((this.isAdd) ? "Schedule Maintenance" : "Edit Maintenance");
},
isAdd() {
return this.$route.path === "/add-maintenance";
},
isEdit() {
return this.$route.path.startsWith("/maintenance/edit");
},
},
watch: {
"$route.fullPath"() {
this.init();
},
neverEnd(value) {
if (value) {
this.maintenance.recurringEndDate = "";
}
},
},
mounted() {
this.init();
this.$root.getMonitorList((res) => {
if (res.ok) {
Object.values(this.$root.monitorList).map(monitor => {
this.affectedMonitorsOptions.push({
id: monitor.id,
name: monitor.name,
});
});
}
});
},
methods: {
init() {
this.affectedMonitors = [];
this.selectedStatusPages = [];
if (this.isAdd) {
this.maintenance = {
title: "",
description: "",
strategy: "single",
active: 1,
intervalDay: 1,
dateRange: [ this.minDate ],
timeRange: [{
hours: 2,
minutes: 0,
}, {
hours: 3,
minutes: 0,
}],
weekdays: [],
daysOfMonth: [],
};
} else if (this.isEdit) {
this.$root.getSocket().emit("getMaintenance", this.$route.params.id, (res) => {
if (res.ok) {
this.maintenance = res.maintenance;
this.$root.getSocket().emit("getMonitorMaintenance", this.$route.params.id, (res) => {
if (res.ok) {
Object.values(res.monitors).map(monitor => {
this.affectedMonitors.push(monitor);
});
} else {
toast.error(res.msg);
}
});
this.$root.getSocket().emit("getMaintenanceStatusPage", this.$route.params.id, (res) => {
if (res.ok) {
Object.values(res.statusPages).map(statusPage => {
this.selectedStatusPages.push({
id: statusPage.id,
name: statusPage.title
});
});
this.showOnAllPages = Object.values(res.statusPages).length === this.selectedStatusPagesOptions.length;
} else {
toast.error(res.msg);
}
});
} else {
toast.error(res.msg);
}
});
}
},
async submit() {
this.processing = true;
if (this.affectedMonitors.length === 0) {
toast.error(this.$t("atLeastOneMonitor"));
return this.processing = false;
}
if (this.isAdd) {
this.$root.addMaintenance(this.maintenance, async (res) => {
if (res.ok) {
await this.addMonitorMaintenance(res.maintenanceID, async () => {
await this.addMaintenanceStatusPage(res.maintenanceID, () => {
toast.success(res.msg);
this.processing = false;
this.$root.getMaintenanceList();
this.$router.push("/maintenance");
});
});
} else {
toast.error(res.msg);
this.processing = false;
}
});
} else {
this.$root.getSocket().emit("editMaintenance", this.maintenance, async (res) => {
if (res.ok) {
await this.addMonitorMaintenance(res.maintenanceID, async () => {
await this.addMaintenanceStatusPage(res.maintenanceID, () => {
this.processing = false;
this.$root.toastRes(res);
this.init();
this.$router.push("/maintenance");
});
});
} else {
this.processing = false;
toast.error(res.msg);
}
});
}
},
async addMonitorMaintenance(maintenanceID, callback) {
await this.$root.addMonitorMaintenance(maintenanceID, this.affectedMonitors, async (res) => {
if (!res.ok) {
toast.error(res.msg);
} else {
this.$root.getMonitorList();
}
callback();
});
},
async addMaintenanceStatusPage(maintenanceID, callback) {
await this.$root.addMaintenanceStatusPage(maintenanceID, (this.showOnAllPages) ? this.selectedStatusPagesOptions : this.selectedStatusPages, async (res) => {
if (!res.ok) {
toast.error(res.msg);
} else {
this.$root.getMaintenanceList();
}
callback();
});
},
},
};
</script>
<style lang="scss" scoped>
.shadow-box {
padding: 20px;
}
textarea {
min-height: 150px;
}
.dark-calendar::-webkit-calendar-picker-indicator {
filter: invert(1);
}
.weekday-picker {
display: flex;
gap: 10px;
& > div {
display: flex;
flex-direction: column;
align-items: center;
width: 40px;
.form-check-inline {
margin-right: 0;
}
}
}
.day-picker {
display: flex;
gap: 10px;
flex-wrap: wrap;
& > div {
display: flex;
flex-direction: column;
align-items: center;
width: 40px;
.form-check-inline {
margin-right: 0;
}
}
}
</style>

View File

@@ -24,6 +24,9 @@
<option value="keyword">
HTTP(s) - {{ $t("Keyword") }}
</option>
<option value="grpc-keyword">
gRPC(s) - {{ $t("Keyword") }}
</option>
<option value="dns">
DNS
</option>
@@ -46,11 +49,14 @@
MQTT
</option>
<option value="sqlserver">
SQL Server
Microsoft SQL Server
</option>
<option value="postgres">
PostgreSQL
</option>
<option value="mysql">
MySQL/MariaDB
</option>
<option value="radius">
Radius
</option>
@@ -70,6 +76,12 @@
<input id="url" v-model="monitor.url" type="url" class="form-control" pattern="https?://.+" required>
</div>
<!-- gRPC URL -->
<div v-if="monitor.type === 'grpc-keyword' " class="my-3">
<label for="grpc-url" class="form-label">{{ $t("URL") }}</label>
<input id="grpc-url" v-model="monitor.grpcUrl" type="url" class="form-control" pattern="[^\:]+:[0-9]{5}" required>
</div>
<!-- Push URL -->
<div v-if="monitor.type === 'push' " class="my-3">
<label for="push-url" class="form-label">{{ $t("PushUrl") }}</label>
@@ -81,7 +93,7 @@
</div>
<!-- Keyword -->
<div v-if="monitor.type === 'keyword' " class="my-3">
<div v-if="monitor.type === 'keyword' || monitor.type === 'grpc-keyword' " class="my-3">
<label for="keyword" class="form-label">{{ $t("Keyword") }}</label>
<input id="keyword" v-model="monitor.keyword" type="text" class="form-control" required>
<div class="form-text">
@@ -97,8 +109,8 @@
</div>
<!-- Port -->
<!-- For TCP Port / Steam / MQTT Type -->
<div v-if="monitor.type === 'port' || monitor.type === 'steam' || monitor.type === 'mqtt'" class="my-3">
<!-- For TCP Port / Steam / MQTT / Radius Type -->
<div v-if="monitor.type === 'port' || monitor.type === 'steam' || monitor.type === 'mqtt' || monitor.type === 'radius'" class="my-3">
<label for="port" class="form-label">{{ $t("Port") }}</label>
<input id="port" v-model="monitor.port" type="number" class="form-control" required min="0" max="65535" step="1">
</div>
@@ -235,8 +247,8 @@
</div>
</template>
<!-- SQL Server and PostgreSQL -->
<template v-if="monitor.type === 'sqlserver' || monitor.type === 'postgres'">
<!-- SQL Server / PostgreSQL / MySQL -->
<template v-if="monitor.type === 'sqlserver' || monitor.type === 'postgres' || monitor.type === 'mysql'">
<div class="my-3">
<label for="sqlConnectionString" class="form-label">{{ $t("Connection String") }}</label>
@@ -246,6 +258,9 @@
<template v-if="monitor.type === 'postgres'">
<input id="sqlConnectionString" v-model="monitor.databaseConnectionString" type="text" class="form-control" placeholder="postgres://username:password@host:port/database">
</template>
<template v-if="monitor.type === 'mysql'">
<input id="sqlConnectionString" v-model="monitor.databaseConnectionString" type="text" class="form-control" placeholder="mysql://username:password@host:port/database">
</template>
</div>
<div class="my-3">
<label for="sqlQuery" class="form-label">{{ $t("Query") }}</label>
@@ -313,7 +328,7 @@
</div>
<!-- HTTP / Keyword only -->
<template v-if="monitor.type === 'http' || monitor.type === 'keyword' ">
<template v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'grpc-keyword' ">
<div class="my-3">
<label for="maxRedirects" class="form-label">{{ $t("Max. Redirects") }}</label>
<input id="maxRedirects" v-model="monitor.maxredirects" type="number" class="form-control" required min="0" step="1">
@@ -491,6 +506,55 @@
</template>
</template>
</template>
<!-- gRPC Options -->
<template v-if="monitor.type === 'grpc-keyword' ">
<!-- Proto service enable TLS -->
<h2 class="mt-5 mb-2">{{ $t("GRPC Options") }}</h2>
<div class="my-3 form-check">
<input id="grpc-enable-tls" v-model="monitor.grpcEnableTls" class="form-check-input" type="checkbox" value="">
<label class="form-check-label" for="grpc-enable-tls">
{{ $t("Enable TLS") }}
</label>
<div class="form-text">
{{ $t("enableGRPCTls") }}
</div>
</div>
<!-- Proto service name data -->
<div class="my-3">
<label for="protobuf" class="form-label">{{ $t("Proto Service Name") }}</label>
<input id="name" v-model="monitor.grpcServiceName" type="text" class="form-control" :placeholder="protoServicePlaceholder" required>
</div>
<!-- Proto method data -->
<div class="my-3">
<label for="protobuf" class="form-label">{{ $t("Proto Method") }}</label>
<input id="name" v-model="monitor.grpcMethod" type="text" class="form-control" :placeholder="protoMethodPlaceholder" required>
<div class="form-text">
{{ $t("grpcMethodDescription") }}
</div>
</div>
<!-- Proto data -->
<div class="my-3">
<label for="protobuf" class="form-label">{{ $t("Proto Content") }}</label>
<textarea id="protobuf" v-model="monitor.grpcProtobuf" class="form-control" :placeholder="protoBufDataPlaceholder"></textarea>
</div>
<!-- Body -->
<div class="my-3">
<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>
</div>
@@ -569,6 +633,40 @@ export default {
return this.$root.baseURL + "/api/push/" + this.monitor.pushToken + "?status=up&msg=OK&ping=";
},
protoServicePlaceholder() {
return this.$t("Example:", [ "Health" ]);
},
protoMethodPlaceholder() {
return this.$t("Example:", [ "check" ]);
},
protoBufDataPlaceholder() {
return this.$t("Example:", [ `
syntax = "proto3";
package grpc.health.v1;
service Health {
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}
message HealthCheckRequest {
string service = 1;
}
message HealthCheckResponse {
enum ServingStatus {
UNKNOWN = 0;
SERVING = 1;
NOT_SERVING = 2;
SERVICE_UNKNOWN = 3; // Used only by the Watch method.
}
ServingStatus status = 1;
}
` ]);
},
bodyPlaceholder() {
return this.$t("Example:", [ `
{
@@ -616,9 +714,11 @@ export default {
}
// Set default port for DNS if not already defined
if (! this.monitor.port || this.monitor.port === "53") {
if (! this.monitor.port || this.monitor.port === "53" || this.monitor.port === "1812") {
if (this.monitor.type === "dns") {
this.monitor.port = "53";
} else if (this.monitor.type === "radius") {
this.monitor.port = "1812";
} else {
this.monitor.port = undefined;
}

View File

@@ -0,0 +1,161 @@
<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: {
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);
}
});
},
deleteDialog() {
this.$refs.confirmDelete.show();
},
deleteMaintenance() {
this.$root.deleteMaintenance(this.maintenance.id, (res) => {
if (res.ok) {
toast.success(res.msg);
this.$router.push("/maintenance");
} else {
toast.error(res.msg);
}
});
},
},
};
</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

@@ -0,0 +1,280 @@
<template>
<transition name="slide-fade" appear>
<div>
<h1 class="mb-3">
{{ $t("Maintenance") }}
</h1>
<div>
<router-link to="/add-maintenance" class="btn btn-primary mb-3">
<font-awesome-icon icon="plus" /> {{ $t("Schedule Maintenance") }}
</router-link>
</div>
<div class="shadow-box">
<span v-if="Object.keys(sortedMaintenanceList).length === 0" class="d-flex align-items-center justify-content-center my-3">
{{ $t("No Maintenance") }}
</span>
<div
v-for="(item, index) in sortedMaintenanceList"
:key="index"
class="item"
:class="item.status"
>
<div class="left-part">
<div
class="circle"
></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>
<MaintenanceTime :maintenance="item" />
</div>
</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" @click="pauseDialog(item.id)">
<font-awesome-icon icon="pause" /> {{ $t("Pause") }}
</button>
<button v-if="!item.active" class="btn btn-primary" @click="resumeMaintenance(item.id)">
<font-awesome-icon icon="play" /> {{ $t("Resume") }}
</button>
<router-link :to="'/maintenance/edit/' + item.id" class="btn btn-normal">
<font-awesome-icon icon="edit" /> {{ $t("Edit") }}
</router-link>
<button class="btn btn-danger" @click="deleteDialog(item.id)">
<font-awesome-icon icon="trash" /> {{ $t("Delete") }}
</button>
</div>
</div>
</div>
</div>
<div class="text-center mt-3" style="font-size: 13px;">
<a href="https://github.com/louislam/uptime-kuma/wiki/Maintenance" target="_blank">Learn More</a>
</div>
<Confirm ref="confirmPause" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="pauseMaintenance">
{{ $t("pauseMaintenanceMsg") }}
</Confirm>
<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 { getResBaseURL } from "../util-frontend";
import { getMaintenanceRelativeURL } from "../util.ts";
import Confirm from "../components/Confirm.vue";
import MaintenanceTime from "../components/MaintenanceTime.vue";
import { useToast } from "vue-toastification";
const toast = useToast();
export default {
components: {
MaintenanceTime,
Confirm,
},
data() {
return {
selectedMaintenanceID: undefined,
statusOrderList: {
"under-maintenance": 1000,
"scheduled": 900,
"inactive": 800,
"ended": 700,
"unknown": 0,
}
};
},
computed: {
sortedMaintenanceList() {
let result = Object.values(this.$root.maintenanceList);
result.sort((m1, m2) => {
if (this.statusOrderList[m1.status] === this.statusOrderList[m2.status]) {
return m1.title.localeCompare(m2.title);
} else {
return this.statusOrderList[m1.status] < this.statusOrderList[m2.status];
}
});
return result;
},
},
mounted() {
},
methods: {
/**
* Get the correct URL for the icon
* @param {string} icon Path for icon
* @returns {string} Correctly formatted path including port numbers
*/
icon(icon) {
if (icon === "/icon.svg") {
return icon;
} else {
return getResBaseURL() + icon;
}
},
maintenanceURL(id) {
return getMaintenanceRelativeURL(id);
},
deleteDialog(maintenanceID) {
this.selectedMaintenanceID = maintenanceID;
this.$refs.confirmDelete.show();
},
deleteMaintenance() {
this.$root.deleteMaintenance(this.selectedMaintenanceID, (res) => {
if (res.ok) {
toast.success(res.msg);
this.$router.push("/maintenance");
} else {
toast.error(res.msg);
}
});
},
/**
* Show dialog to confirm pause
*/
pauseDialog(maintenanceID) {
this.selectedMaintenanceID = maintenanceID;
this.$refs.confirmPause.show();
},
/**
* Pause maintenance
*/
pauseMaintenance() {
this.$root.getSocket().emit("pauseMaintenance", this.selectedMaintenanceID, (res) => {
this.$root.toastRes(res);
});
},
/**
* Resume maintenance
*/
resumeMaintenance(id) {
this.$root.getSocket().emit("resumeMaintenance", id, (res) => {
this.$root.toastRes(res);
});
},
},
};
</script>
<style lang="scss" scoped>
@import "../assets/vars.scss";
.item {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
border-radius: 10px;
transition: all ease-in-out 0.15s;
justify-content: space-between;
padding: 10px;
min-height: 90px;
margin-bottom: 5px;
&:hover {
background-color: $highlight-white;
}
&.under-maintenance {
background-color: rgba(23, 71, 245, 0.16);
&:hover {
background-color: rgba(23, 71, 245, 0.3) !important;
}
.circle {
background-color: $maintenance;
}
}
&.scheduled {
.circle {
background-color: $primary;
}
}
&.inactive {
.circle {
background-color: $danger;
}
}
&.ended {
.left-part {
opacity: 0.3;
}
.circle {
background-color: $dark-font-color;
}
}
&.unknown {
.circle {
background-color: $dark-font-color;
}
}
.left-part {
display: flex;
gap: 12px;
align-items: center;
.circle {
width: 25px;
height: 25px;
border-radius: 50rem;
}
.info {
.title {
font-weight: bold;
font-size: 20px;
}
.status {
font-size: 14px;
}
}
}
.buttons {
display: flex;
gap: 8px;
}
}
.dark {
.item {
&:hover {
background-color: $dark-bg2;
}
}
}
</style>

View File

@@ -218,12 +218,29 @@
{{ $t("Degraded Service") }}
</div>
<div v-else-if="isMaintenance">
<font-awesome-icon icon="wrench" class="status-maintenance" />
{{ $t("maintenanceStatus-under-maintenance") }}
</div>
<div v-else>
<font-awesome-icon icon="question-circle" style="color: #efefef;" />
</div>
</template>
</div>
<!-- Maintenance -->
<template v-if="maintenanceList.length > 0">
<div
v-for="maintenance in maintenanceList" :key="maintenance.id"
class="shadow-box alert mb-4 p-3 bg-maintenance mt-4 position-relative" role="alert"
>
<h4 class="alert-heading">{{ maintenance.title }}</h4>
<div class="content">{{ maintenance.description }}</div>
<MaintenanceTime :maintenance="maintenance" />
</div>
</template>
<!-- Description -->
<strong v-if="editMode">{{ $t("Description") }}:</strong>
<Editable v-model="config.description" :contenteditable="editMode" tag="div" class="mb-4 description" />
@@ -295,8 +312,9 @@ import "vue-prism-editor/dist/prismeditor.min.css"; // import the styles somewhe
import { useToast } from "vue-toastification";
import Confirm from "../components/Confirm.vue";
import PublicGroupList from "../components/PublicGroupList.vue";
import MaintenanceTime from "../components/MaintenanceTime.vue";
import { getResBaseURL } from "../util-frontend";
import { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_PARTIAL_DOWN, UP } from "../util.ts";
import { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_MAINTENANCE, STATUS_PAGE_PARTIAL_DOWN, UP, MAINTENANCE } from "../util.ts";
const toast = useToast();
@@ -316,6 +334,7 @@ export default {
ImageCropUpload,
Confirm,
PrismEditor,
MaintenanceTime,
},
// Leave Page for vue route change
@@ -356,6 +375,7 @@ export default {
loadedData: false,
baseURL: "",
clickedEditButton: false,
maintenanceList: [],
};
},
computed: {
@@ -409,6 +429,10 @@ export default {
return "bg-" + this.incident.style;
},
maintenanceClass() {
return "bg-maintenance";
},
overallStatus() {
if (Object.keys(this.$root.publicLastHeartbeatList).length === 0) {
@@ -421,7 +445,9 @@ export default {
for (let id in this.$root.publicLastHeartbeatList) {
let beat = this.$root.publicLastHeartbeatList[id];
if (beat.status === UP) {
if (beat.status === MAINTENANCE) {
return STATUS_PAGE_MAINTENANCE;
} else if (beat.status === UP) {
hasUp = true;
} else {
status = STATUS_PAGE_PARTIAL_DOWN;
@@ -447,6 +473,10 @@ export default {
return this.overallStatus === STATUS_PAGE_ALL_DOWN;
},
isMaintenance() {
return this.overallStatus === STATUS_PAGE_MAINTENANCE;
},
},
watch: {
@@ -551,6 +581,7 @@ export default {
}
this.incident = res.data.incident;
this.maintenanceList = res.data.maintenanceList;
this.$root.publicGroupList = res.data.publicGroupList;
}).catch( function (error) {
if (error.response.status === 404) {
@@ -946,6 +977,24 @@ footer {
}
}
.maintenance-bg-info {
color: $maintenance;
}
.maintenance-icon {
font-size: 35px;
vertical-align: middle;
}
.dark .shadow-box {
background-color: #0d1117;
}
.status-maintenance {
color: $maintenance;
margin-right: 5px;
}
.mobile {
h1 {
font-size: 22px;
@@ -1007,4 +1056,10 @@ footer {
}
}
.bg-maintenance {
.alert-heading {
font-weight: bold;
}
}
</style>

View File

@@ -6,6 +6,7 @@ import Dashboard from "./pages/Dashboard.vue";
import DashboardHome from "./pages/DashboardHome.vue";
import Details from "./pages/Details.vue";
import EditMonitor from "./pages/EditMonitor.vue";
import EditMaintenance from "./pages/EditMaintenance.vue";
import List from "./pages/List.vue";
const Settings = () => import("./pages/Settings.vue");
import Setup from "./pages/Setup.vue";
@@ -14,6 +15,9 @@ import Entry from "./pages/Entry.vue";
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";
// Settings - Sub Pages
import Appearance from "./components/settings/Appearance.vue";
@@ -25,7 +29,6 @@ const Security = () => import("./components/settings/Security.vue");
import Proxies from "./components/settings/Proxies.vue";
import Backup from "./components/settings/Backup.vue";
import About from "./components/settings/About.vue";
import DockerHosts from "./components/settings/Docker.vue";
const routes = [
{
@@ -126,6 +129,22 @@ const routes = [
path: "/add-status-page",
component: AddStatusPage,
},
{
path: "/maintenance",
component: ManageMaintenance,
},
{
path: "/maintenance/:id",
component: MaintenanceDetails,
},
{
path: "/add-maintenance",
component: EditMaintenance,
},
{
path: "/maintenance/edit/:id",
component: EditMaintenance,
},
],
},
],

View File

@@ -1,12 +1,7 @@
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import timezones from "timezones-list";
import { localeDirection, currentLocale } from "./i18n";
dayjs.extend(utc);
dayjs.extend(timezone);
/**
* Returns the offset from UTC in hours for the current locale.
* @returns {number} The offset from UTC in hours.

View File

@@ -7,17 +7,21 @@
// Backend uses the compiled file util.js
// Frontend uses util.ts
Object.defineProperty(exports, "__esModule", { value: true });
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.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isDev = void 0;
const _dayjs = require("dayjs");
const dayjs = _dayjs;
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.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.isDev = void 0;
const dayjs = require("dayjs");
exports.isDev = process.env.NODE_ENV === "development";
exports.appName = "Uptime Kuma";
exports.DOWN = 0;
exports.UP = 1;
exports.PENDING = 2;
exports.MAINTENANCE = 3;
exports.STATUS_PAGE_ALL_DOWN = 0;
exports.STATUS_PAGE_ALL_UP = 1;
exports.STATUS_PAGE_PARTIAL_DOWN = 2;
exports.STATUS_PAGE_MAINTENANCE = 3;
exports.SQL_DATE_FORMAT = "YYYY-MM-DD";
exports.SQL_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss";
exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = "YYYY-MM-DD HH:mm";
/** Flip the status of s */
function flipStatus(s) {
if (s === exports.UP) {
@@ -100,7 +104,7 @@ class Logger {
}
module = module.toUpperCase();
level = level.toUpperCase();
const now = new Date().toISOString();
const now = dayjs.tz(new Date()).format();
const formattedMessage = (typeof msg === "string") ? `${now} [${module}] ${level}: ${msg}` : msg;
if (level === "INFO") {
console.info(formattedMessage);
@@ -303,3 +307,71 @@ function getMonitorRelativeURL(id) {
return "/dashboard/" + id;
}
exports.getMonitorRelativeURL = getMonitorRelativeURL;
function getMaintenanceRelativeURL(id) {
return "/maintenance/" + id;
}
exports.getMaintenanceRelativeURL = getMaintenanceRelativeURL;
/**
* Parse to Time Object that used in VueDatePicker
* @param {string} time E.g. 12:00
* @returns object
*/
function parseTimeObject(time) {
if (!time) {
return {
hours: 0,
minutes: 0,
};
}
let array = time.split(":");
if (array.length < 2) {
throw new Error("parseVueDatePickerTimeFormat: Invalid Time");
}
let obj = {
hours: parseInt(array[0]),
minutes: parseInt(array[1]),
seconds: 0,
};
if (array.length >= 3) {
obj.seconds = parseInt(array[2]);
}
return obj;
}
exports.parseTimeObject = parseTimeObject;
/**
* @returns string e.g. 12:00
*/
function parseTimeFromTimeObject(obj) {
if (!obj) {
return obj;
}
let result = "";
result += obj.hours.toString().padStart(2, "0") + ":" + obj.minutes.toString().padStart(2, "0");
if (obj.seconds) {
result += ":" + obj.seconds.toString().padStart(2, "0");
}
return result;
}
exports.parseTimeFromTimeObject = parseTimeFromTimeObject;
function isoToUTCDateTime(input) {
return dayjs(input).utc().format(exports.SQL_DATETIME_FORMAT);
}
exports.isoToUTCDateTime = isoToUTCDateTime;
/**
* @param input
*/
function utcToISODateTime(input) {
return dayjs.utc(input).toISOString();
}
exports.utcToISODateTime = utcToISODateTime;
/**
* For SQL_DATETIME_FORMAT
*/
function utcToLocal(input, format = exports.SQL_DATETIME_FORMAT) {
return dayjs.utc(input).local().format(format);
}
exports.utcToLocal = utcToLocal;
function localToUTC(input, format = exports.SQL_DATETIME_FORMAT) {
return dayjs(input).utc().format(format);
}
exports.localToUTC = localToUTC;

View File

@@ -6,18 +6,25 @@
// Backend uses the compiled file util.js
// Frontend uses util.ts
import * as _dayjs from "dayjs";
const dayjs = _dayjs;
import * as dayjs from "dayjs";
import * as timezone from "dayjs/plugin/timezone";
import * as utc from "dayjs/plugin/utc";
export const isDev = process.env.NODE_ENV === "development";
export const appName = "Uptime Kuma";
export const DOWN = 0;
export const UP = 1;
export const PENDING = 2;
export const MAINTENANCE = 3;
export const STATUS_PAGE_ALL_DOWN = 0;
export const STATUS_PAGE_ALL_UP = 1;
export const STATUS_PAGE_PARTIAL_DOWN = 2;
export const STATUS_PAGE_MAINTENANCE = 3;
export const SQL_DATE_FORMAT = "YYYY-MM-DD";
export const SQL_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss";
export const SQL_DATETIME_FORMAT_WITHOUT_SECOND = "YYYY-MM-DD HH:mm";
/** Flip the status of s */
export function flipStatus(s: number) {
@@ -112,7 +119,7 @@ class Logger {
module = module.toUpperCase();
level = level.toUpperCase();
const now = new Date().toISOString();
const now = dayjs.tz(new Date()).format();
const formattedMessage = (typeof msg === "string") ? `${now} [${module}] ${level}: ${msg}` : msg;
if (level === "INFO") {
@@ -336,3 +343,79 @@ export function genSecret(length = 64) {
export function getMonitorRelativeURL(id: string) {
return "/dashboard/" + id;
}
export function getMaintenanceRelativeURL(id: string) {
return "/maintenance/" + id;
}
/**
* Parse to Time Object that used in VueDatePicker
* @param {string} time E.g. 12:00
* @returns object
*/
export function parseTimeObject(time: string) {
if (!time) {
return {
hours: 0,
minutes: 0,
};
}
let array = time.split(":");
if (array.length < 2) {
throw new Error("parseVueDatePickerTimeFormat: Invalid Time");
}
let obj = {
hours: parseInt(array[0]),
minutes: parseInt(array[1]),
seconds: 0,
}
if (array.length >= 3) {
obj.seconds = parseInt(array[2]);
}
return obj;
}
/**
* @returns string e.g. 12:00
*/
export function parseTimeFromTimeObject(obj : any) {
if (!obj) {
return obj;
}
let result = "";
result += obj.hours.toString().padStart(2, "0") + ":" + obj.minutes.toString().padStart(2, "0")
if (obj.seconds) {
result += ":" + obj.seconds.toString().padStart(2, "0")
}
return result;
}
export function isoToUTCDateTime(input : string) {
return dayjs(input).utc().format(SQL_DATETIME_FORMAT);
}
/**
* @param input
*/
export function utcToISODateTime(input : string) {
return dayjs.utc(input).toISOString();
}
/**
* For SQL_DATETIME_FORMAT
*/
export function utcToLocal(input : string, format = SQL_DATETIME_FORMAT) {
return dayjs.utc(input).local().format(format);
}
export function localToUTC(input : string, format = SQL_DATETIME_FORMAT) {
return dayjs(input).utc().format(format);
}

View File

@@ -6,6 +6,9 @@ const { UptimeKumaServer } = require("../server/uptime-kuma-server");
const Database = require("../server/database");
const {Settings} = require("../server/settings");
const fs = require("fs");
const dayjs = require("dayjs");
dayjs.extend(require("dayjs/plugin/utc"));
dayjs.extend(require("dayjs/plugin/timezone"));
jest.mock("axios");