Compare commits

...

175 Commits

Author SHA1 Message Date
Raj Nandan Sharma 9ed35589f7 Merge pull request #220 from rajnandan1/release/3.0.3
fix: load time #219
2025-01-24 21:45:33 +05:30
Raj Nandan Sharma e946dd18e0 fix: load time #219 2025-01-24 21:44:36 +05:30
Raj Nandan Sharma 51cad598c1 fix: load time #219 2025-01-24 21:42:57 +05:30
Raj Nandan Sharma 0595f23c18 Merge pull request #218 from cosmic-jellyfish/feature/ENG-Locale-Fix
English Locale tweaks
2025-01-24 13:04:58 +05:30
Raj Nandan Sharma b88816706b fix: retry await decrease from 4000 to 500ms 2025-01-24 11:42:06 +05:30
Cake 80edaae023 fix: update localisation strings across the board 2025-01-24 17:08:09 +11:00
Raj Nandan Sharma 883c46b064 Merge pull request #214 from rajnandan1/release/3.0.2
release of 3.0.2
2025-01-24 10:48:15 +05:30
Raj Nandan Sharma 4248cc31f2 chore: updated package.json version 2025-01-24 10:47:56 +05:30
Raj Nandan Sharma 5f81c6d61a fix: daily data local date format 2025-01-24 10:45:50 +05:30
Raj Nandan Sharma 00d82cc817 fix: daily data local date format 2025-01-24 10:44:48 +05:30
Raj Nandan Sharma a9edfde162 fix: bug fix for no data 2025-01-24 10:35:22 +05:30
Cake 468d7f445d fix: update localisation strings further 2025-01-24 15:15:13 +11:00
Cake 4cc74deece fix: update localisation strings 2025-01-24 15:11:50 +11:00
Raj Nandan Sharma f9831490af fix: base path for docker build 2025-01-24 09:08:13 +05:30
Raj Nandan Sharma a055a616eb fix: fix logo url as reported in #213 2025-01-23 23:37:39 +05:30
Raj Nandan Sharma 8009a2cbc4 feat: monitor re-arrange as requested in #215 2025-01-23 22:39:13 +05:30
Raj Nandan Sharma 693cc149cf feat: added retry for api timeouts as requested in #208 2025-01-23 10:33:15 +05:30
Raj Nandan Sharma d126fe0bce fix: fix for #212, data interpolation introduced 2025-01-23 09:36:37 +05:30
Raj Nandan Sharma 4cc49cce9e fix: default locale fix as reported in #209 2025-01-22 09:25:56 +05:30
Raj Nandan Sharma f7cb4cd805 fix: cron validation updated as reported in #206 2025-01-21 22:02:39 +05:30
Raj Nandan Sharma a2bd9883d1 chore: remove old docs.md 2025-01-21 09:18:21 +05:30
Raj Nandan Sharma 10a079c304 fix: fixing #193 base path login was having double slash in cookie 2025-01-21 09:12:10 +05:30
Raj Nandan Sharma 65f12f1082 docs: added contributing file 2025-01-21 08:44:58 +05:30
Raj Nandan Sharma c15f855c72 docs: added contributing file 2025-01-21 08:42:41 +05:30
Raj Nandan Sharma 3d38483022 Merge pull request #205 from matribeiro15/main
Add Brazilian Portuguese
2025-01-21 08:31:33 +05:30
Mateus Ribeiro 57dc1e7175 modified: src/lib/i18n/client.js
modified:   src/lib/locales/locales.json
	new file:   src/lib/locales/pt-BR.json
2025-01-21 01:22:01 +00:00
Raj Nandan Sharma ead68c929f docs: embed docs 2025-01-18 23:19:38 +05:30
Raj Nandan Sharma d54b346259 Merge pull request #204 from rajnandan1/date-fns
feat: i18n for dates
2025-01-18 23:14:13 +05:30
Raj Nandan Sharma a05de8269c docs: updated feature list 2025-01-18 23:13:13 +05:30
Raj Nandan Sharma 130b70c6af feat: i18n for dates 2025-01-18 23:10:07 +05:30
Raj Nandan Sharma 7247a54c08 feat: i18n for dates 2025-01-18 22:46:15 +05:30
Raj Nandan Sharma 28de7ebff8 feat: i18n for dates 2025-01-18 22:07:54 +05:30
Raj Nandan Sharma e2c9d4dc2d feat: i18n for dates 2025-01-18 21:44:46 +05:30
Raj Nandan Sharma 4866382180 feat: i18n for dates 2025-01-18 21:36:51 +05:30
Raj Nandan Sharma 1b1507db1a Merge pull request #202 from rajnandan1/heroku-dep
fix: fixes #198
2025-01-18 12:40:12 +05:30
Raj Nandan Sharma 0ea7d687a7 fix: fixes #198 2025-01-18 12:39:05 +05:30
Raj Nandan Sharma 502e1539ca Merge pull request #201 from sandyi5/main
korean translation update for v 3.0
2025-01-18 10:31:24 +05:30
김세영 f3910fea1b Update ko.json 2025-01-18 12:44:33 +09:00
김세영 ee9b48fb79 Create ko.json 2025-01-18 12:43:39 +09:00
김세영 142686ef92 Update locales.json 2025-01-18 12:24:57 +09:00
Raj Nandan Sharma 2aa187984e fix: cookie secure fix 2025-01-17 21:26:55 +05:30
Raj Nandan Sharma c20d4d71c3 fix: cookie secure fix 2025-01-17 20:37:02 +05:30
Raj Nandan Sharma 4f4d1e1b39 fix: login redirection to setup if not done 2025-01-17 09:51:07 +05:30
Raj Nandan Sharma f38b590f70 fix: handle file uplaod for docker container 2025-01-17 08:48:21 +05:30
Raj Nandan Sharma c2caf42fd3 Merge pull request #195 from rajnandan1/fix/day0
Fix/day0
2025-01-17 08:44:52 +05:30
Raj Nandan Sharma 8c279c8c10 fix: fixed bugs reported in reddit around analytics and css 2025-01-17 08:43:35 +05:30
Raj Nandan Sharma 3480c78360 fix: fixed bugs reported in reddit around analytics and css 2025-01-17 08:39:29 +05:30
Raj Nandan Sharma 79ce708f60 fixes reported on reddit 2025-01-16 23:13:47 +05:30
Raj Nandan Sharma a4526eb4b5 fix: nav list in home manage 2025-01-15 22:25:23 +05:30
Raj Nandan Sharma 866a133917 Merge pull request #194 from jghaanstra/main 2025-01-15 22:16:30 +05:30
Jelger Haanstra adce978244 Add Dutch Translation 2025-01-15 17:42:37 +01:00
Raj Nandan Sharma dd3c26b29a fix: fixing #193 and #191 2025-01-15 22:02:15 +05:30
Raj Nandan Sharma 25ad42ba9b Merge pull request #190 from otherwiseGG/danish-customer-language
Danish Translation
2025-01-15 21:29:56 +05:30
Benjamin Thiele 3285b7e472 Merge branch 'main' into danish-customer-language 2025-01-15 16:43:28 +01:00
Raj Nandan Sharma c7da55480a docs(docker): updated roadmap 2025-01-15 09:35:14 +05:30
Raj Nandan Sharma 35e81b5782 fix(docker): fixing #188 and #189
CHANGE: startup script started running before migrations of database, moved it to after migration
2025-01-15 09:24:05 +05:30
Raj Nandan Sharma 769a9526e7 Merge pull request #187 from kutovoys/main
Add Russian translations and update locales.json
2025-01-15 08:59:43 +05:30
Benjamin Thiele 5470153f4f Merge branch 'main' into danish-customer-language 2025-01-15 00:15:52 +01:00
Benjamin Thiele 9d6ad87275 Create dk.json 2025-01-15 00:13:55 +01:00
Benjamin Thiele ac6261d9ac Update locales.json 2025-01-15 00:12:18 +01:00
Sergey Kutovoy 48effe077e Add Russian translations and update locales.json
- Added Russian language support by creating ru.json with translations for various terms and phrases.
- Updated locales.json to include Russian in the list of available languages.
2025-01-15 00:48:57 +05:00
Raj Nandan Sharma c656bf8612 Merge pull request #186 from otherwiseGG/patch-1 2025-01-14 23:43:42 +05:30
Benjamin Thiele 9daf7c7156 Update locales.json 2025-01-14 18:53:27 +01:00
Benjamin Thiele 4bd3bfae9d Create de.json
German Translation File
2025-01-14 18:51:38 +01:00
Raj Nandan Sharma 727964949c docs: updated i18n docs 2025-01-14 23:10:24 +05:30
Raj Nandan Sharma 21e1fc1aca fix: fix seo meta tags 2025-01-14 22:49:00 +05:30
Raj Nandan Sharma eeba2ef2d4 docs: updated readme file 2025-01-14 22:37:06 +05:30
Raj Nandan Sharma b3e738ce03 fix: upload for base path aslo fixed 2025-01-14 22:01:30 +05:30
Raj Nandan Sharma 46848c7b19 fix: file upload issue 404 2025-01-14 21:49:56 +05:30
Raj Nandan Sharma 5f3d48597f fix: sitemap fixed.removed for now 2025-01-14 21:25:33 +05:30
Raj Nandan Sharma 0efc3e5999 Merge pull request #178 from rajnandan1/release/2.0.1
Release/3.0.0
2025-01-14 21:12:51 +05:30
Raj Nandan Sharma 0c0ce89317 docs: changelogs 2025-01-14 21:11:58 +05:30
Raj Nandan Sharma 4a47061f76 fix: deployment 2025-01-14 21:09:50 +05:30
Raj Nandan Sharma 9b3ff9b362 fix: deployment 2025-01-14 21:08:29 +05:30
Raj Nandan Sharma 8315fede7d docs: updated docs for release 2025-01-14 20:22:14 +05:30
Raj Nandan Sharma d6d0568f67 docs: updated docs for release 2025-01-14 17:57:42 +05:30
Raj Nandan Sharma ff0119db9b fix: fix embed and sub path 2025-01-14 14:07:53 +05:30
Raj Nandan Sharma 37d776c315 refactor: move accounts pages under manage and forgot password 2025-01-14 07:46:13 +05:30
Raj Nandan Sharma f53d4abd8b fix: responsiveness 2025-01-13 11:31:10 +05:30
Raj Nandan Sharma f827bd4aeb feat: i18n added french #179, removed github dependency, clean up old code, simpler i18n file 2025-01-13 10:57:12 +05:30
Raj Nandan Sharma f28c4e96c5 feat: db clean up 2025-01-12 16:22:39 +05:30
Raj Nandan Sharma cf81b11c0b feat: support for postgres using knex 2025-01-12 14:07:37 +05:30
Raj Nandan Sharma 17dc752902 feat: support for postgres using knex 2025-01-12 13:41:17 +05:30
Raj Nandan Sharma bc9faf9456 feat: alerting with new incidents 2025-01-10 08:59:11 +05:30
Raj Nandan Sharma e5b615267c feat: incidents api 2025-01-09 12:03:54 +05:30
Raj Nandan Sharma 70e9086646 feat: incidents from github to sqlite 2025-01-08 23:14:59 +05:30
Raj Nandan Sharma 0cc30bc67e feat: incidents from github to sqlite 2025-01-07 11:57:40 +05:30
Raj Nandan Sharma 73792e7ce6 feat: incidents from github to sqlite 2025-01-07 11:39:38 +05:30
Raj Nandan Sharma 32f873d9c2 feat: incidents from github to sqlite 2025-01-06 09:56:34 +05:30
Raj Nandan Sharma 6566bc5f8f build: docker file update 2025-01-03 11:48:18 +05:30
Raj Nandan Sharma cc93114eab docs: adding docs for 3.0.0 2025-01-02 11:20:28 +05:30
Raj Nandan Sharma 6d5d949f5c docs: adding docs for 3.0.0 2024-12-29 23:16:11 +05:30
Raj Nandan Sharma f1be4a4db0 feat: pre release 3.0.0 2024-12-28 19:21:23 +05:30
Raj Nandan Sharma 8b9f576b30 feat: pre release 3.0.0 2024-12-27 16:58:51 +05:30
Raj Nandan Sharma 0735f959ef feat: pre release 3.0.0 2024-12-27 09:16:30 +05:30
Raj Nandan Sharma f91d65b7a4 docs: fix docs for npm deploy 2024-12-05 09:54:35 +05:30
Raj Nandan Sharma c8d4920cd3 docs: fix docs for npm deploy 2024-12-05 08:55:37 +05:30
Raj Nandan Sharma 27625bc31a docs: fix docs for npm deploy 2024-12-05 08:47:32 +05:30
Raj Nandan Sharma 67e22e4339 docs: fix docs for npm deploy 2024-12-04 23:49:18 +05:30
Raj Nandan Sharma b4ed5cffd6 docs: fix docs for npm deploy 2024-12-04 23:48:27 +05:30
Raj Nandan Sharma 6209ff12bb fix: fix the build 2024-12-04 23:38:45 +05:30
Raj Nandan Sharma 392a0e85be Merge pull request #176 from rajnandan1/release/2.0.0
Release/2.0.0 retry
2024-12-04 23:02:51 +05:30
Raj Nandan Sharma a7abf37781 fix: fix build 2024-12-04 22:58:46 +05:30
Raj Nandan Sharma c09f87daa7 fix: fix build 2024-12-04 22:53:16 +05:30
Raj Nandan Sharma 43072d2777 fix: fix build 2024-12-04 22:48:33 +05:30
Raj Nandan Sharma ed52f28743 Merge pull request #175 from rajnandan1/release/2.0.0
Release/2.0.0
2024-12-04 18:32:36 +05:30
Raj Nandan Sharma fe953b6b36 fix: fixed docs 2024-12-04 18:30:50 +05:30
Raj Nandan Sharma d931f3ec6d fix: fixed docs 2024-12-04 17:52:14 +05:30
Raj Nandan Sharma 2fbf404bf3 docs: monitor example alerting 2024-12-04 16:19:10 +05:30
Raj Nandan Sharma 49064e4e25 fix: fix embeds 2024-12-04 16:10:01 +05:30
Raj Nandan Sharma 2c4004d91f fix: as reported in #103 changing timezone to UTC 2024-12-04 15:33:01 +05:30
Raj Nandan Sharma 37706b87c4 docs: updated docs and docker file 2024-12-04 15:21:13 +05:30
Raj Nandan Sharma c8a98a745d docs: added docs for pull request #112 2024-12-04 12:39:52 +05:30
Raj Nandan Sharma abae472545 docs: added postgres database also 2024-12-04 12:33:00 +05:30
Raj Nandan Sharma 749593f1af refactor: remove dependency on github addresses #94 2024-11-29 10:57:31 +05:30
Raj Nandan Sharma a824a5dcf2 docs: added docs for alerting to address #49 2024-11-29 09:57:28 +05:30
Raj Nandan Sharma 8b2fe90eb7 docs: added a feature to address #53 2024-11-28 23:07:42 +05:30
Raj Nandan Sharma e6370fab66 feat: added daily view for each day on click 2024-11-28 22:54:21 +05:30
Raj Nandan Sharma 92ea4b64f9 feat: first commit for the release of version 2.0.0 2024-11-27 11:41:00 +05:30
Raj Nandan Sharma 9f6d071f25 Update README.md 2024-11-24 16:30:35 +05:30
Raj Nandan Sharma 274536eafa Update README.md 2024-11-23 22:53:21 +05:30
Raj Nandan Sharma 5b0ad39526 Update README.md 2024-11-23 22:52:28 +05:30
Raj Nandan Sharma 01d54f504b Update README.md 2024-11-23 21:57:31 +05:30
Raj Nandan Sharma e413fb7ecd Update README.md 2024-11-23 21:51:10 +05:30
Raj Nandan Sharma dc665ec581 Merge pull request #124 from rajnandan1/fix-issue-123
fix: fixed theme settings as reported in issue #123
2024-11-20 09:49:10 +05:30
Raj Nandan Sharma f7fa0452cd fix: fixed theme settings as reported in issue #123 2024-11-20 09:47:32 +05:30
Raj Nandan Sharma 47253cc54b Update README.md 2024-11-19 19:03:53 +05:30
Raj Nandan Sharma 71b56db909 docs: added api reference 2024-11-19 11:11:58 +05:30
Raj Nandan Sharma ad41edc972 Update FUNDING.yml 2024-11-16 12:47:42 +05:30
Raj Nandan Sharma 9076561644 Update FUNDING.yml 2024-11-16 12:46:29 +05:30
Raj Nandan Sharma ff5601aad2 Create FUNDING.yml 2024-11-16 12:45:24 +05:30
Raj Nandan Sharma 21629b7bed Update README.md 2024-11-16 12:33:05 +05:30
Raj Nandan Sharma 052e38a292 fix: fixed responsiveness for language selector 2024-11-16 12:19:04 +05:30
Raj Nandan Sharma 38c44396b0 Merge pull request #120 from rajnandan1/release-candidate/0.0.16
Release candidate/0.0.16
2024-11-16 11:02:00 +05:30
Raj Nandan Sharma f80e19e18a docs: added docs for pm2 deployment 2024-11-16 11:00:57 +05:30
Raj Nandan Sharma ebdda050f8 docs: added docs for pm2 deployment 2024-11-16 10:59:20 +05:30
Raj Nandan Sharma 110fb1c180 docs: added docs for pm2 deployment 2024-11-16 10:57:53 +05:30
Raj Nandan Sharma fd361f9482 feat: added sitemap again, fixed #59 also 2024-11-15 22:56:31 +05:30
Raj Nandan Sharma 1b8e05ad1f feat: added sitemap again, fixed #59 also 2024-11-15 22:52:36 +05:30
Raj Nandan Sharma 28184d2a52 refactor: deployment refactor 2024-11-15 21:54:43 +05:30
Raj Nandan Sharma 9a14a81956 refactor: deployment refactor 2024-11-15 21:46:29 +05:30
Raj Nandan Sharma 3312bbe8e4 refactor: deployment refactor 2024-11-15 21:28:03 +05:30
Raj Nandan Sharma a9602f5576 fix: respnsiveness 2024-11-15 12:13:41 +05:30
Raj Nandan Sharma 0f5dd0fd31 feat: added analytics 2024-11-14 23:58:22 +05:30
Raj Nandan Sharma e45b016cca docs: updated docs 2024-11-14 09:23:28 +05:30
Raj Nandan Sharma e1fe07cf26 build: new docker build 2024-11-13 21:44:57 +05:30
Raj Nandan Sharma 18e851a205 build: new docker build 2024-11-13 21:28:46 +05:30
Raj Nandan Sharma c992ec22ca fixed docker file 2024-11-13 10:33:29 +05:30
Raj Nandan Sharma c57f75d1ea push 0.0.16 to git 2024-11-12 21:32:29 +05:30
Raj Nandan Sharma 5abfafe2fa new doc site 2024-11-11 08:40:44 +05:30
Raj Nandan Sharma bd87aded67 changes gitignore 2024-11-08 23:10:25 +05:30
Raj Nandan Sharma c0332fe035 revamp kener 2.0 2024-11-08 22:56:35 +05:30
Raj Nandan Sharma 3941cc4e9b Merge pull request #98 from rajnandan1/release/0.0.15
Release/0.0.15
2024-08-11 07:37:33 -07:00
Raj Nandan Sharma 6ebaec5c3b cleanup 2024-08-11 20:03:42 +05:30
Raj Nandan Sharma 4b8aebdc1b increase package.json version 2024-08-11 20:01:48 +05:30
Raj Nandan Sharma 10afb34921 improve hi locale 2024-08-11 20:01:08 +05:30
Raj Nandan Sharma 068a3b9c5e moving theme to site.yaml 2024-08-11 19:58:02 +05:30
Raj Nandan Sharma 94ac704582 added dotenv 2024-08-11 19:32:57 +05:30
Raj Nandan Sharma fd677396ee dotenv 2024-08-11 18:45:16 +05:30
Raj Nandan Sharma 06d81fb419 Added Vietnamese support 2024-08-10 21:54:13 +05:30
Raj Nandan Sharma 3264e86426 Merge pull request #89 from rajnandan1/nav-responsive
fix(nav): made nav bar responsive
2024-05-27 11:03:54 +05:30
Raj Nandan Sharma 5767f2d2c3 fix(nav): made nav bar responsive 2024-05-27 11:02:52 +05:30
Raj Nandan Sharma a267c4028a Merge pull request #82 from rajnandan1/ip_api_fixes
feat(api): Added API_IP_REGEX to match incoming IPs.
2024-05-16 09:31:38 +05:30
Raj Nandan Sharma 4916a6b380 feat(api): Added API_IP_REGEX to match incoming IPs.
CHANGE: Solves Issue #80

Commit message generate with [okgit](https://github.com/rajnandan1/okgit)
2024-05-16 09:30:09 +05:30
Raj Nandan Sharma 55550ade86 Update README.md 2024-05-13 16:56:28 +05:30
Raj Nandan Sharma 8a30906bd4 docs(readme): added example for custom thresholds 2024-05-11 14:07:59 +05:30
Raj Nandan Sharma e049ec7782 Merge pull request #79 from rajnandan1/support-ping
feat(monitor): added ping monitor
2024-05-11 14:03:42 +05:30
Raj Nandan Sharma fab2a2aed7 feat(monitor): added ping monitor 2024-05-11 14:02:27 +05:30
Raj Nandan Sharma 3abc8b730f feat(monitor): added ping monitor 2024-05-11 13:48:18 +05:30
Raj Nandan Sharma 80bf60e75d Update README.md 2024-05-08 08:52:21 +05:30
Raj Nandan Sharma d0095d3e31 Update README.md 2024-05-08 08:50:47 +05:30
Raj Nandan Sharma 04a9e85bd2 Merge pull request #78 from rajnandan1/pretty
refactor: added prettier config
2024-05-04 12:03:15 +05:30
Raj Nandan Sharma 5bab933364 refactor: added prettier config 2024-05-04 12:01:35 +05:30
Raj Nandan Sharma ad768296c3 Merge pull request #77 from rajnandan1/custom-threshold-bars
feat(kener): supports custom threshold for calculations of day uptime
2024-04-30 11:16:14 +05:30
Raj Nandan Sharma cc830827bb feat(kener): supports custom threshold for calculations of day uptime
CHANGE: monitors now get three new optional parameter dayDegradedMinimumCount, dayDownMinimumCount and includeDegradedInDowntime

Requestd on issue #54
2024-04-30 11:13:45 +05:30
282 changed files with 29771 additions and 10243 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
build
.env
.DS_Store
*.log
+12
View File
@@ -0,0 +1,12 @@
KENER_SECRET_KEY=please_change_me
NODE_ENV=production
PORT=3000
KENER_BASE_PATH=""
RESEND_API_KEY=
ORIGIN=http://localhost:3000
DATABASE_URL=sqlite://./database/kener.sqlite.db
TZ=UTC
RESEND_SENDER_EMAIL=Accounts <accounts@resend.dev>
# DATABASE_URL=postgresql://myuser:mypassword@localhost:5432/mydatabase
# DATABASE_URL=mysql://root:password@127.0.0.1:3306/kener
+48
View File
@@ -0,0 +1,48 @@
# Contributing to Kener
Thank you for considering contributing to our project! Here are some guidelines to help you get started.
---
## How to Contribute
1. Fork the repository and clone it locally.
2. Create a new branch for your feature or bug fix:
```bash
git checkout -b feature/your-feature-name
```
3. Make your changes and commit them:
```bash
git commit -m 'Describe your changes'
```
4. Push your changes to your fork:
```bash
git push origin feature/your-feature-name
```
5. Create a pull request to the `main` branch.
## Development
1. Install dependencies:
```bash
npm install
```
2. Create a `.env` file in the root of the project and add the following:
```bash
cp .env.example .env
```
2. Start the development server:
```bash
npm run dev
```
3. Open [http://localhost:3000](http://localhost:3000) in your browser.
## Documentation
The documentation is available in the `docs` folder. You can view it by going to [http://localhost:3000/docs/home](http://localhost:3000/docs/home) in your browser.
## Where to Start
1. Check out the [roadmap items](https://kener.ing/docs/roadmap/)
2. Add language support by following the [i18n guide](https://kener.ing/docs/i18n/)
+2
View File
@@ -0,0 +1,2 @@
github: rajnandan1
buy_me_a_coffee: rajnandan1
+7 -20
View File
@@ -1,40 +1,32 @@
---
name: Publish Docker image to Dockerhub and GHCR
on:
push:
branches:
- 'main'
# add additional branches that should build to images here
# they will be tagged based on the branch name IE kener:test
#- 'test'
- main
tags:
- '*.*.*'
# don't trigger if just updating docs
- "*.*.*"
paths-ignore:
- 'docs.md'
- 'README.md'
- '**/*.md'
- 'docs/**'
jobs:
push_to_registry:
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest
# only run if we've specified image tag to push to
if: ${{ vars.DOCKERHUB_IMAGE_NAME != '' || vars.GHCR_IMAGE_NAME != '' }}
# https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v2
- name: Log in to Docker Hub
if: ${{ github.event_name != 'pull_request' && vars.DOCKERHUB_IMAGE_NAME != '' }}
if: ${{ github.event_name != 'pull_request' && vars.DOCKERHUB_IMAGE_NAME != ''
}}
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: ${{ github.event_name != 'pull_request' && vars.GHCR_IMAGE_NAME != '' }}
uses: docker/login-action@v2
@@ -42,7 +34,6 @@ jobs:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v3
@@ -50,20 +41,16 @@ jobs:
images: |
${{ vars.DOCKERHUB_IMAGE_NAME }}
${{ vars.GHCR_IMAGE_NAME }}
# generate Docker tags based on the following events/attributes
tags: |
type=raw,value=latest,enable=${{ endsWith(github.ref, 'main') }}
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'main') }}
type=semver,pattern={{version}}
flavor: |
latest=false
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
+12 -1
View File
@@ -4,6 +4,7 @@ static/kener
build
config/monitors.yaml
config/site.yaml
config/server.yaml
/.svelte-kit
/src/lib/.kener
/package
@@ -15,4 +16,14 @@ vite.config.ts.timestamp-*
nodemon.json
.okgit/
config/static/*
!config/static/.kener
!config/static/.kener
db/*
!db/.kener
database/*
!database/.kener
uploads/*
!uploads/upload.dir
static/uploads/*
!static/uploads/upload.dir
temp.txt
+22
View File
@@ -0,0 +1,22 @@
.DS_Store
node_modules
static/kener
build
config/monitors.yaml
config/site.yaml
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
.okgit/
config/static/*
!config/static/.kener
**/*.yaml
**/*.yml
.github/
+9
View File
@@ -0,0 +1,9 @@
{
"useTabs": true,
"semi": true,
"tabWidth": 4,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
+36 -61
View File
@@ -1,74 +1,49 @@
FROM lsiobase/alpine:3.18 as base
FROM node:23
ENV TZ=Etc/GMT
RUN \
echo "**** install build packages ****" && \
apk add --no-cache \
# Install necessary packages including tzdata for timezone setting
RUN apt-get update && apt-get install -y \
build-essential \
libnode108 \
nodejs \
npm && \
echo "**** cleanup ****" && \
rm -rf \
/root/.cache \
/tmp/*
python3 \
sqlite3 \
libsqlite3-dev \
make \
node-gyp \
g++ \
tzdata \
iputils-ping && \
rm -rf /var/lib/apt/lists/*
# set OS timezone specified by docker ENV
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
ARG data_dir=/config
VOLUME $data_dir
ENV CONFIG_DIR=$data_dir
COPY docker/root/ /
# Dir ENVs need to be set before building or else build throws errors
ENV PUBLIC_KENER_FOLDER=/config/static \
MONITOR_YAML_PATH=/config/monitors.yaml \
SITE_YAML_PATH=/config/site.yaml
# build requires devDependencies which are not used by production deploy
# so build in a stage so we can copy results to clean "deploy" stage later
FROM base as build
# Set the timezone environment variable and the application environment
ARG KENER_BASE_PATH=
ENV TZ=Etc/UTC
ENV KENER_BASE_PATH=${KENER_BASE_PATH}
# Set the working directory
WORKDIR /app
COPY --chown=abc:abc . /app
# Copy package files and install dependencies
COPY package*.json ./
RUN npm install && npm cache clean --force
# build requires PUBLIC_KENER_FOLDER dir exists so create temporarily
# -- it is non-existent in final stage to allow proper startup and chown'ing/example population
RUN mkdir -p "${CONFIG_DIR}"/static \
&& npm install \
&& chown -R root:root node_modules \
&& npm run kener:build
# Copy the rest of the application code
COPY . .
FROM base as app
# Ensure /app/uploads and /app/database have rw permissions
RUN mkdir -p /app/uploads /app/database && \
chmod -R 777 /app/uploads /app/database
# copy package, required libs (npm,nodejs) results of build, prod entrypoint, and examples to be used to populate config dir
# to clean, new stage
COPY --chown=abc:abc package*.json ./
COPY --from=base /usr/local/bin /usr/local/bin
COPY --from=base /usr/local/lib /usr/local/lib
COPY --chown=abc:abc scripts /app/scripts
COPY --chown=abc:abc static /app/static
COPY --chown=abc:abc locales /app/locales
COPY --chown=abc:abc config /app/config
COPY --chown=abc:abc src/lib/helpers.js /app/src/lib/helpers.js
COPY --from=build --chown=abc:abc /app/build /app/build
COPY --from=build --chown=abc:abc /app/prod.js /app/prod.js
# Build the application
RUN npm run build
# Argument for the port
ARG PORT=3000
# Set the environment variable for the port
ENV PORT=$PORT
ENV NODE_ENV=production
# install prod depdendencies and clean cache
RUN npm install --omit=dev \
&& npm cache clean --force \
&& chown -R abc:abc node_modules
ARG webPort=3000
ENV PORT=$webPort
# Expose the application port
EXPOSE $PORT
# leave entrypoint blank!
# uses LSIO s6-init entrypoint with scripts
# that populate CONFIG_DIR with static dir, monitor/site.yaml when dir is empty
# and chown's all files so they are owned by proper user based on PUID/GUID env
# Set the command to run the application
CMD ["node", "main"]
+82 -63
View File
@@ -1,93 +1,112 @@
# Kener - A Beautiful Status Page System
<p align="center">
<img src="https://kener.ing/ss.png" width="100%" height="auto" alt="kener example illustration">
<img src="https://kener.ing/newbg.png?v=1" width="100%" height="auto" class="rounded-lg shadow-lg" alt="kener example illustration">
</p>
<p align="center">
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/rajnandan1/kener?label=Star%20Repo&style=social">
<a href="https://github.com/ivbeg/awesome-status-pages"><img src="https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg" alt="Awesome status page" /></a>
<a href="https://hub.docker.com/r/rajnandan1/kener"><img src="https://img.shields.io/docker/pulls/rajnandan1/kener" alt="Docker Kener" /></a>
</p>
#### 👉 Visit a live server [here](https://kener.ing)
#### 👉 Read the documentation [here](https://kener.ing/docs)
# Kener - Status Page System
Kener: Open-source Node.js status page tool, designed to make service monitoring and incident handling a breeze. It offers a sleek and user-friendly interface that simplifies tracking service outages and improves how we communicate during incidents. And the best part? Kener integrates seamlessly with GitHub, making incident management a team effort—making it easier for us to track and fix issues together in a collaborative and friendly environment.
It uses files to store the data. Other adapters are coming soon
<p align="center">
<picture>
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f514/512.webp" type="image/webp">
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f514/512.gif" alt="🔔" width="32" height="32">
</picture>
<picture>
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f680/512.webp" type="image/webp">
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f680/512.gif" alt="🚀" width="32" height="32">
</picture>
<picture>
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f6a7/512.webp" type="image/webp">
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f6a7/512.gif" alt="🚧" width="32" height="32">
</picture>
</p>
#### [👉 Visit a live server](https://kener.ing)
#### [👉 Quick Start](https://kener.ing/docs/quick-start)
#### [👉 Documentation](https://kener.ing/docs/home)
## What is Kener?
Kener is status page system built with Sveltekit and NodeJS. It does not try to replace the Datadogs and Atlassian of the world. It tries to help some who wants to come up with a status page that looks nice and minimum overhead, in a modern way.
It is carefully crafted to be easy to use and customize.
It comes with all the basic asks for a status page. It is open-source and free to use.
Kener name is derived from the word "Kene" which means "how is it going" in Assamese, then .ing because it was a cheaply available domain.
<div align="left">
<img alt="Visitor Stats" src="https://widgetbite.com/stats/rajnandan"/>
</div>
## Features
**Monitoring and Tracking:**
- Real-time monitoring
- Polls HTTP endpoint or Push data to monitor using Rest APIs
- Handles Timezones for visitors
- Categorize Monitors into different Sections
- Cron-based scheduling for monitors. Minimum per minute
- Flexible monitor configuration using YAML. Define your own parsing for monitor being UP/DOWN/DEGRADED
- Construct complex API Polls - Chain, Secrets etc
- Supports a Default Status for Monitors. Example defaultStatus=DOWN if you dont hit API per minute with Status UP
- Supports base path for hosting in k8s
- Pre-built docker image for easy deployment
Here are some of the features that you get out of the box. Please read the documentation to know how to use them.
**Customization and Branding:**
- Customizable status page using yaml or code
- Badge generation for status and uptime of Monitors
- Support for custom domains
- Embed Monitor as an iframe or widget
- Light + Dark Theme
- Internationalization support
### Monitoring and Tracking
**Incident Management:**
- Create Incidents using Github Issues - Rich Text
- Or use APIs to create Incidents
- Advanced application performance monitoring tools
- Real-time network monitor software capabilities
- Polls HTTP endpoint or Push data to monitor using Rest APIs
- Adjusts Timezones for visitors
- Categorize Monitors into different Sections
- Cron-based scheduling for monitors. Minimum per minute
- Construct complex API Polls - Chain, Secrets etc
- Supports a Default Status for Monitors
- Supports base path for hosting in k8s
- Pre-built docker image for easy deployment
- Automatically adjusts timezones for visitors
**User Experience and Design:**
- 100% Accessibility Score
- Easy installation and setup
- User-friendly interface
- Responsive design for various devices
- Auto SEO and Social Media ready
### Customization and Branding
- Customizable status page
- Badge generation for status and uptime of Monitors
- Support for custom domains
- Embed Monitor as an iframe or widget
- Light + Dark Theme
- Internationalization support
- Beautifully Crafted Status Page
### Incident Management
- Incident Management
- Incident Communication
- Comprehensive APIs for Incident Management
### User Experience and Design
- Good Accessibility Score
- Easy installation and setup
- User-friendly interface
- Responsive design for various devices
- Auto SEO and Social Media ready
- Server Side Rendering
## Technologies used
- [SvelteKit](https://kit.svelte.dev/)
- [shadcn-svelte](https://www.shadcn-svelte.com/)
## Inspired from
- [Upptime](https://upptime.js.org/)
- [SvelteKit](https://kit.svelte.dev/)
- [shadcn-svelte](https://www.shadcn-svelte.com/)
## Roadmap
## Support Me
- [x] Add api to create incident
- [x] Add docker file
- [ ] Add notification
- [ ] Add Mysql adapter
If you are using Kener and want to support me, you can do so by sponsoring me on GitHub or buying me a coffee.
## Screenshots
[Sponsor Me Using Github](https://github.com/sponsors/rajnandan1)
![image](static/marken_90.png)
![image](static/marken_api.png)
![image](static/marken_badge.png)
![image](static/marken_embed.png)
![image](static/marken_inci.png)
![image](static/marken_share.png)
![image](static/marken_td.png)
![image](static/marken_tl.png)
![image](static/marken_theme.png)
[Buy Me a Coffee](https://www.buymeacoffee.com/rajnandan1)
![image](https://badges.pufler.dev/visits/rajnandan1/kener)
## Support
## Contributing
<a href="https://stackexchange.com/users/3713933"><img src="https://stackexchange.com/users/flair/3713933.png" width="108" height="28" alt="profile for Raj Nandan Sharma on Stack Exchange, a network of free, community-driven Q&amp;A sites" title="profile for Raj Nandan Sharma on Stack Exchange, a network of free, community-driven Q&amp;A sites"></a>
<a href="https://www.buymeacoffee.com/rajnandan1"><img src="https://img.buymeacoffee.com/button-api/?text=Buy me a coffee&emoji=&slug=rajnandan1&button_colour=5F7FFF&font_colour=ffffff&font_family=Poppins&outline_colour=000000&coffee_colour=FFDD00" /></a>
<a href="https://www.paypal.com/paypalme/rajnandan1"><img style="height:90px;margin-left:-15px" src="static/paypal.png" /></a>
If you want to contribute to Kener, please read the [Contributing Guide](https://github.com/rajnandan1/kener/blob/main/.github/CONTRIBUTING.md).
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=rajnandan1/kener&type=Date)](https://star-history.com/#rajnandan1/kener&Date)
+12 -12
View File
@@ -1,13 +1,13 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app.postcss",
"baseColor": "slate"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils"
}
}
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app.postcss",
"baseColor": "slate"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils"
}
}
-26
View File
@@ -1,26 +0,0 @@
- name: Google Search
description: Search the world's information, including webpages, images, videos and more.
tag: "google-search"
image: "/google.png"
api:
method: GET
url: https://www.google.com/webhp
- name: Svelte Website
description: Cybernetically enhanced web apps
tag: "svelte-website"
api:
method: GET
url: https://svelte.dev/
image: "/svelte.svg"
- name: Earth
description: Our blue planet
tag: "earth"
defaultStatus: "UP"
image: "/earth.png"
- name: Frogment
description: A free openAPI spec editor and linter that breaks down your spec into fragments to make editing easier and more intuitive. Visit https://www.frogment.com
tag: "frogment"
image: "/frogment.png"
api:
method: GET
url: https://www.frogment.com
-43
View File
@@ -1,43 +0,0 @@
title: "Kener"
home: "/"
logo: "/logo.png"
github:
owner: "rajnandan1"
repo: "kener"
incidentSince: 48
metaTags:
description: "Kener: Open-source modern looking Node.js status page tool, designed to make service monitoring and incident handling a breeze. It offers a sleek and user-friendly interface that simplifies tracking service outages and improves how we communicate during incidents. And the best part? Kener integrates seamlessly with GitHub, making incident management a team effort—making it easier for us to track and fix issues together in a collaborative and friendly environment."
keywords: "Node.js status page, Incident management tool, Service monitoring, Service outage tracking, Real-time status updates, GitHub integration for incidents, Open-source status page, Node.js monitoring application, Service reliability, User-friendly incident management, Collaborative incident resolution, Seamless outage communication, Service disruption tracker, Real-time incident alerts, Node.js status reporting"
og:description: "Kener: Open-source Node.js status page tool, designed to make service monitoring and incident handling a breeze. It offers a sleek and user-friendly interface that simplifies tracking service outages and improves how we communicate during incidents. And the best part? Kener integrates seamlessly with GitHub, making incident management a team effort—making it easier for us to track and fix issues together in a collaborative and friendly environment."
og:image: "https://kener.ing/ss.png"
og:title: "Kener - Open-Source and Modern looking Node.js Status Page for Effortless Incident Management"
og:type: "website"
og:site_name: "Kener"
twitter:card: "summary_large_image"
twitter:site: "@_rajnandan_"
twitter:creator: "@_rajnandan_"
twitter:image: "https://kener.ing/ss.png"
twitter:title: "Kener: Open-Source and Modern looking Node.js Status Page for Effortless Incident Management"
twitter:description: "Kener: Open-source Node.js status page tool, designed to make service monitoring and incident handling a breeze. It offers a sleek and user-friendly interface that simplifies tracking service outages and improves how we communicate during incidents. And the best part? Kener integrates seamlessly with GitHub, making incident management a team effort—making it easier for us to track and fix issues together in a collaborative and friendly environment."
nav:
- name: "Documentation"
url: "/docs"
- name: "Github"
url: "https://github.com/rajnandan1/kener"
hero:
title: Kener is a Open-Source Status Page System
subtitle: Let your users know what's going on.
footerHTML: |
Made using
<a href="https://github.com/rajnandan1/kener" target="_blank" rel="noreferrer" class="font-medium underline underline-offset-4">
Kener
</a>
an open source status page system built with Svelte and TailwindCSS.
i18n:
defaultLocale: "en"
locales:
en: "English"
hi: "हिन्दी"
zh-CN: "中文"
ja: "日本語"
-1
View File
@@ -1 +0,0 @@
I am Empty File
+1
View File
@@ -0,0 +1 @@
database folder
-2
View File
@@ -1,2 +0,0 @@
import { Startup } from "./scripts/startup.js";
Startup();
+14 -19
View File
@@ -1,31 +1,26 @@
version: '3.7'
version: '3.8'
services:
kener:
image: rajnandan1/kener:latest # assuming this is final namespace/image
image: rajnandan1/kener:latest
container_name: kener
#env_file: .env #uncomment this, if you are using .env file
environment:
- TZ=Etc/GMT
#- GH_TOKEN=
#- API_TOKEN=
#- API_IP
- TZ=Etc/UTC
- NODE_ENV=production
#- PORT=3000
#- KENER_BASE_PATH=
#- RESEND_API_KEY=
#- ORIGIN=
#- KENER_SECRET_KEY=
#- RESEND_SENDER_EMAIL=
#- DATABASE_URL=
# If running on a LINUX HOST and not podman rootless these MUST BE SET
# run "id $user" from command line and replace numbers below with output from command
#- PUID=1000 # gid
#- PGID=1000 # uid
### Most likely DO NOT need to change anything below this ###
#- PORT=3000 Port app listens on IN CONTAINER
### If any of the below are changed make sure the bound volume is correct as well ###
#- CONFIG_DIR=/config
#- PUBLIC_KENER_FOLDER=/config/static
#- MONITOR_YAML_PATH=/config/monitors.yaml
#- SITE_YAML_PATH=/config/site.yaml
#- KENER_BASE_PATH=/status
ports:
- '3000:3000/tcp'
volumes:
- '/host/path/to/config:/config:rw'
- '.:/app/database:rw'
- '.:/app/uploads:rw'
@@ -25,8 +25,8 @@ if [ "$POPULATE_EXAMPLES" = true ]; then
echo "Directory is empty, adding defaults..."
mkdir -p "${CONFIG_DIR}"/static
cp -r /app/static/. "${CONFIG_DIR}"/static
mv /app/config/monitors.example.yaml "${CONFIG_DIR}"/monitors.yaml
mv /app/config/site.example.yaml "${CONFIG_DIR}"/site.yaml
cp /app/config/monitors.example.yaml "${CONFIG_DIR}"/monitors.yaml
cp /app/config/site.example.yaml "${CONFIG_DIR}"/site.yaml
fi
# permissions
@@ -3,5 +3,7 @@
echo -e "\nApp is starting!"
export NODE_ENV=production
cd /app || exit
# Run build first
exec \
s6-setuidgid abc /usr/bin/node $NODE_ARGS /app/prod.js
s6-setuidgid abc /usr/bin/node $NODE_ARGS /app/main.js
-1086
View File
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
---
title: Alert Config | Server.yaml | Kener
description: Alerts are the heart of Kener. This is where you define the alerts you want to show on your site.
---
# Add Alert Config
Use the `config/server.yaml` file to configure the alert settings.
## Alert Triggers
Kener supports multiple alerting mechanisms.
Currently, Kener supports the following alerting mechanisms:
- Webhook
- Discord
- Slack
We are adding more alerting mechanisms in the future.
### Configure Alerts
In the `server.yaml` file, you can add the alerting mechanisms under the `triggers` key. It accepts an array of objects.
```yaml
triggers:
- name: Awesome Webhook
type: "webhook"
url: "https://kener.requestcatcher.com/test"
method: "POST"
headers:
Authorization: Bearer $SOME_TOKEN_FROM_ENV
- name: My Discord Channel
type: "discord"
url: "https://discord.com/api/webhooks/your-webhook-url"
- name: Some Slack Channel
type: slack
url: https://hooks.slack.com/services/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
```
| Key | Description |
| ---- | ----------------------------------------------------------------------- |
| name | Name of the alerting mechanism. This will be used in the monitor config |
| type | Type of the alerting mechanism. Can be `webhook`, `discord`, `slack` |
| url | URL of the webhook or discord or slack channel |
There may be additional keys based on the type of alerting mechanism.
In webhook alerting, you can also add headers and method to the request.
```yaml
- name: Awesome Webhook
type: "webhook"
url: "https://kener.requestcatcher.com/test"
method: "POST"
headers:
Authorization: Bearer $SOME_TOKEN_FROM_ENV
```
### Webhook
Body of the webhook will be sent as below:
```json
{
"id": "mockoon-9",
"alert_name": "Mockoon DOWN",
"severity": "critical",
"status": "TRIGGERED",
"source": "Kener",
"timestamp": "2024-11-27T04:55:00.369Z",
"description": "🚨 **Service Alert**: Check the details below",
"details": {
"metric": "Mockoon",
"current_value": 1,
"threshold": 1
},
"actions": [
{
"text": "View Monitor",
"url": "https://kener.ing/monitor-mockoon"
}
]
}
```
| Key | Description |
| --------------------- | ----------------------------------------------------------- |
| id | Unique ID of the alert |
| alert_name | Name of the alert |
| severity | Severity of the alert. Can be `critical`, `warn` |
| status | Status of the alert. Can be `TRIGGERED`, `RESOLVED` |
| source | Source of the alert. Can be `Kener` |
| timestamp | Timestamp of the alert |
| description | Description of the alert. This you can customize. See below |
| details | Details of the alert. |
| details.metric | Name of the monitor |
| details.current_value | Current value of the monitor |
| details.threshold | Alert trigger hreshold of the monitor |
| actions | Actions to be taken. Link to view the monitor. |
### Discord
The discord message when alert is `TRIGGERED` will look like this
![Discord](/discord.png)
The discord message when alert is `RESOLVED` will look like this
![Discord](/discord_resolved.png)
### Slack
The slack message when alert is `TRIGGERED` will look like this
![Slack](/slack.png)
The slack message when alert is `RESOLVED` will look like this
![Slack](/slack_resolved.png)
### Add Alerts to Monitors
Once you have set up the triggers, you can add them to your monitors in the `config/monitors.yaml` file.
```yaml
- name: OkBookmarks
tag: "okbookmarks"
image: "https://okbookmarks.com/assets/img/extension_icon128.png"
api:
method: GET
url: https://okbookmarks.com
alerts:
DOWN:
failureThreshold: 1
successThreshold: 1
createIncident: true
description: "🚨 **Service Alert**. This is a custom message"
triggers:
- Awesome Webhook
- My Discord Channel
- Some Slack Channel
```
The `alerting` object lets you define the alerting mechanism for the monitor. It can do alerting for `DOWN` or `DEGRADED` status. You can add both or one of them.
- `failureThreshold`: Number of consecutive failures before alerting
- `successThreshold`: Number of consecutive successes before resolving the alert
- `createIncident`: If set to `true`, Kener will create an incident that will be shown on the status page
- `description`: Custom message for the alert
- `triggers`: Array of alerting triggers to send the alert to. The name should match the name in the `server.yaml` file.
<div class="rounded border px-4 py-0 ">
<p class="text-sm font-medium">
It will send alerts to the webhook, discord, and slack channels.
The alert will be set when the monitor goes down for 1 health check.
There will be one more alert when the monitor is up again after, 1 health check is successful.
</p>
</div>
+36
View File
@@ -0,0 +1,36 @@
---
title: View Alerts | Kener
description: Learn how to view alerts in kener.
---
# View Alerts
Alerts are used to notify you when a monitor goes down or up. You can view alerts in the Kener dashboard.
## ID
The unique ID of the alert.
## Type
The type of status that the alert is for. Example DOWN, DEGRADED
## Monitor
The monitor for which the alert is triggered.
## Status
The status of the alert. TRIGGERED, RESOLVED
## Checks
The number of checks that have been done.
## Incident
The incident that the alert is part of.
## Created At
The time when the alert was created.
+16
View File
@@ -0,0 +1,16 @@
---
title: API Keys | Kener
description: Learn how to set up and work with API keys in kener.
---
# API Keys
API keys can be used to call the [APIs of kener](/docs/kener-apis). You can create multiple API keys and use them to call the APIs.
API keys once generated cannot be viewed again so please keep them safe and secure.
## Create API Key
1. To create an API key click on the `Create API Key` button in the API Keys page.
2. Add a name for the key so that it is easy for you to remember
3. Click on Create.
+45
View File
@@ -0,0 +1,45 @@
---
title: Categorize Monitors Guide | Kener
description: Categorize Monitors in Kener
---
# Categorize Monitors
Let us add a category to our monitors.
## Sample monitors.yaml
```yaml
- name: OkBookmarks
description: A free bookmark manager that lets you save and search your bookmarks in the cloud.
tag: "okbookmarks"
image: "https://okbookmarks.com/assets/img/extension_icon128.png"
api:
method: GET
url: https://okbookmarks.com
- name: Earth
description: Our blue planet
tag: "earth"
default_status: "UP"
image: "/earth.png"
category: "Hello"
- name: Frogment
description: A free openAPI spec editor and linter that breaks down your spec into fragments to make editing easier and more intuitive. Visit https://www.frogment.com
tag: "frogment"
image: "/frogment.png"
api:
method: GET
url: https://www.frogment.com
```
## Sample site.yaml
```yaml
#...
categories:
- name: Hello
description: Say Hello to the world
#...
```
The above will have OkBookmarks and Frogment under home. Earth will be under Hello category.
+104
View File
@@ -0,0 +1,104 @@
---
title: Changelogs | Kener
description: Changelogs for Kener
---
# Changelogs
## v3.0.1
<picture>
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f680/512.webp" type="image/webp">
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f680/512.gif" alt="🚀" width="32" height="32">
</picture>
Here are the changes in this release
### Features
- Support for i18n in dates.
- Support of i18n in monitor embeds. Read more [here](/docs/embed#javascript-parameters)
## v3.0.0
<picture>
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f680/512.webp" type="image/webp">
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f680/512.gif" alt="🚀" width="32" height="32">
</picture>
Here are the changes in this release
### Features
- New APIs for creating incidents and pushing updates. Read more [here](/docs/kener-apis)
- Incident management is now part of the admin UI and removed from the config file.
- The UI colors have been updated to be more muted.
- Email Notifications for incidents using [resend](https://resend.com).
- New Kener management portal. No monitors.yaml or site.yaml needed anymore
- Login Page and Setup Page
- Remove Github dependency
- Options to disable square or dot pattern
- Support for new languages
- Multiple DB support (mysql, postgres, sqlite3)
- New API reference
- New documentation site
## v2.0.0
<picture>
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f680/512.webp" type="image/webp">
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f680/512.gif" alt="🚀" width="32" height="32">
</picture>
Here are the changes in this release
### Features
- Added support for sqlite3 and removed dependency on file system
- Added support for postgres database. Read more [here](/docs/database)
- Added support for alerting. Read more [here](/docs/alerting)
- Added color customization. Read more [here](/docs/customize-site#color)
- Added three new customizations for home page. Read more [here](/docs/customize-site#barstyle)
- `barStyle`
- `barRoundness`
- `summaryStyle`
### Migration
Kener will automatically migrate your data from file system to sqlite3. If you are using a custom domain, you need to update the `site.yaml` file with the new `siteURL` field. Read more [here](/docs/customize-site#siteURL)
## v0.0.16
<picture>
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f680/512.webp" type="image/webp">
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f680/512.gif" alt="🚀" width="32" height="32">
</picture>
Here are the changes in this release
### Features
- Added support for `hideURLForGet` in monitors. Read more [here](/docs/monitors)
- New SVG badges for LIVE status. Read more [here](/docs/status-badges#live)
- `[Breaking Change]` Removed dependency on Environment variable `PUBLIC_KENER_FOLDER`. Read more [here](#v0-0-16-migration)
- Simplified build and deploy process
- Added support for fonts. Read more [here](/docs/customize-site#font)
- Added support for home page pattern. Read more [here](/docs/customize-site#pattern)
- Added support for adding your analytics provider. Read more [here](/docs/site-analytics)
- New Documentation Site
- Addes support for `sqaures` pattern in home page. Read more [here](/docs/customize-site#pattern)
- Redesigned the UI for better consistency
- Embed now supports background color using a parameter `bgc`. Read more [here](/docs/embed#javascript-parameters)
- Now title in `site.yaml` is `<title>` and `siteName` is actually the name of the site. Read more [here](/docs/customize-site#siteName)
### Migration
#### Source
- Move data from `PUBLIC_KENER_FOLDER` to `/database` file.
- Move `site.yaml` to `/config` folder
- Move `monitors.yaml` to `/config` folder
#### Docker
- Use `-v $(pwd)/database:/app/database` and `-v $(pwd)/config:/app/config` in your docker run command
+116
View File
@@ -0,0 +1,116 @@
---
title: Concepts | Kener
description: Concepts for Kener
---
# Concepts
Here are the different concepts that Kener uses to make your life easier.
## Monitors
Monitors are the heart of Kener. They are the ones that keep an eye on your services and let you know when something goes wrong. Monitors can be of different types like HTTP, DNS, Ping etc. They can be configured to check your services at different intervals and alert you when something goes wrong.
### HTTP Monitor
HTTP Monitor is used to check the status of a website or any http api. It can be configured to check the status of the website at different intervals.
It can also be configured to check for a specific status code or a specific response.
You can also add secrets to the HTTP request using environment variables.
### DNS Monitor
DNS Monitor is used to check the status of a DNS server. It can be configured to check the status of the DNS server at different intervals.
### Ping Monitor
Ping Monitor is used to check the status of a server. It can be configured to check the status of the server at different intervals.
### Data Interpolation
Kener interpolates the data for missing intervals. For example, suppose if you have a cron monitor of 5 minutes and it has run twice at 10AM and 10:05AM and the monitor is `DOWN` at 10AM. Kener will interpolate the data for 10:01AM, 10:02AM, 10:03AM and 10:04AM as `DOWN`. Similarly, if the monitor is UP at 10:05AM, Kener will interpolate the data for 10:06AM, 10:07AM, 10:08AM and 10:09AM as UP. Similarly for missing data points it will consider the previous data point. If there is no previous data point, it will consider it as `UP`.
---
## Triggers
Triggers are the ones that are responsible for sending alerts when something goes wrong. They can be configured to send alerts to different channels like Email, Slack, Discord, Webhook etc.
You can set multiple triggers of the same type.
### Email
Email Trigger is used to send an email when something goes wrong. It can be configured to send an email to different email addresses.
Kener uses [resend.com](https://resend.com) to send emails. Please make sure to sign up in [resend.com](https://resend.com) and get the API key. You will need to set the API key in the environment variable `RESEND_API_KEY`. Learn more about environment variables [here](/docs/environment-vars).
### Slack
Slack Trigger is used to send a message to a slack channel when something goes wrong. It can be configured to send a message to different slack channels. Needs slack URL
### Discord
Discord Trigger is used to send a message to a discord channel when something goes wrong. It can be configured to send a message to different discord channels. Needs discord URL
### Webhook
Webhook Trigger is used to send a message to a webhook when something goes wrong. It can be configured to send a message to different webhooks. Needs webhook URL. Method will be POST.
Body of the webhook will be sent as below:
```json
{
"id": "mockoon-9",
"alert_name": "Mockoon DOWN",
"severity": "critical",
"status": "TRIGGERED",
"source": "Kener",
"timestamp": "2024-11-27T04:55:00.369Z",
"description": "🚨 **Service Alert**: Check the details below",
"details": {
"metric": "Mockoon",
"current_value": 1,
"threshold": 1
},
"actions": [
{
"text": "View Monitor",
"url": "https://kener.ing/monitor-mockoon"
}
]
}
```
| Key | Description |
| --------------------- | ----------------------------------------------------------- |
| id | Unique ID of the alert |
| alert_name | Name of the alert |
| severity | Severity of the alert. Can be `critical`, `warn` |
| status | Status of the alert. Can be `TRIGGERED`, `RESOLVED` |
| source | Source of the alert. Can be `Kener` |
| timestamp | Timestamp of the alert |
| description | Description of the alert. This you can customize. See below |
| details | Details of the alert. |
| details.metric | Name of the monitor |
| details.current_value | Current value of the monitor |
| details.threshold | Alert trigger hreshold of the monitor |
| actions | Actions to be taken. Link to view the monitor. |
---
## Incidents
Incidents are the ones that are created when something goes wrong. They can be created using Manage portal or using APIs.
There can be two types of incidents - `DOWN` and `DEGRADED`. Incident can have a title, description, severity and status. Status can be `INVESTIGATING`, `IDENTIFIED`, `MONITORING` and `RESOLVED`.
To learn more about incidents, click [here](/docs/incident-management).
### Incident Updates
Incident updates are done using comment. Each comment has a status and a timestamp. The status of the latest added comment becomes the current status of the incident.
### Incident Monitors
Monitors can be added as affected components for an incident. Depending on the severity of the incident the status of the monitor also changes.
+42
View File
@@ -0,0 +1,42 @@
---
title: Custom JS and CSS Guide | Kener
description: Custom JS and CSS Guide for Kener
---
Here is a guide to add custom JS and CSS to your Kener instance.
## Adding Custom JS
Add your custom JS to `static/` file. And in the `src/app.html` file, add the following line:
```html
<script src="/your-custom-js-file.js"></script>
```
## Adding Custom CSS
Three are two ways you can add custom CSS to your Kener instance.
### CSS file
Add your custom CSS to `static/` file. And in the `src/app.html` file, add the following line:
```html
<link rel="stylesheet" href="/your-custom-css-file.css" />
```
Do not forget to add the base path if you are using a subpath. For example, if you are using a subpath `/kener`, then the path should be `/kener/your-custom-js-file.js`.
### Inline CSS
To add inline css go to Manage kener -> Theme -> Custom CSS and add your CSS there.
```css
.my-class {
color: red;
}
```
<div class="note danger">
Do not include &#x3C;style&#x3E; tags.
</div>
+391
View File
@@ -0,0 +1,391 @@
---
title: Customize Site - Site.yaml - Kener
description: Customize your Kener site using site.yaml
---
# Customize Site
There is a folder called `./config`. Inside which there is a `site.yaml` file. You can modify this file to have your own branding and do few other things.
## Sample site.yaml
```yaml
title: "Kener"
siteName: "Kener.ing"
logo: "/logo.svg"
favicon: "/logo96.png"
home: "/"
theme: dark
themeToggle: true
github:
owner: "rajnandan1"
repo: "kener"
incidentSince: 72
metaTags:
description: "Your description"
keywords: "keyword1, keyword2"
nav:
- name: "Documentation"
url: "/docs"
- name: "Github"
iconURL: "/github.svg"
url: "https://github.com/rajnandan1/kener"
siteURL: https://kener.ing
hero:
title: Kener is a Open-Source Status Page System
subtitle: Let your users know what's going on.
footerHTML: |
Made using
<a href="https://github.com/rajnandan1/kener" target="_blank" rel="noreferrer" class="font-medium underline underline-offset-4">
Kener
</a>
an open source status page system built with Svelte and TailwindCSS.
i18n:
defaultLocale: "en"
locales:
en: "English"
hi: "हिन्दी"
zh-CN: "中文"
ja: "日本語"
vi: "Tiếng Việt"
pattern: "squares"
font:
cssSrc: "https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap"
family: '"Lato", sans-serif'
analytics:
- id: "G-QsFT"
type: "GA"
- id: "deasf0d350"
type: "AMPLITUDE"
- id: "FKOdsKener"
type: "MIXPANEL"
```
---
## title
This translates to
```html
<title>Your Title</title>
```
---
## siteName
This is the name that will be shown in the nav bar, top left corner of the page
## logo
URL of the logo that will be shown in the nav bar. You can also add your logo in the `static` folder. Use the path `/logo.svg` to refer to it if you have added `logo.svg` in the `static` folder. Otherwise, you can use any URL.
## favicon
URL of the favicon that will be shown in the browser tab. You can also add your favicon in the `static` folder. Use the path `/logo96.png` to refer to it if you have added `logo96.png` in the `static` folder. Otherwise, you can use any URL.
## home
This is the location where someone will be taken when they click on the site name in the nav bar
## theme
This is the default theme of the site that will be used when a user lands for the first time. It can be `light` or `dark` or `system` or `none`. Defaults to `system`. The user still gets the option to change the theme.
- setting it to `light` will always set the theme to light when page loads
- setting it to `dark` will always set the theme to dark when page loads
- setting it to `system` will set the theme based on the system preference
- setting it to `none` will set the theme to last selected theme by the user
## themeToggle
This is the option to show the theme toggle. It can be `true` or `false`. Defaults to `true`.
As of now there is no option to change the colors of the theme using `site.yaml`. If you still want to change the colors you can do so by modifying the `src/app.postcss` file.
## favicon
It can be set by modifying the `<head>` tag in `src/app.html` file.
Example add a png called `logo.png` file in `static/` and then
```html
...
<link rel="icon" href="/logo.png" />
...
```
---
## github
For incident kener uses github issues. Create an empty [github](https://github.com) repo and add them to `site.yaml`
```yaml
github:
apiURL: "https://api.github.com"
owner: "username"
repo: "repository"
incidentSince: 720
```
### apiURL
API URL of the github. Default is `https://api.github.com`. If you are using github enterprise then you can change it to your github enterprise api url.
### owner
Owner of the github repository. If the repository is `https://github.com/rajnandan1/kener` then the owner is `rajnandan1`
### repo
Repository name of the github repository. If the repository is `https://github.com/rajnandan1/kener` then the repo is `kener`
### incidentSince
`incidentSince` is in hours. It means if an issue is created before X hours then kener would not honor it. What it means is that kener would not show it active incident pages nor it will update the uptime. Default is 30\*24 hours = 720 hours.
To read how to set up github for kener [click here](/docs/gh-setup)
To see how to create an issue [click here](/docs/incident-management)
---
## metaTags
Meta tags are nothing but html `<meta>`. You can use them for SEO purposes
```yaml
metaTags:
description: "Your description"
keywords: "keyword1, keyword2"
og:image: "https://example.com/og.png"
```
will become
```html
<head>
<meta name="description" content="Your description" />
<meta name="keywords" content="keyword1, keyword2" />
<meta name="og:image" content="https://example.com/og.png" />
</head>
```
---
## siteURL
You can set this to generate SiteMaps
```yaml
siteURL: https://kener.ing
```
Sitemaps urls will be `https://kener.ing/sitemap.xml`
---
## hero
Use hero to add a banner to your kener page
```yaml
hero:
title: Kener is a Open-Source Status Page System
subtitle: Let your users know what's going on.
```
### title
Title of the hero section
### subtitle
Subtitle of the hero section
---
## nav
You can add more links to your navbar.
```yaml
nav:
- name: "Home"
url: "/home"
```
### name
Name of the link
### url
URL of the link
### iconURL
Icon of the link. You can add an icon in the `static` folder and refer to it using the path `/github.svg`
---
## categories
You can define categories for your monitors. Each category can have a description. The monitors can be grouped by categories.
`name=home` will be shown in the home page. Categories are shown in the order they are defined in the yaml file. A dropdown will appear in the nav bar to select the category.
```yaml
categories:
- name: API
description: "Kener provides a simple API for you to use to update your status page."
- name: home
description: "loroem ipsum lorem ipsum"
```
### name
Name of the category
### description
Description of the category
---
## footerHTML
You can add HTML to the footer. You can add links to your social media or anything else.
```yaml
footerHTML: |
Made using <a href="https://kener.ing" target="_blank" rel="noreferrer" class="font-medium underline underline-offset-4">Kener</a> an open source status page system built with Svelte and TailwindCSS.
```
---
## i18n
You can add translations to your site. By default it is set to `en`. Available translations are present in `locales/` folders in the root directory. You can add more translations by adding a new file in the `locales` folder.
### Enable
Once you have added a new translation file in the `locales` folder, you can enable it by adding the locale code in the `site.yaml` file.
Let us say you have added a `hi.json` file in the `locales` folder. You can enable it by adding the following to the `site.yaml` file.
```yaml
i18n:
defaultLocale: en
locales:
en: English
hi: हिन्दी
```
### defaultLocale
**_defaultLocale_**: The default locale to be used for a user when he or she visits for the first time. It is important to note that the default locale json file should be present in the locales folder.
### locales
**_locales_**: A list of locales that you want to enable. The key is the locale code and the value is the name of the language. The locale code should be the same as the json file name in the locales folder. `en` means `en.json` should be present in the locales folder.
Adding more than one locales will enable a dropdown in the navbar to select the language.
Selected languages are stored in cookies and will be used when the user visits the site again.
There is no auto detection of the language. The user has to manually select the language.
### Variables
There are few variables that you you should not change,
- %hours : This will be replaced by the hours
- %minutes : This will be replaced by the minutes
- %minute : This will be replaced by the minute
- %status : This will be replaced by the status
---
## pattern
You can set the background pattern of the site. It can be `squares`, `dots`, `none`
---
## font
You can set the font of the site. You can use google fonts or any other font.
```yaml
font:
cssSrc: "https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap"
family: '"Lato", sans-serif'
```
### cssSrc
URL of the font css
### family
Font family
---
## analytics
You can add analytics to your site. You can add multiple analytics. Supported analytics are `GA`, `AMPLITUDE`, `MIXPANEL`
```yaml
analytics:
- id: "G-QsFT"
type: "GA"
- id: "deasf0d350"
type: "AMPLITUDE"
- id: "FKOdsKener"
type: "MIXPANEL"
```
## barStyle
Kener shows a bar for a day. By default if any downtime or degradation is there the bar will be red or yellow respectively.
If you want to only show a part of the bar as red or yellow then you can set the `barStyle` to `PARTIAL`. Default is `FULL`
```yaml
barStyle: PARTIAL
```
---
## barRoundness
You can set the roundness of the bar. It can be `SHARP` or `ROUNDED`. Default is `ROUNDED`
```yaml
barRoundness: ROUNDED
```
---
## summaryStyle
The summary of the monitor today is shown in the monitor page. By default it shows the the whole day summary. If you want to show the current status then you can set the `summaryStyle` to `CURRENT`. Default is `DAY`.
```yaml
summaryStyle: CURRENT
```
---
## colors
You can set the colors of UP, DOWN, DEGRADED
```yaml
colors:
UP: "#4ead94"
DOWN: "#ca3038"
DEGRADED: "#e6ca61"
```
+58
View File
@@ -0,0 +1,58 @@
---
title: Database Config - Server.yaml - Kener
description: Add database configuration to your kener server.yaml
---
# Databases
Kener uses relational database to store its data.
## Supported Database
- Sqlite (default)
- Postgres
- Mysql
We will be adding more database support in the future.
## Sqlite
Sqlite is the default database for Kener. You don't need to do anything to use it. The database file will be created in the `database` folder which is present in the root of the project.
The name of the default database file is `kener.db`. The path will be `database/kener.sqlite.db`.
You can change the database file name by changing the environment variable `DATABASE_URL`. Prefix the connection string with `sqlite://` and add the path to the database file.
```bash
export DATABASE_URL=sqlite://./database/awesomeKener.db
```
In this case, the database file will be created in the `database` folder with the name `awesomeKener.db`.
Make sure the `database` folder is writable by the Kener process.
## Postgres
To use Postgres, you need to update the connection string details in the `DATABASE_URL` environment variable. The connection string has to with `postgresql`
```bash
export DATABASE_URL=postgresql://myuser:mypassword@your.host:5432/kenerdb
```
## Mysql
To use Mysql, you need to update the connection string details in the `DATABASE_URL` environment variable. The connection string has to with `mysql`
```bash
export DATABASE_URL=mysql://root:password@your.host:3306/kenerdb
```
## Migrations
To run migrations to latest, run
```bash
npm run migrate
```
When kener runs it will always migrate up to the latest version
+136
View File
@@ -0,0 +1,136 @@
---
title: Kener Deployment - From Source or Docker
description: Kener can be deployed in multiple ways. You can use the pre-built docker image or build from source.
---
# Deployment
Kener can be deployed in multiple ways. You can use the pre-built docker image or build from source.
## Prerequisites
Make sure you have the following installed:
- [Node.js 20 or above](https://nodejs.org/en/download/)
- [npm](https://www.npmjs.com/get-npm)
- [Sqlite3](https://www.sqlite.org/download.html)
- Make sure `./database` directory is present in the root directory
- [Set up Environment Variables](/docs/environment-vars). You can use a `.env` file or pass them as arguments. Be sure to add `NODE_ENV=production` for production deployment
## NPM
```bash
npm i
npm run build #build the frontend
npm run start
```
## PM2
```bash
npm i
npm run build #build the frontend
pm2 start main.js
```
## Docker
[Dockerhub](https://hub.docker.com/r/rajnandan1/kener)
```bash
docker.io/rajnandan1/kener:latest
```
[Github Packages](https://github.com/rajnandan1/kener/pkgs/container/kener)
```bash
ghcr.io/rajnandan1/kener:latest
```
### Volumes
- `$(pwd)/uploads` You should mount one host directories to persist your image uploads.
- `$(pwd)/database` If you are using sqlite, you should mount one host directory to persist your database.
### Environment Variables
[Environment variables](/docs/environment-vars) can be passed with `-e` An example `docker run` command:
Make sure `./database` directory is present in the root directory.
### Examples
This example is for sqlite. You can also use postgres. Read more about it [here](/docs/environment-vars#database-url)
#### sqlite
```bash
mkdir database
docker run \
-v $(pwd)/database:/app/database \
-v $(pwd)/uploads:/app/uploads \
-p 3000:3000 \
-e "KENER_SECRET_KEY=somesecretkey" \
-e "ORIGIN=http://localhost:3000" \
rajnandan1/kener
```
#### .env
You can also use a .env file
```bash
docker run \
-v $(pwd)/database:/app/database \
-v $(pwd)/uploads:/app/uploads \
--env-file .env \
-p 3000:3000 \
rajnandan1/kener
```
Or use **Docker Compose** with the example [docker-compose.yaml](https://raw.githubusercontent.com/rajnandan1/kener/main/docker-compose.yml)
#### Postgres
```bash
docker run \
-p 3000:3000 \
-v $(pwd)/uploads:/app/uploads \
-e "KENER_SECRET_KEY=somesecretkey" \
-e "DATABASE_URL=postgresql://myuser:mypassword@localhost:5432/mydatabase" \
-e "ORIGIN=http://localhost:3000" \
rajnandan1/kener
```
#### MySQL
```bash
docker run \
-p 3000:3000 \
-v $(pwd)/uploads:/app/uploads \
-e "KENER_SECRET_KEY=somesecretkey" \
-e "DATABASE_URL=mysql://root:password@mysql-container-2.orb.local:3306/kener-2" \
-e "ORIGIN=http://localhost:3000" \
rajnandan1/kener
```
#### Base path
By default kener runs on `/` but you can change it to `/status` or any other path. Read more about it [here](/docs/environment-vars/#kener-base-path).
<div class="note info">
- Important: The base path should _**NOT**_ have a trailing slash and should start with `/`
- Important: This env variable should be present during both build and run time
</div>
Let us say you are running kener on a subpath `/status`. You can set the base path like this:
```bash
docker build --build-arg KENER_BASE_PATH=/status -t kener .
```
```bash
docker run -p 3000:3000 --env-file .env -v $(pwd)/database:/app/uploads -v $(pwd)/database:/app/database kener
```
+89
View File
@@ -0,0 +1,89 @@
---
title: Embed Monitor | Kener
description: Embed your monitor in your website
---
# Embed Monitor
There are two ways to embed your monitor in your website
## Javascript
You can embed your monitor in your website using javascript. We recommend using this method as it takes care of the height of the embedded monitor.
```html
<script
async
src="http://[hostname]/embed/monitor-[tag]/js?theme=light&monitor=http://[hostname]/embed/monitor-[tag]"
></script>
```
Here is an example
```html
<script
async
src="https://kener.ing/embed/monitor-earth/js?theme=light&monitor=https://kener.ing/embed/monitor-earth"
></script>
```
### Parameters
You can pass the following parameters to the embed code
- `theme`: You can pass `light` or `dark` theme
- `monitor`: The monitor url
- `bgc`: Background color of the monitor. Only supports hex color codes. DO NOT include the `#` symbol. Example: `ff0000`
- `locale`: The locale of the monitor. You can pass the code of the locale you have enabled in your kener settings. Example: `en`, `fr` etc
Replace `[hostname]` with your kener hostname and `[tag]` with your monitor tag.
### Demo
<div class="border mx-auto rounded-sm w-585px">
<script async src="/embed/monitor-earth/js?theme=dark&monitor=/embed/monitor-earth"></script>
</div>
## Iframe
This is the simplest way to embed your monitor in your website. You can use the following code to embed your monitor in your website.
```html
<iframe
src="http://[hostname]/embed/monitor-[tag]?theme=light"
width="100%"
height="200"
allowfullscreen="allowfullscreen"
allowpaymentrequest
frameborder="0"
></iframe>
```
Here is an example
```html
<iframe
src="https://kener.ing/embed/monitor-earth?theme=light"
width="100%"
height="100"
allowfullscreen="allowfullscreen"
allowpaymentrequest
frameborder="0"
></iframe>
```
Replace `[hostname]` with your kener hostname and `[tag]` with your monitor tag.
### Parameters
You can pass the following parameters to the embed code
- `theme`: You can pass `light` or `dark` theme
- `bgc`: Background color of the monitor. Only supports hex color codes. DO NOT include the `#` symbol. Example: `ff0000`
- `locale`: The locale of the monitor. You can pass the code of the locale you have enabled in your kener settings. Example: `en`, `fr` etc
### Demo
<div class="border mx-auto rounded-sm w-585px">
<iframe src="/embed/monitor-earth?theme=dark" width="100%" height="100" allowfullscreen="allowfullscreen" allowpaymentrequest frameborder="0"></iframe>
</div>
+116
View File
@@ -0,0 +1,116 @@
---
title: Environment Variables | Kener
description: Kener needs some environment variables to be set to run properly. Here are the list of environment variables that you need to set.
---
# Environment Variables
Kener needs some environment variables to be set to run properly. Here are the list of environment variables that you need to set.
All of these are optional but are required for specific features.
## KENER_SECRET_KEY [Required]
Please set a strong secret key for Kener to use for encrypting the data. This is required to run Kener.
```bash
export KENER_SECRET_KEY=a-strong-secret-key
```
## ORIGIN [Required]
Set this to the origin of your website(protocl + hostname + port if there). This is required for CORS.
```bash
export ORIGIN=http://localhost:3000
```
## PORT
Defaults to 3000 if not specified
```bash
export PORT=4242
```
## GH_TOKEN
A github token to read issues and create labels. This is required for **incident management**
```bash
export GH_TOKEN=your-github-token
```
## KENER_BASE_PATH
By default kener runs on `/` but you can change it to `/status` or any other path.
- Important: The base path should _**NOT**_ have a trailing slash and should start with `/`
- Important: This env variable should be present during both build and run time
```bash
export KENER_BASE_PATH=/status
```
## RESEND_API_KEY
Kener uses [resend.com](https://resend.com) to send emails. Please make sure to sign up in [resend.com](https://resend.com) and get the API key. You will need to set the API key in the environment variable `RESEND_API_KEY`.
```bash
export RESEND_API_KEY=re_sometoken
```
## RESEND_SENDER_EMAIL
Set the sender email for the emails that are sent by Kener. This is required for sending emails. If you have not added a domain in resend, you can se something like `Some Name <onboarding@resend.dev>`. We recommend adding a domain in resend and using that email domain.
```bash
export RESEND_SENDER_EMAIL=Some Name <email@domain.com>
```
<div class=" note danger ">
Please note that the RESEND_API_KEY is required for sending emails. If you do not set this, Kener will not be able to send emails. RESEND_SENDER_EMAIL is a must if you forget your password.
</div>
## DATABASE_URL
Kener uses a database to store its data. By default, Kener uses sqlite. You can change the database by setting the `DATABASE_URL` environment variable. The connection string has to start with `sqlite`, `postgresql`, or `mysql`. Read more about [database configuration](/docs/database).
```bash
export DATABASE_URL=sqlite://./database/awesomeKener.db
```
## TZ
Set the timezone for the server. Set it to UTC.
```bash
export TZ=UTC
```
## Using .env
You can also use a `.env` file to set these variables. Create a `.env` file in the root of the project and add the variables like below
```bash
KENER_SECRET_KEY=please_change_me
PORT=4242
GH_TOKEN=your-github-token
RESEND_API_KEY=re_sometoken
KENER_BASE_PATH=/status
ORIGIN=http://localhost:3000
```
## Secrets
Kener supports secrets in monitors. Let us say you have a monitor that is API based and you want to keep the API key secret.
Example: `https://api.example.com/users` with a header `Authorization: Bearer $CLIENT_SECRET`
You should set the `CLIENT_SECRET` in the your environment variables.
```bash
export CLIENT_SECRET=your-api-key
```
Remember to set the `CLIENT_SECRET` in your `.env` file if you are using one.
+62
View File
@@ -0,0 +1,62 @@
---
title: Github Setup | Kener
description: Kener uses github for incident management. Issues created in github using certain tags go to kener as incidents.
---
# Github Setup
Kener uses github for incident management. Issues created in github using certain tags go to kener as incidents.
## Step 1: Create Github Repository
Create a [Github Repository](https://github.com/new). It can be either public or private.
## Step 2: Create Github Token
You can create either a classic token or personal access token
### Creating Classic Token
- Go to [Tokens](https://github.com/settings/tokens/new)
- Note: kener
- Expiration: No Expiration
- Scopes: write:packages
- Click on generate Token
### Creating Personal Access Token
- Go to [Personal Access Token](https://github.com/settings/personal-access-tokens/new)
- Token Name: kener
- Expiration: Use custom to select a calendar date
- Description: My Kener
- Repository access: Check Only Selected Repositories. Select your github repository
- Repository Permission: Select Issues Read Write
- Click on generate token
## Step 3: Set environment
```bash
export GH_TOKEN=github_pat_11AD3ZA3Y0
```
## Step 4: Add to Kener
Add your repository details to kener.
![Monitors API](/gh_s.png)
### Github API URL
If you are on github enterprise you can set the github api url. For most users, it will be `https://api.github.com`
### Github Repo
The repository name you created in step 1
### Github Username
Your github username
### Incident History
It is in hours. It means if an issue is created before X hours then kener would not honor it. What it means, is that kener would not show it under active incidents nor it will update the uptime. Default is 30\*24 hours = 720 hours.
+74
View File
@@ -0,0 +1,74 @@
---
title: Home Page Setup | Kener
description: Learn how to set up and work with the home page in kener.
---
# Home Page
Home page is the first page that the user sees when they visit your status page. You can customize the home page to show the information that you want to show.
---
## Hero Section
### Title
A big text that will be shown on the hero section.
### Subtitle
A small text that will be shown below the title.
<div class="border rounded-md">
![Hero Section](/home_1.png)
</div>
---
## Navigation
You can navigation links to other urls. You can add as many as you want.
![Nav Section](/home_2.png)
### Icon
A small image to be shown along with the link. Icon is optional
### Title
The title of the link.
### URL
The URL to redirect to when the link is clicked.
![Nav Section](/home_3.png)
---
## Internationalization
Add multiple languages to your status page. You can add a new language and add translations for all the text that is shown on the status page by following the steps [here](/docs/i18n).
- Adding more than one locales will enable a dropdown in the navbar to select the language.
- Selected languages are stored in cookies and will be used when the user visits the site again.
- There is no auto detection of the language. The user has to manually select the language.
### Default Language
The default language of the status page.
---
## Site Categories
Use this to group monitors. Byh default a readonly home category is created. You can add more categories and move monitors to those categories.
---
## Site Footer
Add footer text or html to the status page. You can add any text that you want to show in the footer. Make sure if you are adding html it is safe and valid.
+115
View File
@@ -0,0 +1,115 @@
---
title: Kener - A Sveltekit NodeJS Status Page System
description: Kener is an open-source Node.js status page tool, designed to make service monitoring and incident handling a breeze. It offers a sleek and user-friendly interface that simplifies tracking service outages and improves how we communicate during incidents.
---
# Kener - A Feature Rich & Modern Status Page
<p align="center">
<img src="/newbg.png" width="100%" height="auto" class="rounded-lg shadow-lg" alt="kener example illustration">
</p>
<p class="flex space-x-2 justify-center">
<a href="https://github.com/rajnandan1/kener/stargazers" >
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/rajnandan1/kener?label=Star%20Repo&
style=social">
</a>
<a href="https://github.com/ivbeg/awesome-status-pages" >
<img src="https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg" alt="Awesome status page" />
</a>
<a href="https://hub.docker.com/r/rajnandan1/kener" >
<img src="https://img.shields.io/docker/pulls/rajnandan1/kener" alt="Docker Kener" />
</a>
</p>
<div class="flex gap-4 justify-center">
<picture>
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f38a/512.webp" type="image/webp">
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f38a/512.gif" alt="🎊" width="32" height="32">
</picture>
<picture>
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f514/512.webp" type="image/webp">
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f514/512.gif" alt="🔔" width="32" height="32">
</picture>
<picture>
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/2049_fe0f/512.webp" type="image/webp">
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/2049_fe0f/512.gif" alt="⁉" width="32" height="32">
</picture>
</div>
<div class="flex gap-2 kener-home-links">
<div class="flex-1 border rounded-md py-4 px-2 text-center">
<a href="https://kener.ing">Live Demo</a>
</div>
<div class="flex-1 border rounded-md py-4 px-2 text-center">
<a href="https://kener.ing/docs/quick-start">Quick Start</a>
</div>
<div class="flex-1 border rounded-md py-4 px-2 text-center">
<a href="https://github.com/rajnandan1/kener">Clone</a>
</div>
<div class="flex-1 border rounded-md py-4 px-2 text-center">
<a href="https://kener.ing/docs/deployment">Deploy</a>
</div>
<div class="flex-1 border rounded-md py-4 px-2 text-center">
<a href="https://kener.ing/docs/kener-apis">APIs</a>
</div>
</div>
## What is Kener?
Kener is status page system built with Sveltekit and NodeJS. It does not try to replace the Datadogs and Atlassian of the world. It tries to help some who wants to come up with a status page that looks nice and minimum overhead, in a modern way.
It comes with all the basic asks for a status page. It is open-source and free to use.
Kener name is derived from the word "Kene" which means "how is it going" in Assamese, then .ing because it was a cheaply available domain.
## Features
### Monitoring and Tracking
- Advanced application performance monitoring tools
- Real-time network monitor software capabilities
- Polls HTTP endpoint or Push data to monitor using Rest APIs
- Handles Timezones for visitors
- Categorize Monitors into different Sections
- Cron-based scheduling for monitors. Minimum per minute
- Construct complex API Polls - Chain, Secrets etc
- Supports a Default Status for Monitors
- Supports base path for hosting in k8s
- Pre-built docker image for easy deployment
### Customization and Branding
- Customizable status page
- Badge generation for status and uptime of Monitors
- Support for custom domains
- Embed Monitor as an iframe or widget
- Light + Dark Theme
- Internationalization support
- Beautifully Crafted Status Page
### Incident Management
- Incident Management
- Incident Communication
- Comprehensive APIs for Incident Management
### User Experience and Design
- 100% Accessibility Score
- Easy installation and setup
- User-friendly interface
- Responsive design for various devices
- Auto SEO and Social Media ready
- Server Side Rendering
## Technologies used
- [SvelteKit](https://kit.svelte.dev/)
- [shadcn-svelte](https://www.shadcn-svelte.com/)
## Support Me
If you are using Kener and want to support me, you can do so by sponsoring me on GitHub or buying me a coffee.
[Sponsor Me Using Github](https://github.com/sponsors/rajnandan1)
[Buy Me a Coffee](https://www.buymeacoffee.com/rajnandan1)
+38
View File
@@ -0,0 +1,38 @@
---
title: How it works | Kener
description: Folder structure and how Kener works
---
# How it works
Kener has two parts.
- Sveltekit application which is server rendered and is the frontend. It is running on Svelte 4.
- Hooks into the backend to get the data for your monitors
## Folder structure
```bash
├── src (svelte frontend files)
├── static (things put here can be referenced directly example static/logo.png -> /logo.png)
├── src
├──── routes
├────── (docs) (You can delete this, this has routes for documentation)
├──── lib
├────── server
├──────── config (Location for you site.yaml and monitos.yaml)
├──────── data (This is the location where server computate data is stored. Do not touch this)
├── docs (Documentation, you can delete this folder)
```
## Site.yaml
This is the configuration file for your site. This is where you define the name of your site, the look and feel of your site etc. Read more about it [here](/docs/customize-site)
## Monitors.yaml
This is the configuration file for your monitors. This is where you define the monitors you want to show on your site. Read more about it [here](/docs/monitors)
## Data
Kener stores its data in a folder which is `./database`. This is where all the data is stored. You can delete this folder if you want to start fresh.
+160
View File
@@ -0,0 +1,160 @@
---
title: i18n | Kener
description: Kener supports multiple languages. You can add translations to your site.
---
# i18n
You can add translations to your site. By default it is set to `en`. Available translations are present in `src/lib/locales/` folders in the root directory. You can add more translations by adding a new file in the `src/lib/locales` folder.
## How to enable a translation
Kener has made it simple to add new translations.
### Step 1
Copy and update the translation file in the `locales` folder. The translation file should be a json file with the locale code as the name of the file. For example, `en.json`, `fr.json`, `de.json`.
<label for="exp2" class="accm">
<input type="checkbox" class="absolute opacity-0" id="exp2" />
<p class=" font-medium p-1 accmt rounded-md showaccm">
<span>↓ Show File</span>
<span>↑ Hide File</span>
</p>
<div class="border rounded-md p-1">
```json
{
"14 Days": "14 Days",
"30 Days": "30 Days",
"60 Days": "60 Days",
"7 Days": "7 Days",
"90 Days": "90 Days",
"Availability per Component": "Availability per Component",
"Back": "Back",
"Badge": "Badge",
"Badge Copied": "Badge Copied",
"Code Copied": "Code Copied",
"Copy Code": "Copy Code",
"Copy Link": "Copy Link",
"Dark": "Dark",
"Days": "Days",
"DEGRADED": "DEGRADED",
"DOWN": "DOWN",
"Embed": "Embed",
"Embed this monitor using &#x3C;script&#x3E; or &#x3C;iframe&#x3E; in your app.": "Embed this monitor using <script> or <iframe> in your app.",
"Get SVG badge for this monitor": "Get SVG badge for this monitor",
"Get a LIVE Status for this monitor": "Get a LIVE Status for this monitor",
"IDENTIFIED": "IDENTIFIED",
"Incident Updates": "Incident Updates",
"INVESTIGATING": "INVESTIGATING",
"Lasted for about %lastedFor": "Lasted for about %lastedFor",
"Light": "Light",
"Link Copied": "Link Copied",
"LIVE Status": "LIVE Status",
"Mode": "Mode",
"MONITORING": "MONITORING",
"No Data": "No Data",
"No Incidents": "No Incidents",
"No Incident in %date": "No Incident in %date",
"No Monitor Found": "No Monitor Found",
"No Updates Yet": "No Updates Yet",
"Ongoing Incidents": "Ongoing Incidents",
"Pinging": "Pinging",
"Recent Incidents": "Recent Incidents",
"RESOLVED": "RESOLVED",
"Share": "Share",
"Share this monitor using a link with others": "Share this monitor using a link with others",
"Standard": "Standard",
"Started about %startedAt, lasted for about %lastedFor": "Started about %startedAt, lasted for about %lastedFor",
"Started about %startedAt, still ongoing": "Started about %startedAt, still ongoing",
"Starts in %startedAt": "Starts in %startedAt",
"Starts in %startedAt, will last for about %lastedFor": "Starts in %startedAt, will last for about %lastedFor",
"Status": "Status",
"Status OK": "Status OK",
"Theme": "Theme",
"Today": "Today",
"UP": "UP",
"Updates": "Updates",
"Uptime": "Uptime",
"%status for %duration": "%status for %duration"
}
```
</div>
</label>
### Step 2
Open `src/lib/locales/locales.json` and add the locale code and the name of the language.
```json
[
{
"code": "en",
"name": "English"
},
{
"code": "hi",
"name": "हिन्दी"
},
{
"code": "zh-CN",
"name": "中文"
},
{
"code": "ja",
"name": "日本語"
},
{
"code": "vi",
"name": "Tiếng Việt"
},
{
"code": "fr",
"name": "Français"
}
]
```
### Step 3
Open `src/lib/i18n/client.js` and add the locale code to the `locales` array.
```js
import { ru, enUS, hi, de, zhCN, vi, ja, nl, da, fr, ko } from "date-fns/locale";
```
This should be valid for the date-fns library. You can find the list of locales [here](https://github.com/date-fns/date-fns/blob/9bb51691f201c3ec05ab832acbc5d478f2e5c47a/docs/i18nLocales.md).
Next add a mapping in the `locales` object.
```js
const locales = { ru, en: enUS, hi, dk: de, "zh-CN": zhCN, vi, ja, nl, dk: da, fr, ko };
```
### Step 4
Restart the server and you will see a dropdown in the navbar to select the language.
<div class="note info">
DO NOT forget to raise a PR with the new translation file so that others can use it too.
</div>
## Variables
There are few variables that you you should not change,
- %status : This will be replaced by the status
- %lastedFor : This will be replaced by the duration the incident lasted
- %startedAt : This will be replaced by the time the incident started
- %date : This will be replaced by the date
- %duration : This will be replaced by the duration
## Dates
The localization for dates are still not there. We are working on it and will be available soon.
+76
View File
@@ -0,0 +1,76 @@
---
title: Incident Management | Kener
description: Kener uses Github to power incident management using labels
---
# Incident Management
Kener lets you manage incidents using its dashboard or APIs.
## Create Incident
### Incident Title
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The title of the incident. This is used to identify the incident in the dashboard.
### Incident Summary
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
A brief summary of the incident. This is used to give a brief overview of the incident.
### Incident Start Date Time
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The start date and time of the incident.
<div class="note info">
Each incident once created will be in INVESTIGATING status. You can update the status of the incident by adding updates as discussed below
</div>
---
## Update Incident
Add comments to the incident to update the status of the incident.
### Time Stamp
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
### Message
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The message to be added to the incident.
### Status
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The status of the incident. This can be `INVESTIGATING`, `IDENTIFIED`, `MONITORING`, `RESOLVED`.
### Close Incident
To close an incident, you need to add a message and set the status to `RESOLVED`.
---
## Add Monitors
To add monitors to the incident, you need to add the monitor tag to the incident. This will automatically add the monitor to the incident. You will also get the status of the monitor in the incident. This can be `DEGRADED` or `DOWN`.
+29
View File
@@ -0,0 +1,29 @@
---
title: Kener APIs
description: Kener gives APIs to push data and create incident.
---
# Kener APIs
Kener also gives APIs to push data and create incident. Before you use kener apis you will have to generate the API keys using the dashboard.
1. Go to manage kener dashboard
2. Go to api keys
3. Click on Create New API Key
4. Enter a name for the API key
5. Click on Create
You can use the generated API key to authenticate the APIs. It will be not be shown again, so make sure to save it.
## API Reference
The current version is 3.0.0. We maintain just one version
[API Reference](/api-reference)
You can download the openapi spec
- [JSON](https://raw.githubusercontent.com/rajnandan1/kener/main/openapi.json)
- [YAML](https://raw.githubusercontent.com/rajnandan1/kener/main/openapi.yaml)
---
View File
+240
View File
@@ -0,0 +1,240 @@
---
title: Monitor Examples | Kener
description: Here are some exhaustive examples for monitors
---
# Monitor Examples
Here are some exhaustive examples for monitors
## Simple GET Monitor
Below example will call https://www.google.com/webhp. If the status code is 200 then it will be UP else DOWN.
```yaml
- name: Google Search
tag: "google-search"
api:
method: GET
url: https://www.google.com/webhp
```
## Simple GET Monitor Without Hyperlink
Below example will call https://www.google.com/webhp. If the status code is 200 then it will be UP else DOWN. It will not show the GET hyperlink in the monitor description.
```yaml
- name: Google Search
tag: "google-search"
api:
method: GET
url: https://www.google.com/webhp
hideURLForGet: true
```
## Monitor with HTML description
Below example will call https://www.google.com/webhp. If the status code is 200 then it will be UP else DOWN. It will show the description in HTML format.
```yaml
- name: Google Search
tag: "google-search"
description: "Hello <b>world</b>"
api:
method: GET
url: https://www.google.com/webhp
```
## Monitor with image
google.png is in the static folder
```yaml
- name: Google Search
tag: "google-search"
image: "/google.png"
api:
method: GET
url: https://www.google.com/webhp
```
## Get Monitor 15 Minute
Below example will call https://www.google.com/webhp every 15 minutes. If the status code is 200 then it will be UP else DOWN.
```yaml
- name: Google Search
description: Search the world's information, including webpages, images, videos and more.
tag: "google-search"
cron: "*/15 * * * *"
api:
method: GET
url: https://www.google.com/webhp
```
## POST Monitor With Body
Below example will call https://www.google.com/webhp with body. If the status code is 200 then it will be UP else DOWN.
```yaml
- name: Google Search
description: Google Search
tag: "google-search-post"
api:
method: POST
url: https://www.google.com/webhp
headers:
Content-Type: application/json
body: '{"order_amount":22222.1,"order_currency":"INR"}'
```
## Secrets in Header
You can set ENV variables in your machine and use them in your monitors. Example below has `GH_TOKEN` as an environment variable. It uses process.env.GH_TOKEN.
```
export GH_TOKEN=some.token.for.github
```
> **_NOTE:_** DO NOT forget the `$` sign in your monitor secret, otherwise it will not be picked up.
```yaml
- name: Github Issues
description: Github Issues Fetch
tag: "gh-search-issue"
api:
method: GET
url: https://api.github.com/repos/rajnandan1/kener/issues
headers:
Authorization: Bearer $GH_TOKEN
```
## Secrets in Body
Assuming `ORDER_ID` is present in env
```yaml
- name: Github Issues
description: Github Issues Fetch
tag: "gh-search-issue"
api:
method: POST
url: https://api.github.com/repos/rajnandan1/kener/issues
headers:
Content-Type: application/json
body: '{"order_amount":22222.1,"order_currency":"INR", "order_id": "$ORDER_ID"}'
```
## Eval Body
Read more about [eval](https://kener.ing/docs/monitors#eval)
Below example will call https://api.github.com/repos/rajnandan1/kener/issues. If the status code is 200 then it will be UP else DOWN. It will also check if the response time is greater than 2000ms then it will be DEGRADED.
```yaml
- name: Github Issues
description: Github Issues Fetch
tag: "gh-search-issue"
api:
method: GET
url: https://api.github.com/repos/rajnandan1/kener/issues
eval: |
(function(statusCode, responseTime, responseDataBase64){
const resp = JSON.parse(atob(responseDataBase64));
let status = 'DOWN'
if(statusCode == 200) status = 'UP';
if(Object.keys(resp).length == 0) status = 'DOWN';
if(statusCode == 200 && responseTime > 2000) status = 'DEGRADED';
return {
status: status,
latency: responseTime,
}
})
```
## With default_status UP
Each minute it will set the status as UP
```yaml
- name: Earth
description: Our Planet
tag: "earth"
default_status: UP
```
## With Category
Add this monitor to the API category instead of the default home category
```yaml
- name: Earth
description: Our Planent
tag: "earth"
category: API
```
## Ping Monitor
This will ping the hosts. It will be up if the ping is successful for all the hosts present in the list of ip4 and ip6.
```yaml
- name: Earth
description: Our Planent
tag: "earth"
ping:
hostsV4:
- www.frogment.com
- 52.84.205.24
hostsV6:
- ipv6.google.com
```
If both ping and api monitors are present then API data will overwrite ping data
## Custom Thresholds
The below monitor will show DEGRADED if 3 or more degraded status in a day and DOWN if 2 or more down status in a day. It will also include degraded in downtime calculation.
```yaml
- name: Earth
description: Our blue planet
tag: "earth"
default_status: "UP"
day_degraded_minimum_count: 3
day_down_minimum_count: 2
include_degraded_in_downtime: true
```
## Monitor with Alerts
Make sure you have set up triggers in `server.yaml`. Read more about [alerts](/docs/alerting).
The below example will trigger an alert if the monitor is DOWN for 10 consecutive times. It will also create an incident in Github and send alerts to Webhook, Discord and Slack. It will also trigger an alert if the monitor is DEGRADED for 5 consecutive times. It will not create an incident in Github and send alerts to Webhook, Discord and Slack.
```yaml
- name: Earth
description: Our blue planet
tag: "earth"
default_status: "UP"
include_degraded_in_downtime: true
alerts:
DOWN:
failureThreshold: 10
successThreshold: 5
createIncident: true
description: "🚨 So much panic in the world and kener tells you about it"
triggers:
- Webhook MyWebhook
- Discord Test
- Slack Test
DEGRADED:
failureThreshold: 5
successThreshold: 2
createIncident: false
description: "⚠️ To degrade is human, to alert is kener"
triggers:
- Webhook MyWebhook
- Discord Test
- Slack Test
```
+209
View File
@@ -0,0 +1,209 @@
---
title: API Monitors | Kener
description: Learn how to set up and work with API monitors in kener.
---
# API Monitors
API monitors are used to monitor APIs. You can use API monitors to monitor the uptime of your Website or APIs and get notified when they are down.
<div class="border rounded-md">
![Monitors API](/m_api.png)
</div>
## Timeout
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The timeout is used to define the time in milliseconds after which the monitor should timeout. If the monitor does not respond within the timeout period, the monitor will be marked as down. For example, `5000` will set the timeout to 5 seconds. It is required and has to be a number greater than 0.
## URL
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The URL is used to define the URL of the API that you want to monitor. It is required and has to be a valid URL.
## Method
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The method is used to define the HTTP method that should be used to make the request. It is required and has to be one of the following: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`.
## Headers
The headers are used to define the headers that should be sent with the request. It is optional and has to be a valid JSON object.
## Eval
The eval is used to define the JavaScript code that should be used to evaluate the response. It is optional and has be a valid JavaScript code.
This is a anonymous JS function, by default it looks like this.
> **_NOTE:_** The eval function should always return a json object. The json object can have only status(UP/DOWN/DEGRADED) and latency(number)
> `{status:"DEGRADED", latency: 200}`.
```javascript
(function (statusCode, responseTime, responseDataBase64) {
let statusCodeShort = Math.floor(statusCode/100);
let status = 'DOWN'
if(statusCodeShort >=2 && statusCodeShort <= 3) {
status = 'UP',
}
return {
status: 'DOWN',
latency: responseTime,
}
})
```
- `statusCode` **REQUIRED** is a number. It is the HTTP status code
- `responseTime` **REQUIRED**is a number. It is the latency in milliseconds
- `responseDataBase64` **REQUIRED** is a string. It is the base64 encoded response data. To use it you will have to decode it
```js
let decodedResp = atob(responseDataBase64);
//if the response is a json object
//let jsonResp = JSON.parse(decodedResp)
```
<div class="note danger">
The eval is validated against (200, 1000, "e30=") which translates to (200, 1000, '{}') within the function. So whatever function you write should be able to handle these values otherwise you won't be able to save the monitor.
</div>
#### Example
The following example shows how to use the eval function to evaluate the response. The function checks if the status code is 2XX then the status is UP, if the status code is 5XX then the status is DOWN. If the response contains the word `Unknown Error` then the status is DOWN. If the response time is greater than 2000 then the status is DEGRADED.
```javascript
(function (statusCode, responseTime, responseDataBase64) {
const resp = atob(responseDataBase64); //convert base64 to string
let status = "DOWN";
//if the status code is 2XX then the status is UP
if (/^[2]\d{2}$/.test(statusCode)) {
status = "UP";
if (responseTime > 2000) {
status = "DEGRADED";
}
}
//if the status code is 5XX then the status is DOWN
if (/^[5]\d{2}$/.test(statusCode)) status = "DOWN";
if (resp.includes("Unknown Error")) {
status = "DOWN";
}
return {
status: status,
latency: responseTime
};
});
```
## Examples
### Website Monitor
This is an example to monitor google every 5 minute.
<label for="websiteMonitor" class="accm">
<input type="checkbox" class="absolute opacity-0" id="websiteMonitor" />
<p class="font-medium p-1 accmt rounded-md showaccm">
<span>↓ Show Example</span>
<span>↑ Hide Example</span>
</p>
<div class="border rounded-md">
![Monitors API](/m_ex_website.png)
</div>
</label>
### Get Request
Example to monitor a GET Request with an Authorization header set to `$SOME_TOKEN`.
`$SOME_TOKEN` is set in environment variables as `SOME_TOKEN`
```bash
export SOME_TOKEN=some-token-example
```
<label for="exp2" class="accm">
<input type="checkbox" class="absolute opacity-0" id="exp2" />
<p class=" font-medium p-1 accmt rounded-md showaccm">
<span>↓ Show Example</span>
<span>↑ Hide Example</span>
</p>
<div class="border rounded-md p-1">
![Monitors API](/m_ex_2.png)
</div>
</label>
### POST Request
Example showing setting up a POST request every minute with a timeout of 2 seconds.
<label for="exp3" class="accm">
<input type="checkbox" class="absolute opacity-0" id="exp3" />
<p class=" font-medium p-1 accmt rounded-md showaccm">
<span>↓ Show Example</span>
<span>↑ Hide Example</span>
</p>
<div class="border rounded-md p-1">
![Monitors API](/m_ex_3.png)
</div>
</label>
### Custom Response Eval
Example showing how to use a custom response eval function. It also shows how to set secrets in body for POST Request.
The secrets have to be present in environment variables.
```bash
export SERVICE_TOKEN=secret1_tone
export SERVICE_SECRET=secret2_secret
```
<label for="exp4" class="accm">
<input type="checkbox" class="absolute opacity-0" id="exp4" />
<p class=" font-medium p-1 accmt rounded-md showaccm">
<span>↓ Show Example</span>
<span>↑ Hide Example</span>
</p>
<div class="border rounded-md p-1">
![Monitors API](/m_ex_4.png)
</div>
</label>
+59
View File
@@ -0,0 +1,59 @@
---
title: DNS Monitors | Kener
description: Learn how to set up and work with DNS monitors in kener.
---
# DNS Monitors
DNS monitors are used to monitor DNS servers. Verify DNS queries for your server and match values with the expected values to get notified when they are different.
<div class="border rounded-md">
![Monitors Ping](/m_dns.png)
</div>
## Host
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The host is used to define the host of the DNS server that you want to monitor. It is required and has to be a valid host.
## Lookup Record
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The query is used to define the query that you want to monitor. It is required and has to be a valid query. Example: `A`, `MX`, `TXT`, `CNAME`.
## Name Server
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The name server is the DNS server that you want to query. It is required and has to be a valid DNS server.
Few examples:
- 8.8.8.8 (Google)
- 1.1.1.1 (Cloudflare)
## Match Type
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
Match type for DNS response. Can be ANY or ALL. If ANY is selected, the monitor will be marked as UP if any of the expected values match the response. If ALL is selected, all the expected values should match the response.
## Expected Values
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The expected values are the values that you expect the DNS server to return. You can add multiple values. Atleast one is required.
+26
View File
@@ -0,0 +1,26 @@
---
title: Ping Monitors | Kener
description: Learn how to set up and work with Ping monitors in kener.
---
# Ping Monitors
Ping monitors are used to monitor livenees of your servers. You can use Ping monitors to monitor the uptime of your servers and get notified when they are down.
<div class="border rounded-md">
![Monitors Ping](/m_ping.png)
</div>
## Host V4
You can add as many IP addresses as you want to monitor. The IP address should be a valid IPv4 address. Example of IP4 address is `106.12.43.232`.
## Host V6
You can add as many IP addresses as you want to monitor. The IP address should be a valid IPv6 address. Example of IP6 address is `2001:0db8:85a3:0000:0000:8a2e:0370:7334`.
<p class="note danger">
Please note that atleast one of the Host V4 or Host V6 is required.
<p>
+140
View File
@@ -0,0 +1,140 @@
---
title: Monitors | Kener
description: Monitors are the heart of Kener. This is where you define the monitors you want to show on your site.
---
# Monitors
Monitors are the heart of Kener. This is where you define the monitors you want to show on your site.
## Add Monitors
Click on the to add a monitor.
<div class="border rounded-md">
![Monitors Main](/m_main.png)
</div>
## Tag
<span class="text-red-500 text-xs font-semibold">
UNIQUE
</span>
&
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The tag is unique to each monitor and is used to identify the monitor in the code. It is also used to generate the URL for the monitor.
## Name
<span class="text-red-500 text-xs font-semibold">
UNIQUE
</span>
&
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
Name is also unique to each monitor and is used to identify the monitor in the admin panel or in the status page.
## Image
The image is used to display a logo or an image for the monitor. It is displayed on the status page and in the admin panel. It is optional.
## Description
The description is used to describe the monitor. It is displayed on the status page and in the admin panel. It is optional. You can also write html in the description and can use tailwind classes.
## Cron
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The cron is used to define the interval at which the monitor should run. It is a cron expression. For example, `* * * * *` will run the monitor every minute.
## Default Status
Using default status you can set a predefined status for the monitor. The predefined status can be `UP`, `DOWN`, or `DEGRADED`. If the monitor does not have api/dns/ping or if the monitor status is not updated using webbhook, manual incident this will the status of the monitor.
## Category Name
The category name is used to group monitors. It is displayed on the status page and in the admin panel. It is optional. By a home category is already present. Adding monitor to the home category will show the monitor on the home page.
## Day Degraded Min Count
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The day degraded minimum count is used to define the minimum number of degraded incidents in a day to mark the monitor as degraded for the day. A degraded day means if you are using Monitor Style as FULL under Theme, the whole bar will be shown as Degraded (yellow color) . It is optional. It has to be more than equal to 1
## Day Down Min Count
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The day down minimum count is used to define the minimum number of down incidents in a day to mark the monitor as down for the day. A down day means if you are using Monitor Style as FULL under Theme, the whole bar will be shown as Down (red color) . It is optional. It has to be more than equal to 1
## Include Degraded
The include degraded is used to include the degraded checks in the total bad checks count. It is optional. By default uptime percentage is calculated as (UP+DEGRADED/UP+DEGRADED+DOWN). Setting it as `YES` will change the calculation to (UP/UP+DEGRADED+DOWN)
## Monitor Type
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
### API
To monitor an API, you need to provide the URL of the API and the expected status code. If the status code is not received, the monitor will be marked as down. You can read more about API monitoring [here](/docs/monitors-api).
### DNS
To monitor the DNS records, you need to provide the domain name and the record type. If the record is not found, the monitor will be marked as down. You can read more about DNS monitoring [here](/docs/monitors-dns).
### PING
To monitor the ping, you need to provide the IP address or the domain name. If the ping is not successful, the monitor will be marked as down. You can read more about PING monitoring [here](/docs/monitors-ping).
---
## Triggers
To add a trigger to a monitor make sure you have created a trigger. You can read more about triggers [here](/docs/triggers).
- Click on the 🔔 icon on the top right corner of the monitor.
- Add details for either DOWN or DEGRADED.
- Failure Threshold(Required): The number of consecutive failures before the trigger is activated.
- Success Threshold(Required): The number of consecutive successes before the trigger is deactivated.
- Create Incident: Chose whether to create an incident or not when the trigger is activated. The incident will be created in Github. So make sure you have set up the Github token in the environment variables. The incident will be closed when the monitor is back to UP.
- Severity (Required): The severity of the incident. It can be `Critcal` or `Warning`.
- Custom Message (Required): Add your owner alert message.
- Choose Triggers: Choose the triggers you want to activate the trigger. You can choose multiple triggers.
- It will take upto 1 minute for the trigger to be activated.
---
## Edit Monitors
Click on the ⚙️ to edit the monitor.
### Deactivate Monitor
You can deactivate the monitor by switching the toggle to off. Deactivation a monitor will stop the monitor from running. It will take one minute to deactivate.
### Activate Monitor
You can activate the monitor by switching the toggle to on. Activation a monitor will start the monitor. It will take one minute to activate.
<div class="note info">
Any changes in the a live monitor will take one minute to reflect.
</div>
+76
View File
@@ -0,0 +1,76 @@
---
title: Get started | Kener
description: Get started with Kener
---
# Get Started
Here is a demonstration of how to get started with Kener in seconds
## Requirements
- Node.js Minimum version required is `v18`.
- Git
- sqlite3
## Clone the repository
```bash
git clone https://github.com/rajnandan1/kener.git
cd kener
```
## Install Dependencies
```bash
npm install
```
## Set up Environment Variables
Kener needs some environment variables to be set to run properly. [Here](/docs/environment-vars) are the list of environment variables that you need to set.
```bash
cp .env.example .env
```
## Start Kener
```bash
npm run dev
```
Kener Development Server would be running at PORT 3000. Go to [http://localhost:3000](http://localhost:3000) to see the Kener in action.
<div class="note info">
Status page will be at [http://localhost:3000](http://localhost:3000)
Status Page Manage Portal will be at [http://localhost:3000/manage/app/site](http://localhost:3000/manage/app/site)
</div>
## Create a new User
If this is the first time your are launching kener then you would be redirected to the [set up page](/manage/setup). Fill in the details and click on `Let's Go` button.
- **Name**: Your Name
- **Email**: Your Email
- **Password**: Your Password
Please note that the email should be a valid email address and password should be atleast 8 characters long with uppercase lowercase and numbers.
## Login
Once you have created the user, you can login with the credentials you have provided by going to the [login page](/manage/signin)
## Next Steps
Learn how to configure kener by going through one of the topics
- [Monitors](/docs/monitors): Learn how to set up and work with monitors in kener.
- [Triggers](/docs/triggers): Learn how to set up and work with triggers in kener.
- [Environment Variables](/docs/environment-vars): Learn how to set up and work with environment variables in kener.
- [API](/docs/kener-apis): Learn how to use the API in kener.
- [Databases](/docs/database): Learn how to set up and work with databases in kener.
- [Theme](/docs/theme): Learn how to set up and work with theme in kener.
+43
View File
@@ -0,0 +1,43 @@
---
title: Roadmap | Kener
description: Roadmap for Kener
---
# Roadmap
<picture>
<source srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f331/512.webp" type="image/webp">
<img src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f331/512.gif" alt="🌱" width="32" height="32">
</picture>
~~Move from file system to sqlite3~~
~~Add support for webhooks~~
~~Support DNS probe~~
~~Color Customization~~
~~Support for github enterprise~~
☐ Add Rate limiting to APIs
☐ Add support for custom themes
~~Handle uploaded images for docker containers~~
☐ Introduce sitemap.xml
~~Migrate from moment to date-fns for status page~~ (moment remains in dashboard. Will remove later)
~~Create Admin UI~~ Released in (3.0.0)
☐ Webhook customization
☐ Monitor Update Screen
☐ i18n for admin dashboard
---
Request a feature or start a discussion [here](https://github.com/rajnandan1/kener/discussions/119)
+38
View File
@@ -0,0 +1,38 @@
---
title: SEO Setup | Kener
description: Learn how to set up and work with SEO in kener.
---
# SEO
SEO is important for your status page to be found on the internet. Kener provides you with the ability to set up SEO for your status page.
## Analytics
You can add Google Analytics, Amplitude, Mixpanel. Basic events and page tracking are supported.
### Google Analytics
ADD ID. It is the measurement ID that you get from Google Analytics. You can find it in the admin section of your Google Analytics account.
### Amplitude
Add API key of your Amplitude account
### Mixpanel
Add token of your Mixpanel account.
## Search Engine Optimization
You can add meta tags for SEO. You can add any `meta` that you want to add to the head of the page. Example: `og:image`, `og:title`, `og:description`, `twitter:image`, `twitter:title`, `twitter:description`.
Once added it will become
```html
<meta property="og:image" content="https://example.com/image.png" />
```
## Sitemap
Kener auto generates a sitemap for your status page. You can view it at `/sitemap.xml`
+22
View File
@@ -0,0 +1,22 @@
# Kener Showcase
This page is a showcase of how kener is getting used in the wild. If you want to add your site here, please raise a PR and modify this [file](https://github.com/rajnandan1/kener-docs/blob/main/docs/md/docs/showcase.md)
#### [Kener](https://kener.ing)
#### [Cashfree Payments India](https://statuspage.cashfree.com/)
#### [status.orhun.dev](https://status.orhun.dev/)
#### [status.ordinalsbot.com](https://status.ordinalsbot.com/)
#### [status.britsov.com](https://status.britsov.net/)
#### [status.gosu.bar](https://status.gosu.bar/)
#### [stat.imsun.org](https://stat.imsun.org/)
#### [Goomer](https://status.goomer.com.br/)
#### [kennek.io](https://status.kennek.io/)
#### [evelan.io](https://status.evelan.io/)
#### [evelan.io](https://status.evelan.io/)
#### [sveir.xyz](https://status.sveir.xyz/)
#### [cellcast.com](https://status.cellcast.com/)
#### [flytbase.com](https://status.flytbase.com/)
#### [scriptor-artis.fr](https://status.scriptor-artis.fr/)
#### [jiance.f.ozizio.com](http://jiance.f.ozizio.com/)
#### [sshaw.cn](https://s.sshaw.cn/)
#### [donotes.app](https://status.donotes.app)
+54
View File
@@ -0,0 +1,54 @@
---
title: Site Analytics | Kener
description: Add Google Analytics, Amplitude, Mixpanel etc to your Kener site
---
# Site Analytics
You can add Google Analytics, Amplitude, Mixpanel etc to your Kener site. In order to do this, you need to add the following configuration to your `site.yaml` file.
```yaml
analytics:
- id: "G-QsFT"
type: "GA"
- id: "deasf0d350"
type: "AMPLITUDE"
- id: "FKOdsKener"
type: "MIXPANEL"
```
## Google Analytics
To add Google Analytics to your site, you need to add the following configuration to your `site.yaml` file.
```yaml
analytics:
- id: "G-QsFT"
type: "GA"
```
`id` is the tracking ID of your Google Analytics account. You can find this in your Google Analytics account.
## Amplitude
To add Amplitude to your site, you need to add the following configuration to your `site.yaml` file.
```yaml
analytics:
- id: "deasf0d350"
type: "AMPLITUDE"
```
`id` is the API key of your Amplitude account. You can find this in your Amplitude account.
## Mixpanel
To add Mixpanel to your site, you need to add the following configuration to your `site.yaml` file.
```yaml
analytics:
- id: "FKOdsKener"
type: "MIXPANEL"
```
`id` is the token of your Mixpanel account. You can find this in your Mixpanel account.
+52
View File
@@ -0,0 +1,52 @@
---
title: Site Info Setup | Kener
description: Learn how to set up and work with site info in kener.
---
# Site
Site info is used to define the site information that will be shown on the status page.
## Site Title
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The site title is used to define the title of the site. It is required and has to be a string. It what will be shown on the browser tab. This will become
Example: `Kener - Open-Source and Modern looking Node.js Status Page for Effortless Incident Management`
```html
<title>
Kener - Open-Source and Modern looking Node.js Status Page for Effortless Incident Management
</title>
```
![Site Title](/ms_1.png)
## Site Name
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
This will be shown as a brand name on the status page on the nav bar top left.
![Site Name](/s_2.png)
## Home Location
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
Link to redirect to when Site Name or logo is clicked. Can be your main website. or it can be `/` to be current page. If you are using a base path it should be `/base-path` to keep the the user on the same page.
## Logo
Same as Site Name, it is placed left of Site Name as shown in image above
## Favicon
Favicon to be shown in browser tab.
+206
View File
@@ -0,0 +1,206 @@
---
title: Status Badges | Kener
description: Status badges for your monitors
---
# Status Badges
There are three types of badges
Syntax
```md
http://[hostname]/badge/[tag]/status
http://[hostname]/badge/[tag]/dot
http://[hostname]/badge/[tag]/uptime
```
---
## Status
Shows the last health check was UP/DOWN/DEGRADED
![Earth Status](https://kener.ing/badge/earth/status)
Example in HTML
```html
<img src="https://kener.ing/badge/earth/status" />
```
Example in MarkDown
```md
![Status Badge](https://kener.ing/badge/[monitor.tag]/status)
```
### Custom Label
You can set custom label for the status badge. If you do not set a label it will default to the monitor name.
![Earth Status](https://kener.ing/badge/earth/status?label=Gotcha)
```md
![Earth Status](https://kener.ing/badge/earth/status?label=Gotcha)
```
---
## Live
Shows the last health check was UP/DOWN/DEGRADED as SVG dot
```bash
http://[hostname]/badge/[tag]/dot
#or
http://[hostname]/badge/[tag]/dot?animate=ping
```
### Standard
![Earth Status](/badge/earth/dot)
```html
<img src="https://kener.ing/badge/earth/dot" />
```
### Animated
![Earth Status](/badge/earth/dot?animate=ping)
```html
<img src="https://kener.ing/badge/earth/dot?animate=ping" />
```
---
## Uptime
Shows the 90 Day uptime by default. You can `sinceLast` as query param to get uptime since last x seconds.
![Earth Uptime](https://kener.ing/badge/earth/uptime)
### 90 Day Uptime
Example in HTML
```html
<img src="https://kener.ing/badge/earth/uptime" />
```
Example in MarkDown
```md
![Uptime Badge](https://kener.ing/badge/[monitor.tag]/uptime)
```
### 15 Minute Uptime
Set `sinceLast` as query param to get uptime since last x seconds.
Example in HTML
```html
<img src="https://kener.ing/badge/earth/uptime?sinceLast=900" />
```
Example in MarkDown
```md
![Uptime Badge](https://kener.ing/badge/[monitor.tag]/uptime?sinceLast=900)
```
### Custom Label
You can set custom label for the uptime badge. If you do not set a label it will default to the **monitor name**.
![Earth Uptime](https://kener.ing/badge/earth/uptime?label=Gotcha)
```md
![Earth Uptime](https://kener.ing/badge/earth/uptime?label=Gotcha)
```
### Hide Duration
You can hide the duration by setting `hideDuration=true`
![Earth Uptime](https://kener.ing/badge/earth/uptime?hideDuration=true)
```md
![Earth Uptime](https://kener.ing/badge/earth/uptime?hideDuration=true)
```
---
## Customize Badges
You can set different colors for badges and style. It only works for `status` and `uptime` badges.
### With Custom Label Color
![Earth Status](https://kener.ing/badge/earth/status?labelColor=F2BED1)
```md
![Earth Status](https://kener.ing/badge/earth/status?labelColor=F2BED1)
```
### With Custom Value Color
![Earth Status](https://kener.ing/badge/earth/status?color=FFC0D9)
```md
![Earth Status](https://kener.ing/badge/earth/status?color=FFC0D9)
```
### With Both Different Colors
![Earth Status](https://kener.ing/badge/earth/uptime?color=D0BFFF&labelColor=FFF3DA)
```md
![Earth Status](https://kener.ing/badge/earth/uptime?color=D0BFFF&labelColor=FFF3DA)
```
### Style Of the Badge
You can change the style of the badge. Supported Styles are `plastic`, `flat`, `flat-square`, `for-the-badge` or `social`. Default is `flat`
#### plastic
![Earth Uptime](https://kener.ing/badge/earth/uptime?style=plastic)
```md
![Earth Uptime](https://kener.ing/badge/earth/uptime?style=plastic)
```
#### flat
![Earth Uptime](https://kener.ing/badge/earth/uptime?style=flat)
```md
![Earth Uptime](https://kener.ing/badge/earth/uptime?style=flat)
```
#### flat-square
![Earth Uptime](https://kener.ing/badge/earth/uptime?style=flat-square)
```md
![Earth Uptime](https://kener.ing/badge/earth/uptime?style=flat-square)
```
#### for-the-badge
![Earth Uptime](https://kener.ing/badge/earth/uptime?style=for-the-badge)
```md
![Earth Uptime](https://kener.ing/badge/earth/uptime?style=for-the-badge)
```
#### social
![Earth Uptime](https://kener.ing/badge/earth/uptime?style=social)
```md
![Earth Uptime](https://kener.ing/badge/earth/uptime?style=social)
```
+156
View File
@@ -0,0 +1,156 @@
{
"sidebar": [
{
"sectionTitle": "Getting Started",
"children": [
{
"title": "Introduction",
"link": "/docs/home",
"file": "/home.md"
},
{
"title": "Get Started",
"link": "/docs/quick-start",
"file": "/quick-start.md"
},
{
"title": "Concepts",
"link": "/docs/concepts",
"file": "/concepts.md"
},
{
"title": "Deployment",
"link": "/docs/deployment",
"file": "/deployment.md"
},
{
"title": "Databases",
"link": "/docs/database",
"file": "/database.md"
}
]
},
{
"sectionTitle": "Guides",
"children": [
{
"title": "Setup Environment",
"link": "/docs/environment-vars",
"file": "/environment-vars.md"
},
{
"title": "Use Badges",
"link": "/docs/status-badges",
"file": "/status-badges.md"
},
{
"title": "Setup Monitors",
"link": "/docs/monitors",
"file": "/monitors.md"
},
{
"title": "API/Website Monitor",
"link": "/docs/monitors-api",
"file": "/monitors-api.md"
},
{
"title": "Ping Monitor",
"link": "/docs/monitors-ping",
"file": "/monitors-ping.md"
},
{
"title": "DNS Monitor",
"link": "/docs/monitors-dns",
"file": "/monitors-dns.md"
},
{
"title": "Setup Triggers",
"link": "/docs/triggers",
"file": "/triggers.md"
},
{
"title": "Setup Site",
"link": "/docs/site",
"file": "/site.md"
},
{
"title": "Setup Github",
"link": "/docs/gh-setup",
"file": "/gh-setup.md"
},
{
"title": "Setup SEO",
"link": "/docs/seo",
"file": "/seo.md"
},
{
"title": "Setup Home",
"link": "/docs/home-page",
"file": "/home-page.md"
},
{
"title": "Setup Theme",
"link": "/docs/theme",
"file": "/theme.md"
},
{
"title": "View Alerts",
"link": "/docs/alerts",
"file": "/alerts.md"
},
{
"title": "API Keys",
"link": "/docs/apikeys",
"file": "/apikeys.md"
},
{
"title": "Incident Management",
"link": "/docs/incident-management",
"file": "/incident-management.md"
},
{
"title": "Embed",
"link": "/docs/embed",
"file": "/embed.md"
},
{
"title": "Custom JS/CSS",
"link": "/docs/custom-js-css-guide",
"file": "/custom-js-css-guide.md"
},
{
"title": "Internationalization",
"link": "/docs/i18n",
"file": "/i18n.md"
}
]
},
{
"sectionTitle": "API Reference",
"children": [
{
"title": "Kener APIs",
"link": "/docs/kener-apis",
"file": "/kener-apis.md"
}
]
},
{
"sectionTitle": "Help",
"children": [
{
"title": "Changelogs",
"link": "/docs/changelogs",
"file": "/changelogs.md"
},
{
"title": "Roadmap",
"link": "/docs/roadmap",
"file": "/roadmap.md"
}
]
}
]
}
+106
View File
@@ -0,0 +1,106 @@
---
title: Theme Customization | Kener
description: Learn how to customize the theme of your status page in kener.
---
# Theme Customization
Kener provides you with the ability to customize the theme of your status page. You can change the colors, fonts, and other styles of the status page.
---
## Home Page Pattern
Kener can show a subtle pattern in all your pages. It is either sqaure or dots. Right now you cannot modify the color of the pattern. However, you can disable it by choosing none
---
## Default Theme
Kener comes with both dark and light theme. You can set a default theme that will be used every time the user visits.
Setting it to light or dark will make the theme light or dark respectively. Setting it to none will make the theme change based on the user's system preference. Setting it to system will make the theme change based on the user's system preference.
If you want to force a theme to be light or dark you can set it to light or dark and uncheck `Let Users toggle theme between light and dark`
---
## Monitor Style
You can change how the bars and summary of a monitor looks like.
### Status Bar
#### Partial
The status bar will be a gradient from green to red/yellow based on the status of the monitor.
![Trigger API](/x1.png)
#### Full
The status bar will be a solid color based on the status of the monitor.
![Trigger API](/x2.png)
---
### Roundness
Adjust the roundness of the status bar.
#### SHARP
![Trigger API](/x4.png)
#### ROUNDED
![Trigger API](/x3.png)
---
### Summary Type
Control how the summary of the monitor looks like.
#### Current
Shows the current live value of the monitor. Example: If it is DOWN currently it will say `DOWN for x minutes`, if it is UP it will say `Status Ok`
#### DAY
Shows the day's impact. Example: If it was down at 10:00 AM and it is 11:00 AM, it will say `DOWN for 1 hour`. If there was no DOWN or DEGRADED state it will say `Status Ok`
---
## Colors
You can change the colors of UP/DEGRADED/DOWN states of the monitor. You show have a shade of green, yellow, and red respectively.
---
## Font
You can change the font of the status page. You can choose from a list of google fonts.
### Font URL
Add font url
### Font Family
Add font family name
### Custom CSS
You can add custom CSS to your status page. This will be added to the head of the page. You can add custom CSS to your status page. This will be added to the head of the page.
```css
.my-class {
color: red;
}
```
<div class="note danger">
Do not include &#x3C;style&#x3E; tags.
</div>
+252
View File
@@ -0,0 +1,252 @@
---
title: Triggers | Kener
description: Learn how to set up and work with triggers in kener.
---
# Triggers
Triggers are used to trigger actions based on the status of your monitors. You can use triggers to send notifications, or call webhooks when a monitor goes down or up.
<div class="border rounded-md">
![Trigger API](/trig_1.png)
</div>
### Name
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The name is used to define the name of the webhook. It is required and has to be a string.
### Description
The description is used to define the description of the webhook. It is optional and has to be a string.
Kener supports the following triggers:
- [Webhook](#webhook)
- [Discord](#discord)
- [Slack](#slack)
- [Email](#email)
## Webhook
Webhook triggers are used to send a HTTP POST request to a URL when a monitor goes down or up.
<div class="border rounded-md">
![Trigger API](/trig_web.png)
</div>
### URL
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The URL is used to define the URL of the webhook. It is required and has to be a valid URL.
You can also pass secrets that are set in the environment variables.
Example: `https://example.com/webhook?secret=$SECRET_X`. Make sure `$SECRET_X` is set in the environment variables.
### Method
Method will always be `POST`
### Headers
The headers are used to define the headers that should be sent with the request. It is optional and has to be a valid JSON object. You can add secrets that are set in the environment variables.
Example: `Authorization: Bearer $SECRET_Y`. Make sure `$SECRET_Y` is set in the environment variables.
While sending webhook kener will add two more headers: `Content-Type: application/json` and `User-Agent: Kener/3.0.0`.
### Body
Body of the webhook will be sent as below:
```json
{
"id": "mockoon-9",
"alert_name": "Mockoon DOWN",
"severity": "critical",
"status": "TRIGGERED",
"source": "Kener",
"timestamp": "2024-11-27T04:55:00.369Z",
"description": "🚨 **Service Alert**: Check the details below",
"details": {
"metric": "Mockoon",
"current_value": 1,
"threshold": 1
},
"actions": [
{
"text": "View Monitor",
"url": "https://kener.ing/monitor-mockoon"
}
]
}
```
| Key | Description |
| --------------------- | ----------------------------------------------------------- |
| id | Unique ID of the alert |
| alert_name | Name of the alert |
| severity | Severity of the alert. Can be `critical`, `warn` |
| status | Status of the alert. Can be `TRIGGERED`, `RESOLVED` |
| source | Source of the alert. Can be `Kener` |
| timestamp | Timestamp of the alert |
| description | Description of the alert. This you can customize. See below |
| details | Details of the alert. |
| details.metric | Name of the monitor |
| details.current_value | Current value of the monitor |
| details.threshold | Alert trigger threshold of the monitor |
| actions | Actions to be taken. Link to view the monitor. |
## Discord
Discord triggers are used to send a message to a discord channel when a monitor goes down or up.
<div class="border rounded-md">
![Trigger API](/trig_2.png)
</div>
### Discord URL
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The Discord URL is used to define the URL of the discord webhook. It is required and has to be a valid URL.
#### How to get the Discord URL?
1. Go to your discord server
2. Right-click on the channel you want to send the messages
3. Click on `Edit Channel`
4. Go to `Integrations`
5. Click on `Create Webhook`
6. Copy the URL
#### Discord Message
The discord message when alert is `TRIGGERED` will look like this
![Discord](/discord.png)
The discord message when alert is `RESOLVED` will look like this
![Discord](/discord_resolved.png)
## Slack
Slack triggers are used to send a message to a slack channel when a monitor goes down or up.
<div class="border rounded-md">
![Trigger API](/trig_3.png)
</div>
### Slack URL
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The Slack URL is used to define the URL of the slack webhook. It is required and has to be a valid URL.
#### How to get the Slack URL?
1. Go to your slack workspace
2. Click on `Apps` on the left sidebar
3. Search for `Incoming Webhooks`
4. Click on `Add to Slack`
5. Select the channel you want to send the messages
6. Click on `Add Incoming Webhook Integration`
7. Copy the URL
#### Slack Message
The slack message when alert is `TRIGGERED` will look like this
![Slack](/slack.png)
The slack message when alert is `RESOLVED` will look like this
![Slack](/slack_resolved.png)
## Email
Email triggers are used to send an email when a monitor goes down or up.
<div class="border rounded-md">
![Trigger API](/trig_4.png)
</div>
<div class="border px-2 rounded-md mt-4">
#### Note
Please make sure you have set the `RESEND_API_KEY` in the environment variables.
</div>
### To
<span class="text-red-500 text-xs font-semibold">
REQUIRED
</span>
The email addresses to which the email should be sent. It is required and has to be a valid email addresses. You can pass multiple email addresses separated by a comma.
### Sender
The email address from which the email should be sent.
It should be in the format `Name <email@address.com>`
If you have not connected your domain with resend, then use `Some Name <onboarding@resend.dev>`
### Subject
Subject of the email when `TRIGGERED`
```text
[TRIGGERED] Mockoon DOWN at 2024-12-27T04:42:01.430Z
```
Subject of the email when `RESOLVED`
```text
[RESOLVED] Mockoon DOWN at 2024-12-27T04:42:01.430Z
```
### Body
The emaik message when alert is `TRIGGERED` will look like this
![Slack](/em_t.png)
The emaik message when alert is `RESOLVED` will look like this
![Slack](/em_r.png)
---
## Edit Triggers
Click on the ⚙️ to edit the trigger.
### Deactivate Trigger
You can deactivate the trigger by switching the toggle to off. You cannot send message to a deactivated trigger. Any monitor with this trigger will not send any notifications.
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
body {
background-color: #111;
}
</style>
</head>
<body>
Hello world
<script
async
src="http://localhost:3000/embed/monitor-earth/js?theme=dark&bgc=111&locale=hi&monitor=http://localhost:3000/embed/monitor-earth"
></script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
// @ts-nocheck
import dotenv from "dotenv";
dotenv.config();
const databaseURL = process.env.DATABASE_URL || "sqlite://./database/kener.sqlite.db";
const databaseURLParts = databaseURL.split("://");
const databaseType = databaseURLParts[0];
const databasePath = databaseURLParts[1];
const knexOb = {
migrations: {
directory: "./migrations"
},
seeds: {
directory: "./seeds"
}
};
if (databaseType === "sqlite") {
knexOb.client = "better-sqlite3";
knexOb.connection = {
filename: databasePath
};
knexOb.useNullAsDefault = true;
} else if (databaseType === "postgresql") {
knexOb.client = "pg";
knexOb.connection = databaseURL;
} else if (databaseType === "mysql") {
knexOb.client = "mysql2";
knexOb.connection = databaseURL;
} else {
console.error("Invalid database type");
process.exit(1);
}
export default knexOb;
-57
View File
@@ -1,57 +0,0 @@
{
"root": {
"ongoing_incidents": "Ongoing Incidents",
"availability_per_component": "Availability per Component",
"other_monitors": "Other Monitors",
"no_monitors": "No monitors found",
"read_doc_monitor": "Read the documentation to add your first monitor",
"here": "here",
"category": "Category",
"incident": "Incident",
"incidents": "Incidents",
"no_recent_incident": "No recent incident",
"recent_incidents": "Recent Incidents",
"active_incidents": "Active Incidents",
"no_active_incident": "No Active Incident",
"last_x_hours": "Last %hours hours"
},
"statuses": {
"UP": "UP",
"DOWN": "DOWN",
"DEGRADED": "DEGRADED"
},
"incident": {
"identified": "Identified",
"resolved": "Resolved",
"maintenance": "Maintenance"
},
"monitor": {
"share": "Share",
"badge": "Badge",
"embed": "Embed",
"mode": "Mode",
"status": "Status",
"copied": "Copied",
"uptime": "Uptime",
"theme": "Theme",
"theme_light": "Light",
"theme_dark": "Dark",
"today": "Today",
"90_day": "90 Day",
"share_desc": "Share this monitor using a link with others",
"badge_desc": "Get SVG badge for this monitor",
"embed_desc": "Embed this monitor using <script> or <iframe> in your app.",
"cp_link": "Copy Link",
"cpd_link": "Link Copied",
"cp_code": "Copy Code",
"cpd_code": "Code Copied",
"status_x_minute": "%status for %minute minute",
"status_x_minutes": "%status for %minutes minutes",
"status_x_hour_y_minute": "%status for %hours h and %minutes m",
"status_no_data": "No Data",
"status_ok": "Status OK",
"am": "am",
"pm": "pm"
},
"numbers": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
}
-57
View File
@@ -1,57 +0,0 @@
{
"incident": {
"identified": "डेंटिफ़िएड",
"maintenance": "मेंटेनेंस",
"resolved": "रेसोल्वेड"
},
"monitor": {
"90_day": "९० दिन",
"am": "ऍम",
"badge": "बैज",
"badge_desc": "इस मॉनिटर के लिए SVG बैज प्राप्त करें",
"copied": "कोपीएड",
"cp_code": "कॉपी कोड",
"cp_link": "लिंक कॉपी करें",
"cpd_code": "कोड कॉपी किया गया",
"cpd_link": "लिंक कॉपी किया गया",
"embed": "एम्बेड",
"embed_desc": "इस मॉनीटर को एम्बेड करें<script> या<iframe> अपने ऐप में.",
"mode": "मोड",
"pm": "पं",
"share": "शेयर",
"share_desc": "लिंक का उपयोग करके इस मॉनिटर को अन्य लोगों के साथ शेयर करें",
"status": "स्टेटस",
"status_no_data": "कोई डेटा नहीं",
"status_ok": "स्थिति ठीक है",
"status_x_hour_y_minute": "%hours घंटा %minutes मिनट के लिए %status",
"status_x_minute": "%minute मिनट के लिए %status",
"status_x_minutes": "%minutes मिनट के लिए %status",
"theme": "थीम",
"theme_dark": "डार्क",
"theme_light": "लाइट",
"today": "आज",
"uptime": "अपटाइम"
},
"numbers": ["", "१", "२", "३", "४", "५", "६", "७", "८", "९"],
"root": {
"active_incidents": "सक्रिय घटनाएं",
"availability_per_component": "प्रति कॉम्पोनेन्ट उपलब्धता",
"category": "श्रेणी",
"here": "यहाँ",
"incident": "हादसा",
"incidents": "घटनाएं",
"last_x_hours": "अंतिम %hours घंटे",
"no_active_incident": "कोई सक्रिय घटना नहीं",
"no_monitors": "कोई मॉनिटर नहीं मिला",
"no_recent_incident": "कोई हालिया घटना नहीं",
"ongoing_incidents": "चल रही घटनाएँ",
"other_monitors": "अन्य मॉनिटर",
"read_doc_monitor": "अपना पहला मॉनिटर जोड़ने के लिए डॉक्यूमेंटेशन पढ़ें",
"recent_incidents": "हाल की घटनाएँ"
},
"statuses": {
"DEGRADED": "डेग्रेडेड",
"DOWN": "डाउन",
"UP": "उप"
}
}
-57
View File
@@ -1,57 +0,0 @@
{
"root": {
"ongoing_incidents": "進行中のインシデント",
"availability_per_component": "コンポーネントごとの可用性",
"other_monitors": "その他のモニター",
"no_monitors": "モニターはありません",
"read_doc_monitor": "最初のモニターを追加するには、ドキュメントをお読みください",
"here": "ここ",
"category": "カテゴリー",
"incident": "インシデント",
"incidents": "インシデント",
"no_recent_incident": "最近のインシデントはありません",
"recent_incidents": "最近のインシデント",
"active_incidents": "現在のインシデント",
"no_active_incident": "現在のインシデントはありません",
"last_x_hours": "最近%hours時間"
},
"statuses": {
"UP": "正常",
"DOWN": "ダウン",
"DEGRADED": "縮退"
},
"incident": {
"identified": "確認済み",
"resolved": "解決済み",
"maintenance": "メンテナンス"
},
"monitor": {
"share": "シェア",
"badge": "バッジ",
"embed": "埋め込み",
"mode": "モード",
"status": "ステータス",
"copied": "コピーしました",
"uptime": "稼働時間",
"theme": "テーマ",
"theme_light": "ライト",
"theme_dark": "ダーク",
"today": "今日",
"90_day": "90日間",
"share_desc": "このモニターをリンクで他の人にシェア",
"badge_desc": "このモニターのSVGバッジを取得",
"embed_desc": "このモニターを <script> や <iframe> で埋め込む",
"cp_link": "リンクをコピー",
"cpd_link": "リンクをコピーしました",
"cp_code": "コードをコピー",
"cpd_code": "コードをコピーしました",
"status_x_minute": "%minute分間の%status",
"status_x_minutes": "%minutes分間の%status",
"status_x_hour_y_minute": "%hours時間%minutes分間の%status",
"status_no_data": "データはありません",
"status_ok": "正常",
"am": "午前",
"pm": "午後"
},
"numbers": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
}
-57
View File
@@ -1,57 +0,0 @@
{
"root": {
"ongoing_incidents": "正在进行的事件",
"availability_per_component": "每个服务的可用性",
"other_monitors": "其他显示器",
"no_monitors": "未找到显示器",
"read_doc_monitor": "阅读文档以添加您的第一个显示器",
"here": "这里",
"category": "类别",
"incident": "事件",
"incidents": "事件",
"no_recent_incident": "最近没有发生事件",
"recent_incidents": "最近发生的事件",
"active_incidents": "活跃事件",
"no_active_incident": "没有活跃事件",
"last_x_hours": "最近%hours个小时"
},
"statuses": {
"UP": "正常",
"DOWN": "故障",
"DEGRADED": "异常"
},
"incident": {
"identified": "确认",
"resolved": "解决",
"maintenance": "维护"
},
"monitor": {
"share": "分享",
"badge": "徽章",
"embed": "嵌入",
"mode": "模式",
"status": "状态",
"copied": "已复制",
"uptime": "正常运行时间",
"theme": "主题",
"theme_light": "Light",
"theme_dark": "Dark",
"today": "今天",
"90_day": "90 天",
"share_desc": "使用链接与其他人共享此显示器",
"badge_desc": "获取此显示器的 SVG 徽章",
"embed_desc": "在您的应用程序中使用 <script> 或 <iframe> 嵌入此监视器。",
"cp_link": "复制链接",
"cpd_link": "链接已复制",
"cp_code": "复制代码",
"cpd_code": "代码已复制",
"status_x_minute": "%minute分钟的%status",
"status_x_minutes": "%minutes分钟的%status",
"status_x_hour_y_minute": "%hours小时%minutes分钟的%status",
"status_no_data": "没有数据",
"status_ok": "状态正常",
"am": "上午",
"pm": "下午"
},
"numbers": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
}
+94
View File
@@ -0,0 +1,94 @@
import { handler } from "./build/handler.js";
import { apiReference } from "@scalar/express-api-reference";
import dotenv from "dotenv";
dotenv.config();
import express from "express";
import Startup from "./src/lib/server/startup.js";
import fs from "fs-extra";
import knex from "knex";
import knexOb from "./knexfile.js";
const PORT = process.env.PORT || 3000;
const base = process.env.KENER_BASE_PATH || "";
const app = express();
const db = knex(knexOb);
app.use((req, res, next) => {
if (req.path.startsWith("/embed")) {
res.setHeader("Content-Security-Policy", "frame-ancestors *");
}
res.setHeader("X-Powered-By", "Kener");
next();
});
app.get(base + "/healthcheck", (req, res) => {
res.end("ok");
});
//part /uploads server static files from static/uploads
//set env variable for upload path
process.env.UPLOAD_PATH = "./uploads";
app.use(base + "/uploads", express.static("uploads"));
try {
const openapiJSON = fs.readFileSync("./openapi.json", "utf-8");
app.use(
"/api-reference",
apiReference({
spec: {
content: openapiJSON
},
theme: "alternate",
hideModels: true,
hideTestRequestButton: true,
darkMode: true,
metaData: {
title: "Kener API Reference",
description: "Kener free open source status page API Reference",
ogDescription: "Kener free open source status page API Reference",
ogTitle: "Kener API Reference",
ogImage: "https://kener.ing/newbg.png",
twitterCard: "summary_large_image",
twitterTitle: "Kener API Reference",
twitterDescription: "Kener free open source status page API Reference",
twitterImage: "https://kener.ing/newbg.png"
},
favicon: "https://kener.ing/logo96.png"
})
);
} catch (e) {
console.warn("Error loading openapi.json, but that is okay.");
}
app.use(handler);
//migrations
async function runMigrations() {
try {
console.log("Running migrations...");
await db.migrate.latest(); // Runs migrations to the latest state
console.log("Migrations completed successfully!");
} catch (err) {
console.error("Error running migrations:", err);
}
}
//seed
async function runSeed() {
try {
console.log("Running seed...");
await db.seed.run(); // Runs seed to the latest state
console.log("Seed completed successfully!");
} catch (err) {
console.error("Error running seed:", err);
}
}
app.listen(PORT, async () => {
await runMigrations();
await runSeed();
await db.destroy();
Startup();
console.log("Kener is running on port " + PORT + "!");
});
+135
View File
@@ -0,0 +1,135 @@
// migrations/YYYYMMDDHHMMSS_create_monitoring_tables.js
export function up(knex) {
return (
knex.schema
// Create monitoring_data table
.createTable("monitoring_data", (table) => {
table.string("monitor_tag", 255).notNullable();
table.integer("timestamp").notNullable();
table.text("status");
table.float("latency", 8, 2);
table.text("type");
table.primary(["monitor_tag", "timestamp"]);
})
// Create monitor_alerts table
.createTable("monitor_alerts", (table) => {
table.increments("id").primary();
table.string("monitor_tag", 255).notNullable();
table.string("monitor_status", 255).notNullable();
table.string("alert_status", 255).notNullable();
table.integer("health_checks").notNullable();
table.integer("incident_number").defaultTo(0);
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
})
// Add index to monitor_alerts table
.raw(
"CREATE INDEX idx_monitor_tag_created_at ON monitor_alerts (monitor_tag, created_at)"
)
.createTable("site_data", (table) => {
table.increments("id").primary();
table.string("key", 255).notNullable().unique();
table.text("value").notNullable();
table.string("data_type", 255).notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
})
.createTable("monitors", (table) => {
table.increments("id").primary();
table.string("tag", 255).notNullable().unique();
table.string("name", 255).notNullable().unique();
table.text("description");
table.text("image");
table.string("cron", 255);
table.string("default_status", 255);
table.string("status", 255);
table.string("category_name", 255);
table.string("monitor_type", 255);
table.string("down_trigger", 255);
table.string("degraded_trigger", 255);
table.text("type_data");
table.integer("day_degraded_minimum_count");
table.integer("day_down_minimum_count");
table.string("include_degraded_in_downtime", 255).defaultTo("NO");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
})
.createTable("triggers", (table) => {
table.increments("id").primary();
table.string("name", 255).notNullable().unique();
table.string("trigger_type", 255);
table.text("trigger_desc");
table.string("trigger_status", 255);
table.text("trigger_meta");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
})
.createTable("users", (table) => {
table.increments("id").primary();
table.string("email", 255).notNullable().unique();
table.string("name", 255).notNullable();
table.string("password_hash", 255).notNullable();
table.integer("is_active").defaultTo(1);
table.integer("is_verified").defaultTo(0);
table.string("role", 255).defaultTo("user");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
})
.createTable("api_keys", (table) => {
table.increments("id").primary();
table.string("name", 255).notNullable().unique();
table.string("hashed_key", 255).notNullable().unique();
table.string("masked_key", 255).notNullable();
table.string("status", 255).defaultTo("ACTIVE");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
})
.createTable("incidents", (table) => {
table.increments("id").primary();
table.string("title", 255).notNullable();
table.integer("start_date_time").notNullable();
table.integer("end_date_time");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
table.string("status", 255).defaultTo("ACTIVE");
table.string("state", 255).defaultTo("INVESTIGATING");
})
.createTable("incident_monitors", (table) => {
table.increments("id").primary();
table.string("monitor_tag", 255).notNullable();
table.string("monitor_impact", 255);
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
table.integer("incident_id").notNullable();
table.unique(["monitor_tag", "incident_id"]);
})
.createTable("incident_comments", (table) => {
table.increments("id").primary();
table.text("comment").notNullable();
table.integer("incident_id").notNullable();
table.integer("commented_at").notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
table.string("status", 255).defaultTo("ACTIVE");
table.string("state", 255).defaultTo("INVESTIGATING");
})
);
}
export function down(knex) {
return (
knex.schema
// Drop tables in reverse order
.dropTableIfExists("monitor_alerts")
.dropTableIfExists("monitoring_data")
.dropTableIfExists("site_data")
.dropTableIfExists("monitors")
.dropTableIfExists("triggers")
.dropTableIfExists("users")
.dropTableIfExists("api_keys")
.dropTableIfExists("incidents")
.dropTableIfExists("incident_monitors")
.dropTableIfExists("incident_comments")
);
}
+1102
View File
File diff suppressed because it is too large Load Diff
+804
View File
@@ -0,0 +1,804 @@
---
info:
title: Kener API
version: 3.0.0
description: |
# Kener Selfhost node js status page
![Kener](https://kener.ing/newbg.png "kener")
API specification for Kener status page and incident management system. This API spec was created using [Frogment](https://www.frogment.app)
contact:
name: Raj Nandan Sharma
email: rajnandan1@gmail.com
url: https://github.com/rajnandan1/kener/issues
license:
name: MIT
url: https://opensource.org/licenses/MIT
openapi: 3.0.0
servers:
- url: https://{host}
description: Kener host URL
tags:
- name: Monitors
description: APIs to interact with monitors
- name: Incidents
description: APIs to integrate incidents
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: enter your api key here
schemas:
MonitorStatus:
type: object
description: Monitor Status
required:
- status
- latency
- tag
properties:
status:
type: string
example: UP
enum:
- UP
- DOWN
- DEGRADED
latency:
type: number
description: In seconds
example: 100
timestampInSeconds:
type: integer
description: UTC timestamp in seconds
example: 1731251760
tag:
type: string
example: earth
description: Tag of a monitor
example:
status: UP
timestampInSeconds: 1731251760
latency: 100
tag: earth
StatusResponse:
type: object
description: Status of a monitor given a tag
properties:
status:
type: string
example: UP
enum:
- UP
- DOWN
- DEGRADED
uptime:
type: string
example: '100'
last_updated_at:
type: integer
example: 1731251760
example:
status: UP
last_updated_at: 1731251760
uptime: '100'
Incident:
type: object
description: body of an incident
properties:
start_date_time:
type: integer
description: UTC timestamp in seconds
example: 1731901920
end_date_time:
type: integer
description: UTC timestamp in seconds
nullable: true
example: 1704123938
title:
type: string
example: Outage in mumbai
description: title of the incident
created_at:
type: string
example: '2025-01-09 04:12:01'
description: created at time
updated_at:
type: string
example: '2025-01-09 06:10:00'
description: updated at time
status:
type: string
example: OPEN
description: delete or not deleted incident
enum:
- OPEN
- CLOSED
state:
type: string
description: the current status of the incident
example: INVESTIGATING
enum:
- INVESTIGATING
- IDENTIFIED
- MONITORING
- RESOLVED
example:
start_date_time: 1731901920
end_date_time: 1704123938
title: title of the incident
created_at: '2025-01-09 04:12:01'
updated_at: '2025-01-09 04:12:01'
state: INVESTIGATING
status: OPEN
id: 4
IncidentResponse:
description: Incident response schema
allOf:
- "$ref": "#/components/schemas/Incident"
Comment:
type: object
description: Comment of an incident
properties:
comment:
type: string
example: comment 1
commented_at:
type: integer
example: 1736398336
state:
type: string
example: IDENTIFIED
example:
body: comment 2
commented_at: 1736398336
state: IDENTIFIED
CommentResponse:
type: object
description: Comment Response
properties:
id:
type: integer
description: ID of the comment
example: 60
comment:
type: string
description: The content of the comment
example: Sometimes, you want all the goodness of moment#from but you don't
want to have to create two moments, you just want to display a length
of time.
incident_id:
type: integer
description: ID of the associated incident
example: 24
commented_at:
type: integer
description: Timestamp when the comment was added
example: 1736398336
created_at:
type: string
format: date-time
description: Timestamp when the comment was created in ISO 8601 format
example: '2025-01-09 04:52:16'
updated_at:
type: string
format: date-time
description: Timestamp when the comment was last updated in ISO 8601 format
example: '2025-01-09 04:52:16'
status:
type: string
description: Current status of the comment
example: ACTIVE
state:
type: string
description: the current status of the incident
example: INVESTIGATING
enum:
- INVESTIGATING
- IDENTIFIED
- MONITORING
- RESOLVED
example:
id: 60
comment: Sometimes, you want all the goodness of moment
incident_id: 24
commented_at: 1736398336
created_at: '2025-01-09 04:52:16'
updated_at: '2025-01-09 04:52:16'
status: ACTIVE
state: MONITORING
IncidentStatus:
type: object
description: Status of the incident
properties:
isIdentified:
type: boolean
description: Has the incident been indetified
example: true
isResolved:
type: boolean
description: has the incident been resolved
example: true
endDatetime:
type: integer
description: Incident end time
example: 1731901920
example:
isIdentified: true
isResolved: true
endDatetime: 1731901920
IncidentCreateRequest:
title: Incident Create Request Body
type: object
description: body of an incident
required:
- title
- start_date_time
properties:
start_date_time:
type: integer
description: UTC timestamp in seconds
example: 1731901920
title:
type: string
example: Outage in mumbai
description: title of the incident
example:
start_date_time: 1731901920
title: title of the incident
responses:
Response401:
description: Bad API keys response
content:
application/json:
schema:
type: object
properties:
error:
type: string
description: Invalid token response
example: invalid token
example:
error: invalid token
Response400:
description: bad request
content:
application/json:
schema:
type: object
properties:
error:
type: string
description: bad request while calling kener apis
example: unknown tags
example:
error: unknown tags
examples:
GetMonitorStatusExample200:
summary: response of get status of a monitor
description: Some Description
value:
status: UP
uptime: '100'
lastUpdatedAt: 1731901920
DegradedRequestBody:
summary: update to degraded
description: Some Description
value:
status: DEGRADED
timestampInSeconds: 1731251760
latency: 100
tag: earth
CreateIncidentResponse:
summary: create incident response
description: create incident response
value:
start_date_time: 1731901920
end_date_time: 1731901920
id: 4
created_at: '2025-01-09 04:12:01'
updated_at: '2025-01-10 04:12:01'
title: title of the incident
status: OPEN
state: INVESTIGATING
CreateIncidentRequest:
summary: create incident request body
description: create incident request body
value:
start_date_time: 1731901920
title: title of the incident
SearchIncidentsResponse:
summary: array of incidents
description: Some Description
value:
- id: 2
title: future wala
start_date_time: 1736774486
end_date_time: 1737033787
created_at: '2025-01-12 13:21:32'
updated_at: '2025-01-12 13:23:15'
status: OPEN
state: RESOLVED
- id: 1
title: internatiinal asda
start_date_time: 1736684329
end_date_time:
created_at: '2025-01-12 12:18:53'
updated_at: '2025-01-12 13:21:32'
status: OPEN
state: INVESTIGATING
CommentsResponse:
summary: list of comments
description: Some Description
value:
- id: 58
comment: idensad
incident_id: 24
commented_at: 1736398295
created_at: '2025-01-09 04:51:37'
updated_at: '2025-01-09 04:51:37'
status: ACTIVE
state: IDENTIFIED
- id: 57
comment: Sometimes, you want all the goodness of moment#from but you don't
want to have to create two moments, you just want to display a length of
time.
incident_id: 24
commented_at: 1736398279
created_at: '2025-01-09 04:51:19'
updated_at: '2025-01-09 04:51:19'
status: ACTIVE
state: INVESTIGATING
CommentRequestBody:
summary: request body for a update
description: request body for a update to add in an incident
value:
comment: This is a comment
commented_at: 1736398336
state: IDENTIFIED
CreateCommentResponse:
summary: create update response body sample
description: create update response body sample
value:
id: 60
comment: Sometimes, you want all the goodness of moment
incident_id: 24
commented_at: 1736398336
created_at: '2025-01-09 04:52:16'
updated_at: '2025-01-09 04:52:16'
status: ACTIVE
state: MONITORING
paths:
"/api/status":
post:
operationId: updateMonitorStatus
summary: Update status of a monitor
description: Update status of a monitor at a given timestamp UTC
tags:
- Monitors
security:
- bearerAuth: []
requestBody:
required: true
description: request body to update status of a monitor
content:
application/json:
schema:
"$ref": "#/components/schemas/MonitorStatus"
examples:
degraded:
"$ref": "#/components/examples/DegradedRequestBody"
responses:
'200':
description: Status updated successfully
content:
application/json:
schema:
type: object
properties:
status:
type: integer
example: 200
message:
type: string
example: success at 1731251760
example:
status: 200
message: success at 1731251760
'400':
"$ref": "#/components/responses/Response400"
'401':
"$ref": "#/components/responses/Response401"
get:
operationId: getMonitorStatus
summary: Get status of a monitor
description: get status of a monitor at timestamp
tags:
- Monitors
security:
- bearerAuth: []
parameters:
- name: tag
in: query
required: true
description: monitor tag to get status of it
schema:
type: string
example: earth
responses:
'200':
description: Monitor status retrieved successfully
content:
application/json:
schema:
"$ref": "#/components/schemas/StatusResponse"
examples:
successExample:
"$ref": "#/components/examples/GetMonitorStatusExample200"
'400':
"$ref": "#/components/responses/Response400"
'401':
"$ref": "#/components/responses/Response401"
"/api/incident":
post:
operationId: createIncident
summary: Create a new incident
description: API to create incidents
tags:
- Incidents
security:
- bearerAuth: []
requestBody:
required: true
description: request body to manually create an incident
content:
application/json:
schema:
"$ref": "#/components/schemas/IncidentCreateRequest"
examples:
sample:
"$ref": "#/components/examples/CreateIncidentRequest"
responses:
'200':
description: Incident created successfully
content:
application/json:
schema:
"$ref": "#/components/schemas/IncidentResponse"
examples:
success:
"$ref": "#/components/examples/CreateIncidentResponse"
'400':
"$ref": "#/components/responses/Response400"
'401':
"$ref": "#/components/responses/Response401"
get:
operationId: searchIncidents
summary: Search for incidents
description: API to get incidents
tags:
- Incidents
security:
- bearerAuth: []
parameters:
- name: status
in: query
description: status of the incident. Can be open or close. close means deleted
schema:
type: string
description: status of the incident. Can be open or close. close means deleted
enum:
- OPEN
- CLOSED
default: OPEN
example: OPEN
- name: state
in: query
description: state of the incident. Can be open or close
schema:
type: string
description: state of the incident. Can be open or close
enum:
- INVESTIGATING
- IDENTIFIED
- MONITORING
- RESOLVED
default: INVESTIGATING
example: IDENTIFIED
- name: page
in: query
description: page number
schema:
type: integer
description: page number
default: 1
minimum: 1
example: 1
- name: limit
in: query
description: how many per page
schema:
type: integer
default: 10
description: how many per page
maximum: 100
example: 10
- name: start_date_time
description: start time
in: query
schema:
type: integer
description: start time
example: 1731866475
- name: end_date_time
description: end time
in: query
schema:
type: integer
description: end time
example: 1731866475
responses:
'200':
description: Search results retrieved successfully
content:
application/json:
schema:
type: array
items:
"$ref": "#/components/schemas/IncidentResponse"
examples:
success:
"$ref": "#/components/examples/SearchIncidentsResponse"
'400':
"$ref": "#/components/responses/Response400"
'401':
"$ref": "#/components/responses/Response401"
"/api/incident/{incident_id}":
parameters:
- name: incident_id
in: path
required: true
description: incident id as an integer to get incident by id
schema:
type: integer
example: 4
get:
operationId: getIncident
summary: Get an incident by id
description: API to get an incident by incident id
tags:
- Incidents
security:
- bearerAuth: []
responses:
'200':
description: Incident retrieved successfully
content:
application/json:
schema:
"$ref": "#/components/schemas/IncidentResponse"
examples:
success:
"$ref": "#/components/examples/CreateIncidentResponse"
'400':
"$ref": "#/components/responses/Response400"
'401':
"$ref": "#/components/responses/Response401"
patch:
operationId: updateIncident
summary: Update an incident
description: API to update an incident by incident id
tags:
- Incidents
security:
- bearerAuth: []
requestBody:
required: true
description: search for an incident
content:
application/json:
schema:
"$ref": "#/components/schemas/Incident"
examples:
sample:
"$ref": "#/components/examples/CreateIncidentRequest"
responses:
'200':
description: Incident updated successfully
content:
application/json:
schema:
"$ref": "#/components/schemas/IncidentResponse"
examples:
success:
"$ref": "#/components/examples/CreateIncidentResponse"
'400':
"$ref": "#/components/responses/Response400"
'401':
"$ref": "#/components/responses/Response401"
"/api/incident/{incident_id}/updates":
parameters:
- name: incident_id
in: path
required: true
description: incident id to fetch comment for
schema:
type: integer
example: 4
post:
operationId: addIncidentComment
summary: Add an update to an incident
description: API to create update for an incident
tags:
- Incidents
security:
- bearerAuth: []
requestBody:
required: true
description: body to add an update
content:
application/json:
schema:
"$ref": "#/components/schemas/Comment"
examples:
sample:
"$ref": "#/components/examples/CommentRequestBody"
responses:
'200':
description: Comment added successfully
content:
application/json:
schema:
"$ref": "#/components/schemas/CommentResponse"
examples:
sample:
"$ref": "#/components/examples/CreateCommentResponse"
'400':
"$ref": "#/components/responses/Response400"
'401':
"$ref": "#/components/responses/Response401"
get:
operationId: getIncidentComments
summary: Get updates for an incident
description: API to get updates for an incident
tags:
- Incidents
security:
- bearerAuth: []
responses:
'200':
description: updates retrieved successfully
content:
application/json:
schema:
type: array
items:
"$ref": "#/components/schemas/CommentResponse"
examples:
success:
"$ref": "#/components/examples/CommentsResponse"
'400':
"$ref": "#/components/responses/Response400"
'401':
"$ref": "#/components/responses/Response401"
"/api/incident/{incident_id}/monitors":
parameters:
- name: incident_id
in: path
required: true
description: incident id to fetch comment for
schema:
type: integer
example: 4
get:
summary: Get monitors for an incident
operationId: getIncidentMonitors
tags:
- Incidents
parameters:
- name: incident_id
in: path
required: true
schema:
type: integer
description: The ID of the incident
responses:
'200':
description: List of monitors for the incident
content:
application/json:
schema:
type: array
items:
type: object
properties:
monitor_tag:
type: string
description: The tag of the monitor
example: earth
monitor_impact:
type: string
description: The impact status of the monitor
example: DOWN
security:
- bearerAuth: []
post:
summary: Add a monitor to an incident
operationId: addIncidentMonitor
tags:
- Incidents
parameters:
- name: incident_id
in: path
required: true
schema:
type: integer
description: The ID of the incident
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
tag:
type: string
description: The tag for the monitor
example: okbookmarks
impact:
type: string
description: The impact status of the monitor
example: DEGRADED
responses:
'200':
description: Monitor added successfully
content:
application/json:
schema:
type: array
items:
type: object
properties:
monitor_tag:
type: string
description: The tag of the monitor
example: okbookmarks
monitor_impact:
type: string
description: The impact status of the monitor
example: DEGRADED
security:
- bearerAuth: []
delete:
summary: Delete a monitor from an incident
operationId: deleteIncidentMonitor
tags:
- Incidents
parameters:
- name: incident_id
in: path
required: true
schema:
type: integer
description: The ID of the incident
- name: tag
in: query
required: true
schema:
type: string
description: The tag of the monitor to delete
responses:
'200':
description: Monitor deleted successfully
content:
application/json:
schema:
type: object
properties:
message:
type: string
description: Confirmation message
example: Monitor deleted successfully
security:
- bearerAuth: []
+7876 -4111
View File
File diff suppressed because it is too large Load Diff
+108 -71
View File
@@ -1,73 +1,110 @@
{
"name": "kener",
"version": "0.0.14",
"private": false,
"license": "MIT",
"description": "Kener: An open-source Node.js status page application for real-time service monitoring, incident management, and customizable reporting. Simplify service outage tracking, enhance incident communication, and ensure a seamless user experience.",
"author": "Raj Nandan Sharma <rajnandan1@gmail.com>",
"keywords": [
"Node.js application",
"Open-source status page",
"Service monitoring tool",
"Real-time incident management",
"Customizable reporting",
"Service outage tracker",
"User-friendly dashboard",
"Incident communication platform",
"Scalable monitoring solution",
"Community-driven software",
"Website status tracker",
"Incident response tool",
"System status monitoring",
"Service reliability management",
"Incident alert system"
],
"repository": {
"type": "git",
"url": "https://github.com/rajnandan1/kener.git"
},
"scripts": {
"build": "node scripts/check.js && vite build",
"serve": "node prod.js",
"kener:dev": "cross-env NODE_ENV=development PUBLIC_KENER_FOLDER=./static/kener node scripts/check.js && concurrently \"cross-env NODE_ENV=development PUBLIC_KENER_FOLDER=./static/kener node dev.js\" \"cross-env NODE_ENV=development PUBLIC_KENER_FOLDER=./static/kener vite dev\"",
"kener:dev-monitor": "cross-env NODE_ENV=development PUBLIC_KENER_FOLDER=./static/kener node dev.js",
"kener:build": "cross-env NODE_ENV=production node scripts/check.js && cross-env NODE_ENV=production vite build",
"kener": "cross-env NODE_ENV=production node prod.js"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/adapter-node": "^1.3.1",
"@sveltejs/kit": "^1.27.4",
"@tailwindcss/typography": "^0.5.10",
"autoprefixer": "^10.4.14",
"concurrently": "^8.2.2",
"cross-env": "^7.0.3",
"postcss": "^8.4.24",
"postcss-load-config": "^4.0.1",
"svelte": "^4.0.5",
"svelte-check": "^3.6.0",
"tailwindcss": "^3.3.2",
"typescript": "^5.0.0",
"vite": "^4.4.2"
},
"type": "module",
"dependencies": {
"axios": "^1.6.2",
"badge-maker": "^3.3.1",
"bits-ui": "^0.9.9",
"clsx": "^2.0.0",
"croner": "^7.0.5",
"express": "^4.18.2",
"fs-extra": "^11.1.1",
"js-yaml": "^4.1.0",
"lucide-svelte": "^0.292.0",
"marked": "^11.1.1",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
"node-cache": "^5.1.2",
"queue": "^7.0.0",
"randomstring": "^1.3.0",
"tailwind-merge": "^2.0.0",
"tailwind-variants": "^0.1.18"
}
"name": "kener",
"version": "3.0.3",
"private": false,
"license": "MIT",
"description": "Kener: An open-source Node.js status page application for real-time service monitoring, incident management, and customizable reporting. Simplify service outage tracking, enhance incident communication, and ensure a seamless user experience.",
"author": "Raj Nandan Sharma <rajnandan1@gmail.com>",
"keywords": [
"Node.js application",
"Open-source status page",
"Service monitoring tool",
"Real-time incident management",
"Customizable reporting",
"Service outage tracker",
"User-friendly dashboard",
"Incident communication platform",
"Scalable monitoring solution",
"Community-driven software",
"Website status tracker",
"Incident response tool",
"System status monitoring",
"Service reliability management",
"Incident alert system"
],
"repository": {
"type": "git",
"url": "https://github.com/rajnandan1/kener.git"
},
"scripts": {
"build": "vite build",
"preview": "vite preview",
"configure": "node build.js",
"migrate": "npx knex migrate:latest",
"preseed": "npx knex migrate:latest",
"seed": "npx knex seed:run",
"predev": "npm run seed",
"devschedule": "node src/lib/server/startup.js",
"schedule": "node src/lib/server/startup.js",
"development": "vite dev",
"dev": "npm-run-all --parallel devschedule development",
"prettify": "prettier --write .",
"start": "node main.js"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/adapter-node": "^1.3.1",
"@sveltejs/kit": "^1.27.4",
"@tailwindcss/typography": "^0.5.10",
"@zerodevx/svelte-toast": "^0.9.6",
"autoprefixer": "^10.4.14",
"concurrently": "^8.2.2",
"cross-env": "^7.0.3",
"date-picker-svelte": "^2.15.1",
"postcss": "^8.4.24",
"postcss-load-config": "^4.0.1",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.3",
"prettier-plugin-tailwindcss": "^0.5.14",
"svelte": "^4.0.5",
"svelte-awesome-color-picker": "^3.1.4",
"svelte-check": "^3.6.0",
"svelte-dnd-action": "^0.9.55",
"tailwindcss": "^3.3.2",
"typescript": "^5.0.0",
"vite": "^4.4.2"
},
"type": "module",
"dependencies": {
"@formkit/auto-animate": "^0.8.2",
"@number-flow/svelte": "^0.2.1",
"@scalar/express-api-reference": "^0.4.167",
"analytics": "^0.8.14",
"axios": "^1.6.2",
"badge-maker": "^3.3.1",
"bcrypt": "^5.1.1",
"better-sqlite3": "^11.5.0",
"bits-ui": "^0.9.9",
"clsx": "^2.0.0",
"croner": "^7.0.5",
"date-fns": "^4.1.0",
"dns2": "^2.1.0",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"figlet": "^1.8.0",
"fs-extra": "^11.1.1",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"knex": "^3.1.0",
"lucide-svelte": "^0.292.0",
"marked": "^11.1.1",
"mode-watcher": "^0.4.1",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
"mysql2": "^3.12.0",
"node-cache": "^5.1.2",
"npm-run-all": "^4.1.5",
"pg": "^8.13.1",
"pg-pool": "^3.7.0",
"ping": "^0.4.4",
"queue": "^7.0.0",
"randomstring": "^1.3.0",
"resend": "^4.0.1",
"svelte-legos": "^0.2.5",
"tailwind-merge": "^2.0.0",
"tailwind-variants": "^0.1.18"
},
"engines": {
"node": ">=20.0.0"
}
}
+6 -6
View File
@@ -2,12 +2,12 @@ const tailwindcss = require("tailwindcss");
const autoprefixer = require("autoprefixer");
const config = {
plugins: [
//Some plugins, like tailwindcss/nesting, need to run before Tailwind,
tailwindcss(),
//But others, like autoprefixer, need to run after,
autoprefixer,
],
plugins: [
//Some plugins, like tailwindcss/nesting, need to run before Tailwind,
tailwindcss(),
//But others, like autoprefixer, need to run after,
autoprefixer
]
};
module.exports = config;
-30
View File
@@ -1,30 +0,0 @@
import { handler } from "./build/handler.js";
import express from "express";
import { STATUS_OK } from "./scripts/check.js";
import { Startup } from "./scripts/startup.js";
import sitemap from "./scripts/sitemap.js";
const PORT = process.env.PORT || 3000;
console.log("STATUS_OK", STATUS_OK);
Startup();
const app = express();
app.use((req, res, next) => {
if (req.path.startsWith("/embed")) {
res.setHeader("X-Frame-Options", "None");
}
next();
});
app.get("/healthcheck", (req, res) => {
res.end("ok");
});
app.get("/sitemap.xml", (req, res) => {
res.setHeader("Content-Type", "application/xml");
res.end(sitemap);
});
app.use(handler);
app.listen(PORT, () => {
console.log("Kener is running on port " + PORT + "!");
});
-87
View File
@@ -1,87 +0,0 @@
import { FOLDER, FOLDER_MONITOR, FOLDER_SITE, ENV } from "./constants.js";
import { IsStringURLSafe } from "./tool.js";
import fs from "fs-extra";
let STATUS_OK = false;
if (!!process.env.PUBLIC_KENER_FOLDER) {
console.log(`✅ PUBLIC_KENER_FOLDER is ${process.env.PUBLIC_KENER_FOLDER}`);
} else {
console.log(`❌ process.env.PUBLIC_KENER_FOLDER is not set
Set PUBLIC_KENER_FOLDER as an environment variable. Value should be the path to a directory where kener will store its data.
Example:
export PUBLIC_KENER_FOLDER=${process.cwd()}/static/kener`
);
process.exit(1);
}
if (!fs.existsSync(FOLDER)) {
console.log(`❌ Directory does not exist\n\nRun:\nmkdir -p ${FOLDER}`);
process.exit(1);
}
if (!fs.existsSync(FOLDER_SITE)) {
fs.writeFileSync(FOLDER_SITE, JSON.stringify({}));
console.log("✅ site.json file created successfully!");
}
if (!fs.existsSync(FOLDER_MONITOR)) {
fs.writeFileSync(FOLDER_MONITOR, JSON.stringify([]));
console.log("✅ monitors.json file created successfully!");
}
if (ENV === undefined) {
console.log(`❗ process.env.NODE_ENV is not set`);
} else {
console.log(`✅ process.env.NODE_ENV is set. Value is ${ENV}`);
}
if(process.env.GH_TOKEN === undefined) {
console.log(`❗ GH_TOKEN is not set. Go to https://kener.ing/docs#h2github-setup to learn how to set it up`);
} else {
console.log(`✅ GH_TOKEN is set`);
}
if(process.env.API_TOKEN === undefined) {
console.log(`❗ API_TOKEN is not set. Go to https://kener.ing/docs#h2environment-variable to learn how to set it up`);
} else {
console.log(`✅ API_TOKEN is set`);
}
if (process.env.API_IP === undefined) {
console.log(`❗ API_IP is not set. Go to https://kener.ing/docs#h2environment-variable to learn how to set it up`);
} else {
console.log(`✅ API_IP is set`);
}
if (process.env.MONITOR_YAML_PATH === undefined) {
console.log(`❗ MONITOR_YAML_PATH is not set. Go to https://kener.ing/docs#h2environment-variable to learn how to set it up. Defaulting to config/monitors.yaml`);
} else {
console.log(`✅ MONITOR_YAML_PATH is set`);
}
if (process.env.SITE_YAML_PATH === undefined) {
console.log(`❗ SITE_YAML_PATH is not set. Go to https://kener.ing/docs#h2environment-variable to learn how to set it up. Defaulting to config/site.yaml`);
} else {
console.log(`✅ SITE_YAML_PATH is set`);
}
if (process.env.PORT === undefined) {
console.log(`❗ PORT is not set. Defaulting to 3000`);
} else {
console.log(`✅ PORT is set. Value is ${process.env.PORT}`);
}
if (process.env.KENER_BASE_PATH !== undefined) {
if (process.env.KENER_BASE_PATH[0] !== "/") {
console.log("❌ KENER_BASE_PATH should start with /");
process.exit(1);
}
if (process.env.KENER_BASE_PATH[process.env.KENER_BASE_PATH.length - 1] === "/") {
console.log("❌ KENER_BASE_PATH should not end with /");
process.exit(1);
}
if (!IsStringURLSafe(process.env.KENER_BASE_PATH.substr(1))) {
console.log("❌ KENER_BASE_PATH is not url safe");
process.exit(1);
}
}
STATUS_OK = true;
export { STATUS_OK };
-14
View File
@@ -1,14 +0,0 @@
// Define your constants
const FOLDER = process.env.PUBLIC_KENER_FOLDER;
const ENV = process.env.NODE_ENV;
const MONITOR = "./config/monitors.yaml";
const SITE = "./config/site.yaml";
const FOLDER_MONITOR = FOLDER + "/monitors.json";
const FOLDER_SITE = FOLDER + "/site.json";
const UP = "UP";
const DOWN = "DOWN";
const DEGRADED = "DEGRADED";
const API_TIMEOUT = 10 * 1000; // 10 seconds
// Export the constants
export { FOLDER, FOLDER_MONITOR, FOLDER_SITE, MONITOR, UP, DOWN, SITE, DEGRADED, API_TIMEOUT, ENV };
-308
View File
@@ -1,308 +0,0 @@
import axios from "axios";
import fs from "fs-extra";
import { UP, DOWN, DEGRADED } from "./constants.js";
import { GetNowTimestampUTC, GetMinuteStartNowTimestampUTC, GetMinuteStartTimestampUTC } from "./tool.js";
import { GetIncidents, GetEndTimeFromBody, GetStartTimeFromBody, CloseIssue } from "./github.js";
import Randomstring from "randomstring";
import Queue from "queue";
const Kener_folder = process.env.PUBLIC_KENER_FOLDER;
const apiQueue = new Queue({
concurrency: 10, // Number of tasks that can run concurrently
timeout: 10000, // Timeout in ms after which a task will be considered as failed (optional)
autostart: true, // Automatically start the queue (optional)
});
async function manualIncident(monitor, githubConfig){
let incidentsResp = await GetIncidents(monitor.tag, githubConfig, "open");
let manualData = {};
if (incidentsResp.length == 0) {
return manualData;
}
let timeDownStart = +Infinity;
let timeDownEnd = 0;
let timeDegradedStart = +Infinity;
let timeDegradedEnd = 0;
for (let i = 0; i < incidentsResp.length; i++) {
const incident = incidentsResp[i];
const incidentNumber = incident.number;
let start_time = GetStartTimeFromBody(incident.body);
let allLabels = incident.labels.map((label) => label.name);
if (allLabels.indexOf("incident-degraded") == -1 && allLabels.indexOf("incident-down") == -1) {
continue;
}
if (start_time === null) {
continue;
}
let newIncident = {
start_time: start_time,
};
let end_time = GetEndTimeFromBody(incident.body);
if (end_time !== null) {
newIncident.end_time = end_time;
if(end_time <= GetNowTimestampUTC() && incident.state === "open"){
//close the issue after 30 secs
setTimeout(async () => {
await CloseIssue(githubConfig, incidentNumber)
}, 30000)
}
} else {
newIncident.end_time = GetNowTimestampUTC();
}
//check if labels has incident-degraded
if (allLabels.indexOf("incident-degraded") !== -1) {
timeDegradedStart = Math.min(timeDegradedStart, newIncident.start_time);
timeDegradedEnd = Math.max(timeDegradedEnd, newIncident.end_time);
}
if (allLabels.indexOf("incident-down") !== -1) {
timeDownStart = Math.min(timeDownStart, newIncident.start_time);
timeDownEnd = Math.max(timeDownEnd, newIncident.end_time);
}
}
//start from start of minute if unix timeDownStart to timeDownEnd, step each minute
let start = GetMinuteStartTimestampUTC(timeDegradedStart);
let end = GetMinuteStartTimestampUTC(timeDegradedEnd);
for (let i = start; i <= end; i += 60) {
manualData[i] = {
status: DEGRADED,
latency: 0,
type: "manual",
};
}
start = GetMinuteStartTimestampUTC(timeDownStart);
end = GetMinuteStartTimestampUTC(timeDownEnd);
for (let i = start; i <= end; i += 60) {
manualData[i] = {
status: DOWN,
latency: 0,
type: "manual"
};
}
return manualData;
}
function replaceAllOccurrences(originalString, searchString, replacement) {
const regex = new RegExp(`\\${searchString}`, "g");
const replacedString = originalString.replace(regex, replacement);
return replacedString;
}
const apiCall = async (envSecrets, url, method, headers, body, timeout, monitorEval) => {
let axiosHeaders = {};
axiosHeaders["User-Agent"] = "Kener/0.0.1";
axiosHeaders["Accept"] = "*/*";
const start = Date.now();
//replace all secrets
for (let i = 0; i < envSecrets.length; i++) {
const secret = envSecrets[i];
if (!!body) {
body = replaceAllOccurrences(body, secret.find, secret.replace);
}
if (!!url) {
url = replaceAllOccurrences(url, secret.find, secret.replace);
}
if (!!headers) {
headers = replaceAllOccurrences(headers, secret.find, secret.replace);
}
}
if (!!headers) {
headers = JSON.parse(headers);
axiosHeaders = { ...axiosHeaders, ...headers };
}
const options = {
method: method,
headers: headers,
timeout: timeout,
transformResponse: (r) => r,
};
if (!!headers) {
options.headers = headers;
}
if (!!body) {
options.data = body;
}
let statusCode = 500;
let latency = 0;
let resp = "";
let timeoutError = false;
try {
let data = await axios(url, options);
statusCode = data.status;
resp = data.data;
} catch (err) {
if (err.message.startsWith("timeout of") && err.message.endsWith("exceeded")) {
timeoutError = true;
}
if (err.response !== undefined && err.response.status !== undefined) {
statusCode = err.response.status;
}
if (err.response !== undefined && err.response.data !== undefined) {
resp = err.response.data;
}
} finally {
const end = Date.now();
latency = end - start;
}
resp = Buffer.from(resp).toString("base64");
let evalResp = eval(monitorEval + `(${statusCode}, ${latency}, "${resp}")`);
if (evalResp === undefined || evalResp === null) {
evalResp = {
status: DOWN,
latency: latency,
type: "error",
};
} else if (evalResp.status === undefined || evalResp.status === null || [UP, DOWN, DEGRADED].indexOf(evalResp.status) === -1) {
evalResp = {
status: DOWN,
latency: latency,
type: "error",
};
} else {
evalResp.type = "realtime";
}
let toWrite = {
status: DOWN,
latency: latency,
type: "error",
};
if (evalResp.status !== undefined && evalResp.status !== null) {
toWrite.status = evalResp.status;
}
if (evalResp.latency !== undefined && evalResp.latency !== null) {
toWrite.latency = evalResp.latency;
}
if (evalResp.type !== undefined && evalResp.type !== null) {
toWrite.type = evalResp.type;
}
if (timeoutError) {
toWrite.type = "timeout";
}
return toWrite;
};
const getWebhookData = async (monitor) => {
let originalData = {};
let files = fs.readdirSync(Kener_folder);
files = files.filter((file) => file.startsWith(monitor.folderName + ".webhook"));
for (let i = 0; i < files.length; i++) {
const file = files[i];
let webhookData = {};
try {
let fd = fs.readFileSync(Kener_folder + "/" + file, "utf8");
webhookData = JSON.parse(fd);
for (const timestamp in webhookData) {
originalData[timestamp] = webhookData[timestamp];
}
//delete the file
fs.unlinkSync(Kener_folder + "/" + file);
} catch (error) {
console.error(error);
}
}
return originalData;
};
const updateDayData = async (mergedData, startOfMinute, monitor) => {
let dayData = JSON.parse(fs.readFileSync(monitor.path0Day, "utf8"));
for (const timestamp in mergedData) {
dayData[timestamp] = mergedData[timestamp];
}
let since = 24*91;
let mxBackDate = startOfMinute - since * 3600;
let _0Day = {};
for (const ts in dayData) {
const element = dayData[ts];
if (ts >= mxBackDate) {
_0Day[ts] = element;
}
}
//sort the keys
let keys = Object.keys(_0Day);
keys.sort();
let sortedDay0 = {};
keys.reverse().forEach((key) => {
sortedDay0[key] = _0Day[key];
});
try {
fs.writeFileSync(monitor.path0Day, JSON.stringify(sortedDay0, null, 2));
} catch (error) {
console.error(error);
}
};
const Minuter = async (envSecrets, monitor, githubConfig) => {
if (apiQueue.length > 0) console.log("Queue length is " + apiQueue.length);
let apiData = {};
let webhookData = {};
let manualData = {};
const startOfMinute = GetMinuteStartNowTimestampUTC();
if (monitor.hasAPI) {
let apiResponse = await apiCall(envSecrets, monitor.api.url, monitor.api.method, JSON.stringify(monitor.api.headers), monitor.api.body, monitor.api.timeout, monitor.api.eval);
apiData[startOfMinute] = apiResponse;
if (apiResponse.type === "timeout") {
console.log("Retrying api call for " + monitor.name + " at " + startOfMinute + " due to timeout");
//retry
apiQueue.push(async (cb) => {
apiCall(envSecrets, monitor.api.url, monitor.api.method, JSON.stringify(monitor.api.headers), monitor.api.body, monitor.api.timeout, monitor.api.eval).then(async (data) => {
let day0 = {};
day0[startOfMinute] = data;
fs.writeFileSync(Kener_folder + `/${monitor.folderName}.webhook.${Randomstring.generate()}.json`, JSON.stringify(day0, null, 2));
cb();
});
});
}
}
webhookData = await getWebhookData(monitor);
manualData = await manualIncident(monitor, githubConfig);
//merge noData, apiData, webhookData, dayData
let mergedData = {};
if (monitor.defaultStatus !== undefined && monitor.defaultStatus !== null) {
if ([UP, DOWN, DEGRADED].indexOf(monitor.defaultStatus) !== -1) {
mergedData[startOfMinute] = {
status: monitor.defaultStatus,
latency: 0,
type: "defaultStatus",
};
}
}
for (const timestamp in apiData) {
mergedData[timestamp] = apiData[timestamp];
}
for (const timestamp in webhookData) {
mergedData[timestamp] = webhookData[timestamp];
}
for (const timestamp in manualData) {
mergedData[timestamp] = manualData[timestamp];
}
//update day data
await updateDayData(mergedData, startOfMinute, monitor);
};
apiQueue.start((err) => {
if (err) {
console.error("Error occurred:", err);
} else {
console.log("All tasks completed");
}
});
export { Minuter };
-399
View File
@@ -1,399 +0,0 @@
// @ts-nocheck
import axios from "axios";
import { GetMinuteStartNowTimestampUTC } from "./tool.js";
import { marked } from "marked";
const GH_TOKEN = process.env.GH_TOKEN;
const GhnotconfireguredMsg = "owner or repo or GH_TOKEN is undefined. Read the docs to configure github: https://kener.ing/docs#h2github-setup";
/**
* @param {any} url
*/
function getAxiosOptions(url) {
const options = {
url: url,
method: "GET",
headers: {
Accept: "application/vnd.github+json",
Authorization: "Bearer " + GH_TOKEN,
"X-GitHub-Api-Version": "2022-11-28",
},
};
return options;
}
function postAxiosOptions(url, data) {
const options = {
url: url,
method: "POST",
headers: {
Accept: "application/vnd.github+json",
Authorization: "Bearer " + GH_TOKEN,
"X-GitHub-Api-Version": "2022-11-28",
},
data: data,
};
return options;
}
function patchAxiosOptions(url, data) {
const options = {
url: url,
method: "PATCH",
headers: {
Accept: "application/vnd.github+json",
Authorization: "Bearer " + GH_TOKEN,
"X-GitHub-Api-Version": "2022-11-28",
},
data: data,
};
return options;
}
const GetAllGHLabels = async function (owner, repo) {
if (owner === undefined || repo === undefined || GH_TOKEN === undefined) {
console.log(GhnotconfireguredMsg);
return [];
}
const options = getAxiosOptions(`https://api.github.com/repos/${owner}/${repo}/labels?per_page=1000`);
let labels = [];
try {
const response = await axios.request(options);
labels = response.data.map((label) => label.name);
} catch (error) {
console.log(error.response?.data);
return [];
}
return labels;
};
function generateRandomColor() {
var randomColor = Math.floor(Math.random() * 16777215).toString(16);
return randomColor;
//random color will be freshly served
}
const CreateGHLabel = async function (owner, repo, label, description, color) {
if (owner === undefined || repo === undefined || GH_TOKEN === undefined) {
console.log(GhnotconfireguredMsg);
return null;
}
if (color === undefined) {
color = generateRandomColor();
}
const options = postAxiosOptions(`https://api.github.com/repos/${owner}/${repo}/labels`, {
name: label,
color: color,
description: description,
});
try {
const response = await axios.request(options);
return response.data;
} catch (error) {
console.log(error.response.data);
return null;
}
};
const GetStartTimeFromBody = function (text) {
const pattern = /\[start_datetime:(\d+)\]/;
const matches = pattern.exec(text);
if (matches) {
const timestamp = matches[1];
return parseInt(timestamp);
}
return null;
};
const GetEndTimeFromBody = function (text) {
const pattern = /\[end_datetime:(\d+)\]/;
const matches = pattern.exec(text);
if (matches) {
const timestamp = matches[1];
return parseInt(timestamp);
}
return null;
};
const GetIncidentByNumber = async function (githubConfig, incidentNumber) {
if (githubConfig.owner === undefined || githubConfig.repo === undefined || GH_TOKEN === undefined) {
console.log(GhnotconfireguredMsg);
return null;
}
const url = `https://api.github.com/repos/${githubConfig.owner}/${githubConfig.repo}/issues/${incidentNumber}`;
const options = getAxiosOptions(url);
try {
const response = await axios.request(options);
return response.data;
} catch (error) {
console.log(error.message, options, url);
return null;
}
};
const GetIncidents = async function (tagName, githubConfig, state = "all") {
if (githubConfig.owner === undefined || githubConfig.repo === undefined || GH_TOKEN === undefined) {
console.log(GhnotconfireguredMsg);
return [];
}
if (tagName === undefined) {
return [];
}
const since = GetMinuteStartNowTimestampUTC() - githubConfig.incidentSince * 60 * 60;
const sinceISO = new Date(since * 1000).toISOString();
const url = `https://api.github.com/repos/${githubConfig.owner}/${githubConfig.repo}/issues?state=${state}&labels=${tagName},incident&sort=created&direction=desc&since=${sinceISO}`;
const options = getAxiosOptions(url);
try {
const response = await axios.request(options);
let issues = response.data;
//issues.createAt should be after sinceISO
issues = issues.filter((issue) => {
return new Date(issue.created_at) >= new Date(sinceISO);
});
return issues;
} catch (error) {
console.log(error.response?.data);
return [];
}
};
const GetOpenIncidents = async function (githubConfig) {
if (githubConfig.owner === undefined || githubConfig.repo === undefined || GH_TOKEN === undefined) {
console.log(GhnotconfireguredMsg);
return [];
}
const since = GetMinuteStartNowTimestampUTC() - githubConfig.incidentSince * 60 * 60;
const sinceISO = new Date(since * 1000).toISOString();
const url = `https://api.github.com/repos/${githubConfig.owner}/${githubConfig.repo}/issues?state=open&labels=incident&sort=created&direction=desc&since=${sinceISO}`;
const options = getAxiosOptions(url);
try {
const response = await axios.request(options);
let issues = response.data;
//issues.createAt should be after sinceISO
issues = issues.filter((issue) => {
return new Date(issue.created_at) >= new Date(sinceISO);
});
return issues;
} catch (error) {
console.log(error.response?.data);
return [];
}
};
function FilterAndInsertMonitorInIncident(openIncidentsReduced, monitorsActive) {
let openIncidentExploded = [];
for (let i = 0; i < openIncidentsReduced.length; i++) {
for (let j = 0; j < monitorsActive.length; j++) {
if (openIncidentsReduced[i].labels.includes(monitorsActive[j].tag)) {
let incident = JSON.parse(JSON.stringify(openIncidentsReduced[i]));
incident.monitor = {
name: monitorsActive[j].name,
tag: monitorsActive[j].tag,
image: monitorsActive[j].image,
description: monitorsActive[j].description,
};
openIncidentExploded.push(incident);
}
}
}
return openIncidentExploded;
}
function Mapper(issue) {
const html = marked.parse(issue.body);
//convert issue.created_at from iso to timestamp UTC minutes
const issueCreatedAt = new Date(issue.created_at);
const issueCreatedAtTimestamp = issueCreatedAt.getTime() / 1000;
//convert issue.closed_at from iso to timestamp UTC minutes
let issueClosedAtTimestamp = null;
if (issue.closed_at !== null) {
const issueClosedAt = new Date(issue.closed_at);
issueClosedAtTimestamp = issueClosedAt.getTime() / 1000;
}
let labels = issue.labels.map(function (label) {
return label.name;
});
//find and add monitors tag in labels
let res = {
title: issue.title,
incident_start_time: GetStartTimeFromBody(issue.body) || issueCreatedAtTimestamp,
incident_end_time: GetEndTimeFromBody(issue.body) || issueClosedAtTimestamp,
number: issue.number,
body: html,
created_at: issue.created_at,
updated_at: issue.updated_at,
collapsed: true,
// @ts-ignore
state: issue.state,
closed_at: issue.closed_at,
// @ts-ignore
labels: labels,
html_url: issue.html_url,
comments: [],
};
return res;
}
async function GetCommentsForIssue(issueID, githubConfig) {
if (githubConfig.owner === undefined || githubConfig.repo === undefined || GH_TOKEN === undefined) {
console.log(GhnotconfireguredMsg);
return [];
}
const url = `https://api.github.com/repos/${githubConfig.owner}/${githubConfig.repo}/issues/${issueID}/comments`;
try {
const response = await axios.request(getAxiosOptions(url));
return response.data;
} catch (error) {
console.log(error.response.data);
return [];
}
}
async function CreateIssue(githubConfig, issueTitle, issueBody, issueLabels) {
if (githubConfig.owner === undefined || githubConfig.repo === undefined || GH_TOKEN === undefined) {
console.log(GhnotconfireguredMsg);
return null;
}
const url = `https://api.github.com/repos/${githubConfig.owner}/${githubConfig.repo}/issues`;
try {
const payload = {
title: issueTitle,
body: issueBody,
labels: issueLabels,
};
const response = await axios.request(postAxiosOptions(url, payload));
return response.data;
} catch (error) {
console.log(error.response.data);
return null;
}
}
async function UpdateIssue(githubConfig, incidentNumber, issueTitle, issueBody, issueLabels, state = "open") {
if (githubConfig.owner === undefined || githubConfig.repo === undefined || GH_TOKEN === undefined) {
console.log(GhnotconfireguredMsg);
return null;
}
const url = `https://api.github.com/repos/${githubConfig.owner}/${githubConfig.repo}/issues/${incidentNumber}`;
try {
const payload = {
title: issueTitle,
body: issueBody,
labels: issueLabels,
state: state,
};
const response = await axios.request(patchAxiosOptions(url, payload));
return response.data;
} catch (error) {
console.log(error.response.data);
return null;
}
}
async function CloseIssue(githubConfig, incidentNumber) {
if (githubConfig.owner === undefined || githubConfig.repo === undefined || GH_TOKEN === undefined) {
console.log(GhnotconfireguredMsg);
return null;
}
const url = `https://api.github.com/repos/${githubConfig.owner}/${githubConfig.repo}/issues/${incidentNumber}`;
try {
const payload = {
state: "closed"
};
const response = await axios.request(patchAxiosOptions(url, payload));
return response.data;
} catch (error) {
console.log(error.response.data);
return null;
}
}
async function AddComment(githubConfig, incidentNumber, commentBody) {
if (githubConfig.owner === undefined || githubConfig.repo === undefined || GH_TOKEN === undefined) {
console.log(GhnotconfireguredMsg);
return null;
}
const url = `https://api.github.com/repos/${githubConfig.owner}/${githubConfig.repo}/issues/${incidentNumber}/comments`;
try {
const payload = {
body: commentBody,
};
const response = await axios.request(postAxiosOptions(url, payload));
return response.data;
} catch (error) {
console.log(error.response.data);
return null;
}
}
//update issue labels
async function UpdateIssueLabels(githubConfig, incidentNumber, issueLabels, body, state = "open") {
if (githubConfig.owner === undefined || githubConfig.repo === undefined || GH_TOKEN === undefined) {
console.log(GhnotconfireguredMsg);
return null;
}
const url = `https://api.github.com/repos/${githubConfig.owner}/${githubConfig.repo}/issues/${incidentNumber}`;
try {
const payload = {
labels: issueLabels,
body: body,
state: state,
};
const response = await axios.request(patchAxiosOptions(url, payload));
return response.data;
} catch (error) {
console.log(error.response.data);
return null;
}
}
//search issue
async function SearchIssue(query, page, per_page) {
if (GH_TOKEN === undefined) {
console.log(GhnotconfireguredMsg);
return null;
}
const searchQuery =
query
.filter(function (q) {
if (q == "" || q === undefined || q === null) {
return false;
}
const qs = q.split(":");
if (qs.length < 2) {
return false;
}
if (qs[1] === "" || qs[1] === undefined || qs[1] === null) {
return false;
}
return true;
})
.join(" ")
const url = `https://api.github.com/search/issues?q=${encodeURIComponent(
searchQuery
)}&per_page=${per_page}&page=${page}`;
try {
const response = await axios.request(getAxiosOptions(url));
return response.data;
} catch (error) {
console.log(error.response.data);
return [];
}
}
export {
GetAllGHLabels,
CreateGHLabel,
GetIncidents,
GetStartTimeFromBody,
GetEndTimeFromBody,
GetCommentsForIssue,
Mapper,
CreateIssue,
AddComment,
GetIncidentByNumber,
UpdateIssueLabels,
UpdateIssue,
CloseIssue,
GetOpenIncidents,
FilterAndInsertMonitorInIncident,
SearchIssue,
};
-137
View File
@@ -1,137 +0,0 @@
import fs from "fs-extra";
import { GetMinuteStartNowTimestampUTC, BeginningOfDay } from "./tool.js";
import { StatusObj, ParseUptime } from "../src/lib/helpers.js";
function getDayMessage(type, numOfMinute){
if(numOfMinute > 59){
let hour = Math.floor(numOfMinute / 60);
let minute = numOfMinute % 60;
return `${type} for ${hour}h:${minute}m`;
} else {
return `${type} for ${numOfMinute} minute${numOfMinute > 1 ? "s" : ""}`;
}
}
const NO_DATA = "No Data";
function getDayData(day0, startTime, endTime) {
let dayData = {
UP: 0,
DEGRADED: 0,
DOWN: 0,
timestamp: startTime,
cssClass: StatusObj.NO_DATA,
message: NO_DATA,
};
//loop through the ts range
for (let i = startTime; i <= endTime; i += 60) {
//if the ts is in the day0 then add up, down degraded data, if not initialize it
if (day0[i] === undefined) {
continue;
}
if (day0[i].status == "UP") {
dayData.UP++;
} else if (day0[i].status == "DEGRADED") {
dayData.DEGRADED++;
} else if (day0[i].status == "DOWN") {
dayData.DOWN++;
}
}
let cssClass = StatusObj.UP;
let message = "Status OK";
if (dayData.DEGRADED > 0) {
cssClass = StatusObj.DEGRADED;
message = getDayMessage("DEGRADED", dayData.DEGRADED);
}
if (dayData.DOWN > 0) {
cssClass = StatusObj.DOWN;
message = getDayMessage("DOWN", dayData.DOWN);
}
if (dayData.DEGRADED + dayData.DOWN + dayData.UP > 0) {
dayData.message = message;
dayData.cssClass = cssClass;
}
return dayData;
}
const Ninety = async (monitor) => {
let _0Day = {};
let _90Day = {};
let uptime0Day = "0";
let dailyUps = 0;
let dailyDown = 0;
let dailyDegraded = 0;
let completeUps = 0;
let completeDown = 0;
let completeDegraded = 0;
const secondsInDay = 24 * 60 * 60;
const now = GetMinuteStartNowTimestampUTC();
const midnight = BeginningOfDay({ timeZone: "GMT" });
const midnight90DaysAgo = midnight - 90 * 24 * 60 * 60;
const midnightTomorrow = midnight + secondsInDay;
for (let i = midnight; i <= now; i += 60) {
_0Day[i] = {
timestamp: i,
status: "NO_DATA",
cssClass: StatusObj.NO_DATA,
index: (i - midnight) / 60,
};
}
let day0 = JSON.parse(fs.readFileSync(monitor.path0Day, "utf8"));
for (const timestamp in day0) {
const element = day0[timestamp];
let status = element.status;
if (status == "UP") {
completeUps++;
} else if (status == "DEGRADED") {
completeDegraded++;
} else if (status == "DOWN") {
completeDown++;
}
//0 Day data
if (_0Day[timestamp] !== undefined) {
_0Day[timestamp].status = status;
_0Day[timestamp].cssClass = StatusObj[status];
dailyUps = status == "UP" ? dailyUps + 1 : dailyUps;
dailyDown = status == "DOWN" ? dailyDown + 1 : dailyDown;
dailyDegraded = status == "DEGRADED" ? dailyDegraded + 1 : dailyDegraded;
}
}
for (let i = midnight90DaysAgo; i < midnightTomorrow; i += secondsInDay) {
_90Day[i] = getDayData(day0, i, i + secondsInDay - 1);
}
for (const key in _90Day) {
const element = _90Day[key];
delete _90Day[key].UP;
delete _90Day[key].DEGRADED;
delete _90Day[key].DOWN;
if (element.message == NO_DATA) continue;
}
uptime0Day = ParseUptime(dailyUps + dailyDegraded, dailyUps + dailyDown + dailyDegraded);
const dataToWrite = {
_90Day: _90Day,
uptime0Day,
uptime90Day: ParseUptime(completeUps + completeDegraded, completeUps + completeDegraded + completeDown),
dailyUps,
dailyDown,
dailyDegraded,
};
await fs.writeJson(monitor.path90Day, dataToWrite);
return true;
};
export { Ninety };
-300
View File
@@ -1,300 +0,0 @@
/*
The startup js script will
check if monitors.yaml exists
if it does, it will read the file and parse it into a json array of objects
each objects will have a name, url, method: required
name of each of these objects need to be unique
*/
import fs from "fs-extra";
import yaml from "js-yaml";
import { Cron } from "croner";
import { FOLDER, FOLDER_MONITOR, FOLDER_SITE, API_TIMEOUT } from "./constants.js";
import { IsValidURL, IsValidHTTPMethod, LoadMonitorsPath, LoadSitePath } from "./tool.js";
import { GetAllGHLabels, CreateGHLabel } from "./github.js";
import { Minuter } from "./cron-minute.js";
import axios from "axios";
import { Ninety } from "./ninety.js";
let monitors = [];
let site = {};
const envSecrets = [];
const defaultEval = `(function (statusCode, responseTime, responseData) {
let statusCodeShort = Math.floor(statusCode/100);
if(statusCode == 429 || (statusCodeShort >=2 && statusCodeShort <= 3)) {
return {
status: 'UP',
latency: responseTime,
}
}
return {
status: 'DOWN',
latency: responseTime,
}
})`;
function checkIfDuplicateExists(arr) {
return new Set(arr).size !== arr.length;
}
function getWordsStartingWithDollar(text) {
const regex = /\$\w+/g;
const wordsArray = text.match(regex);
return wordsArray || [];
}
if (!fs.existsSync(FOLDER)) {
fs.mkdirSync(FOLDER);
console.log(".kener folder created successfully!");
}
const Startup = async () => {
try {
const fileContent = fs.readFileSync(LoadMonitorsPath(), "utf8");
site = yaml.load(fs.readFileSync(LoadSitePath(), "utf8"));
monitors = yaml.load(fileContent);
} catch (error) {
console.log(error);
process.exit(1);
}
// Use the 'monitors' array of JSON objects as needed
//check if each object has name, url, method
//if not, exit with error
//if yes, check if name is unique
for (let i = 0; i < monitors.length; i++) {
const monitor = monitors[i];
let name = monitor.name;
let tag = monitor.tag;
let hasAPI = monitor.api !== undefined && monitor.api !== null;
let folderName = name.replace(/[^a-z0-9]/gi, "-").toLowerCase();
monitors[i].folderName = folderName;
if (!name || !tag) {
console.log("name, tag are required");
process.exit(1);
}
if(hasAPI) {
let url = monitor.api.url;
let method = monitor.api.method;
let headers = monitor.api.headers;
let evaluator = monitor.api.eval;
let body = monitor.api.body;
let timeout = monitor.api.timeout;
//url
if (!!url) {
if (!IsValidURL(url)) {
console.log("url is not valid");
process.exit(1);
}
}
if (!!method) {
if (!IsValidHTTPMethod(method)) {
console.log("method is not valid");
process.exit(1);
}
method = method.toUpperCase();
} else {
method = "GET";
}
monitors[i].api.method = method;
//headers
if (headers === undefined || headers === null) {
monitors[i].api.headers = undefined;
} else {
//check if headers is a valid json
try {
JSON.parse(JSON.stringify(headers));
} catch (error) {
console.log("headers are not valid. Quiting");
process.exit(1);
}
}
//eval
if (evaluator === undefined || evaluator === null) {
monitors[i].api.eval = defaultEval;
} else {
let evalResp = eval(evaluator + `(200, 1000, "e30=")`);
if (evalResp === undefined || evalResp === null || evalResp.status === undefined || evalResp.status === null || evalResp.latency === undefined || evalResp.latency === null) {
console.log("eval is not valid ");
process.exit(1);
}
monitors[i].api.eval = evaluator;
}
//body
if (body === undefined || body === null) {
monitors[i].api.body = undefined;
} else {
//check if body is a valid string
if (typeof body !== "string") {
console.log("body is not valid should be a string");
process.exit(1);
}
}
//timeout
if (timeout === undefined || timeout === null) {
monitors[i].api.timeout = API_TIMEOUT;
} else {
//check if timeout is a valid number
if (isNaN(timeout) || timeout < 0) {
console.log("timeout is not valid ");
process.exit(1);
}
}
//add a description to the monitor if it is website using api.url and method = GET and headers == undefined
//call the it to see if recevied content-type is text/html
//if yes, append to description
if ((headers === undefined || headers === null) && url !== undefined && method === "GET") {
try {
const response = await axios({
method: "GET",
url: url,
timeout: API_TIMEOUT,
});
if (response.headers["content-type"].includes("text/html")) {
let link = `<a href="${url}" class="font-medium underline underline-offset-4" target="_blank">${url}</a>`;
if(monitors[i].description === undefined) {
monitors[i].description = link;
} else {
monitors[i].description = monitors[i].description?.trim() + " " + link;
}
}
} catch (error) {
console.log(error);
}
}
}
monitors[i].path0Day = `${FOLDER}/${folderName}.0day.utc.json`;
monitors[i].path90Day = `${FOLDER}/${folderName}.90day.utc.json`;
monitors[i].hasAPI = hasAPI;
//secrets can be in url/body/headers
//match in monitor.url if a words starts with $, get the word
const requiredSecrets = getWordsStartingWithDollar(`${monitor.url} ${monitor.body} ${JSON.stringify(monitor.headers)}`).map((x) => x.substr(1));
//iterate over process.env
for (const [key, value] of Object.entries(process.env)) {
if (requiredSecrets.indexOf(key) !== -1) {
envSecrets.push({
find: `$${key}`,
replace: value,
});
}
}
}
if (site.github === undefined || site.github.owner === undefined || site.github.repo === undefined) {
console.log("github owner and repo are required");
process.exit(1);
}
if (site.github.incidentSince === undefined || site.github.incidentSince === null) {
site.github.incidentSince = 48;
}
if (checkIfDuplicateExists(monitors.map((monitor) => monitor.folderName)) === true) {
console.log("duplicate monitor detected");
process.exit(1);
}
if (checkIfDuplicateExists(monitors.map((monitor) => monitor.tag)) === true) {
console.log("duplicate tag detected");
process.exit(1);
}
fs.ensureFileSync(FOLDER_MONITOR);
fs.ensureFileSync(FOLDER_SITE);
try {
fs.writeFileSync(FOLDER_MONITOR, JSON.stringify(monitors, null, 4));
fs.writeFileSync(FOLDER_SITE, JSON.stringify(site, null, 4));
} catch (error) {
console.log(error);
process.exit(1);
}
if (!!site.github && !!site.github.owner && !!site.github.repo) {
const ghowner = site.github.owner;
const ghrepo = site.github.repo;
const ghlabels = await GetAllGHLabels(ghowner, ghrepo);
const tagsAndDescription = monitors.map((monitor) => {
return { tag: monitor.tag, description: monitor.name };
});
//add incident label if does not exist
if (ghlabels.indexOf("incident") === -1) {
await CreateGHLabel(ghowner, ghrepo, "incident", "Status of the site");
}
if (ghlabels.indexOf("resolved") === -1) {
await CreateGHLabel(ghowner, ghrepo, "resolved", "Incident is resolved", "65dba6");
}
if (ghlabels.indexOf("identified") === -1) {
await CreateGHLabel(ghowner, ghrepo, "identified", "Incident is Identified", "EBE3D5");
}
if (ghlabels.indexOf("investigating") === -1) {
await CreateGHLabel(ghowner, ghrepo, "investigating", "Incident is investigated", "D4E2D4");
}
if (ghlabels.indexOf("incident-degraded") === -1) {
await CreateGHLabel(ghowner, ghrepo, "incident-degraded", "Status is degraded of the site", "f5ba60");
}
if (ghlabels.indexOf("incident-down") === -1) {
await CreateGHLabel(ghowner, ghrepo, "incident-down", "Status is down of the site", "ea3462");
}
//add tags if does not exist
for (let i = 0; i < tagsAndDescription.length; i++) {
const tag = tagsAndDescription[i].tag;
const description = tagsAndDescription[i].description;
if (ghlabels.indexOf(tag) === -1) {
await CreateGHLabel(ghowner, ghrepo, tag, description);
}
}
}
// init monitors
for (let i = 0; i < monitors.length; i++) {
const monitor = monitors[i];
if (!fs.existsSync(monitor.path0Day)) {
fs.ensureFileSync(monitor.path0Day);
fs.writeFileSync(monitor.path0Day, JSON.stringify({}));
}
if (!fs.existsSync(monitor.path90Day)) {
fs.ensureFileSync(monitor.path90Day);
fs.writeFileSync(monitor.path90Day, JSON.stringify({}));
}
console.log("Initial Fetch for ", monitor.name);
await Minuter(envSecrets, monitor, site.github);
await Ninety(monitor);
}
//trigger minute cron
for (let i = 0; i < monitors.length; i++) {
const monitor = monitors[i];
let cronExpession = "* * * * *";
if (monitor.cron !== undefined && monitor.cron !== null) {
cronExpession = monitor.cron;
}
console.log("Staring " + cronExpession + " Cron for ", monitor.name);
Cron(cronExpession, async () => {
await Minuter(envSecrets, monitor, site.github);
});
}
//pre compute 90 day data at 1 minute interval
Cron(
"* * * * *",
async () => {
for (let i = 0; i < monitors.length; i++) {
const monitor = monitors[i];
Ninety(monitor);
}
},
{
protect: true,
}
);
};
export { Startup };
-12
View File
@@ -1,12 +0,0 @@
import { GetMinuteStartNowTimestampUTC, GetDayStartWithOffset, BeginningOfDay } from "./tool.js";
let tzOffset = -330;
let ts = GetMinuteStartNowTimestampUTC();
console.log("GetMinuteStartNowTimestampUTC India 12AM: " + ts);
let tm = GetDayStartWithOffset(GetMinuteStartNowTimestampUTC(), tzOffset);
console.log("GetMinuteStartTimestampUTC India 12AM: should be 18:30PM " + tm);
console.log(`getUTCTimestampAtStartOfDayForOffset(${GetMinuteStartNowTimestampUTC()}, ${tzOffset})`);
console.log(BeginningOfDay({ timeZone: "GMT" }));
console.log(BeginningOfDay({ timeZone: "Asia/Kolkata", date: new Date(1703223388000) }));
-138
View File
@@ -1,138 +0,0 @@
// @ts-nocheck
import { MONITOR, SITE } from "./constants.js";
const IsValidURL = function (url) {
return /^(http|https):\/\/[^ "]+$/.test(url);
};
const IsStringURLSafe = function (str) {
const regex = /^[A-Za-z0-9\-_.~]+$/;
return regex.test(str);
};
const IsValidHTTPMethod = function (method) {
return /^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH)$/.test(method);
};
function generateRandomColor() {
var randomColor = Math.floor(Math.random() * 16777215).toString(16);
return randomColor;
//random color will be freshly served
}
const LoadMonitorsPath = function () {
const argv = process.argv;
if (!!process.env.MONITOR_YAML_PATH) {
return process.env.MONITOR_YAML_PATH;
}
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--monitors") {
return argv[i + 1];
}
}
return MONITOR;
};
const LoadSitePath = function () {
const argv = process.argv;
if (!!process.env.SITE_YAML_PATH) {
return process.env.SITE_YAML_PATH;
}
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--site") {
return argv[i + 1];
}
}
return SITE;
};
//return given timestamp in UTC
const GetNowTimestampUTC = function () {
//use js date instead of moment
const now = new Date();
const timestamp = now.getTime();
return Math.floor(timestamp / 1000);
};
//return given timestamp minute start timestamp in UTC
const GetMinuteStartTimestampUTC = function (timestamp) {
//use js date instead of moment
const now = new Date(timestamp * 1000);
const minuteStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), 0, 0);
const minuteStartTimestamp = minuteStart.getTime();
return Math.floor(minuteStartTimestamp / 1000);
};
//return current timestamp minute start timestamp in UTC
const GetMinuteStartNowTimestampUTC = function () {
//use js date instead of moment
const now = new Date();
const minuteStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), 0, 0);
const minuteStartTimestamp = minuteStart.getTime();
return Math.floor(minuteStartTimestamp / 1000);
};
//return given timestamp day start timestamp in UTC
const GetDayStartTimestampUTC = function (timestamp) {
//use js date instead of moment
const now = new Date(timestamp * 1000);
const dayStart = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0));
const dayStartTimestamp = dayStart.getTime();
return Math.floor(dayStartTimestamp / 1000);
};
const GetDayEndTimestampUTC = function (timestamp) {
//use js date instead of moment
const now = new Date(timestamp * 1000);
const dayEnd = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999));
const dayEndTimestamp = dayEnd.getTime();
return Math.floor(dayEndTimestamp / 1000) + 60;
}
const DurationInMinutes = function (start, end) {
return Math.floor((end - start) / 60);
}
const GetDayStartWithOffset = function (timeStampInSeconds, offsetInMinutes) {
const then = new Date(GetMinuteStartTimestampUTC(timeStampInSeconds) * 1000);
let dayStartThen = GetDayStartTimestampUTC(then.getTime() / 1000);
let dayStartTomorrow = dayStartThen + 24 * 60 * 60;
let dayStartYesterday = dayStartThen - 24 * 60 * 60;
//have to figure out when to add a day
//20-12AM [21-12AM] =21:630 xtm [22-12AM] xtd =22:630 23-12AM
//if xtm - 330 > 1 day , add a day to xtm - 330
if (offsetInMinutes < 0) {
//add one day to dayStartThen
dayStartThen = dayStartThen + 24 * 60 * 60;
}
return dayStartThen + offsetInMinutes * 60;
}
const BeginningOfDay = (options = {}) => {
const { date = new Date(), timeZone } = options;
const parts = Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
hour: "numeric",
minute: "numeric",
second: "numeric",
}).formatToParts(date);
const hour = parseInt(parts.find((i) => i.type === "hour").value);
const minute = parseInt(parts.find((i) => i.type === "minute").value);
const second = parseInt(parts.find((i) => i.type === "second").value);
const dt = new Date(1000 * Math.floor((date - hour * 3600000 - minute * 60000 - second * 1000) / 1000));
return dt.getTime() / 1000;
};
export {
IsValidURL,
IsValidHTTPMethod,
LoadMonitorsPath,
LoadSitePath,
GetMinuteStartTimestampUTC,
GetNowTimestampUTC,
GetDayStartTimestampUTC,
GetMinuteStartNowTimestampUTC,
DurationInMinutes,
GetDayStartWithOffset,
BeginningOfDay,
IsStringURLSafe,
};
+32
View File
@@ -0,0 +1,32 @@
import monitorSeed from "../src/lib/server/db/seedMonitorData.js";
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export async function seed(knex) {
// Check if the table is empty
const count = await knex("monitors").count("id as CNT").first();
if (count.CNT == 0) {
// Deletes ALL existing entries
for (const monitor of monitorSeed) {
await knex("monitors").insert({
tag: monitor.tag,
name: monitor.name,
description: monitor.description,
image: monitor.image,
cron: monitor.cron,
default_status: monitor.default_status,
status: monitor.status,
category_name: monitor.category_name,
monitor_type: monitor.monitor_type,
type_data: monitor.type_data,
day_degraded_minimum_count: monitor.day_degraded_minimum_count,
day_down_minimum_count: monitor.day_down_minimum_count,
include_degraded_in_downtime: monitor.include_degraded_in_downtime,
created_at: knex.fn.now(),
updated_at: knex.fn.now()
});
}
}
}
+24
View File
@@ -0,0 +1,24 @@
import seedSiteData from "../src/lib/server/db/seedSiteData.js";
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export async function seed(knex) {
// Check if the table is empty
const count = await knex("site_data").count("id as CNT").first();
for (const key in seedSiteData) {
if (Object.prototype.hasOwnProperty.call(seedSiteData, key)) {
let value = seedSiteData[key];
let data_type = typeof value;
if (data_type === "object") {
value = JSON.stringify(value);
}
const existingEntry = await knex("site_data").where({ key: key }).first();
if (!existingEntry) {
await knex("site_data").insert([{ key: key, value: value, data_type: data_type }]);
}
}
}
}
+19 -16
View File
@@ -2,11 +2,13 @@
//read from process.env.PUBLIC_KENER_FOLDER / monitors.json
// create sitemap.xml
import fs from "fs-extra";
let siteMap = ""
const site = JSON.parse(fs.readFileSync(process.env.PUBLIC_KENER_FOLDER + "/site.json", "utf8"));
const monitors = JSON.parse(fs.readFileSync(process.env.PUBLIC_KENER_FOLDER + "/monitors.json", "utf8"));
if(site.siteURL !== undefined && site.siteURL !== null && site.siteURL !== ""){
if(monitors.length > 0){
let siteMap = "";
import dotenv from "dotenv";
dotenv.config();
const site = fs.readJSONSync("./database/site.json", "utf8");
const monitors = fs.readJSONSync("./database/monitors.json", "utf8");
if (site.siteURL !== undefined && site.siteURL !== null && site.siteURL !== "") {
if (monitors.length > 0) {
siteMap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset
xmlns="https://www.sitemaps.org/schemas/sitemap/0.9"
@@ -14,29 +16,30 @@ if(site.siteURL !== undefined && site.siteURL !== null && site.siteURL !== ""){
xsi:schemaLocation="https://www.sitemaps.org/schemas/sitemap/0.9
https://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
${monitors
.map((monitor) => {
return `<url>
<loc>${site.siteURL}/incident/${monitor.folderName}</loc>
.map((monitor) => {
return `
<url>
<loc>${site.siteURL}/incident/${monitor.tag}</loc>
<lastmod>${new Date().toISOString()}</lastmod>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>`;
})
.join("\n")}
})
.join("\n")}
${monitors
.map((monitor) => {
return `<url>
.map((monitor) => {
return `
<url>
<loc>${site.siteURL}/monitor-${encodeURIComponent(monitor.tag)}</loc>
<lastmod>${new Date().toISOString()}</lastmod>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>`;
})
.join("\n")}
})
.join("\n")}
</urlset>`;
}
}
//export default siteMap
export default siteMap;
export default siteMap;
+12 -24
View File
@@ -1,26 +1,14 @@
<!DOCTYPE html>
<html lang="en" class="dark dark:bg-background">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Q3MLRXCBFT"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "G-Q3MLRXCBFT");
</script>
</body>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+58 -52
View File
@@ -3,80 +3,86 @@
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
:root {
--background: 0 0% 100%;
--background-kener: hsl(0, 0%, 100%);
--background-kener-rgba: rgba(255,255,255,.5);
--foreground: 240 10% 4%;
--background-kener-rgba: rgba(255, 255, 255, 0.5);
--foreground: 240 10% 4%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--muted: 0 0% 92.9%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 4%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 4%;
--card: 0 0% 100%;
--card-foreground: 240 10% 4%;
--card: 0 0% 99%;
--card-foreground: 240 10% 4%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--date-picker-background: hsl(0 0% 99%);
--date-picker-foreground: hsl(240 10% 4%);
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--secondary: 0 0% 92.9%;
--secondary-foreground: 222.2 47.4% 11.2%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 210 40% 98%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--ring: 240 10% 4%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 210 40% 98%;
--radius: 0.5rem;
}
--ring: 240 10% 4%;
.dark {
--background: 240 10% 4%;
--background-kener: hsl(240, 10%, 4%);
--background-kener-rgba: rgba(9, 9, 11, .35);
--foreground: 210 40% 98%;
--radius: 0.5rem;
}
--muted: 240 4% 16%;
--muted-foreground: 215 20.2% 65.1%;
.dark {
--background: 240 7% 11%;
--background-kener: hsl(240 7% 11%);
--background-kener-rgba: rgba(30, 30, 26, 0.35);
--foreground: 60 10% 86%;
--popover: 240 10% 4%;
--popover-foreground: 210 40% 98%;
--muted: 240 4% 16%;
--muted-foreground: 0 0% 40%;
--card: 240 10% 4%;
--card-foreground: 210 40% 98%;
--popover: 230 9% 14%;
--popover-foreground: 210 40% 98%;
--border: 240 4% 16%;
--input: 240 4% 16%;
--card: 230 9% 14%;
--card-foreground: 60 10% 86%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--border: 60 1% 17%;
--input: 60 6% 17%;
--secondary: 240 4% 16%;
--secondary-foreground: 210 40% 98%;
--date-picker-background: hsl(240 4% 16%);
--date-picker-foreground: hsl(60 10% 86%);
--accent: 240 4% 16%;
--accent-foreground: 210 40% 98%;
--primary: 60 10% 86%;
--primary-foreground: 222.2 47.4% 11.2%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--secondary: 240 1% 18%;
--secondary-foreground: 60 10% 86%;
--ring: hsl(212.7, 26.8%, 83.9);
}
--accent: 240 4% 16%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--ring: hsl(212.7, 26.8%, 83.9);
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
+69
View File
@@ -0,0 +1,69 @@
code:not([class^="language-"]) {
@apply rounded bg-gray-100 px-1.5 py-0.5 font-mono text-xs dark:bg-gray-800;
}
.sidebar-item.active,
.sidebar-item:hover {
color: #ed702d;
}
.w-585px {
width: 585px;
}
main {
scroll-behavior: smooth;
}
.kener-home-links a {
text-decoration: none;
background-color: var(--bg-background);
}
.kener-home-links > div:hover {
transition: all 0.3s;
box-shadow: 0 0 8px 1.5px #3e9a4b;
}
.accm input:checked ~ div {
display: block;
}
.accm input ~ div {
display: none;
}
.accm input {
visibility: hidden;
}
.accmt {
background-color: hsl(223, 10%, 14%);
}
.accm input:checked ~ .showaccm span:first-child {
display: none;
}
.accm input:checked ~ .showaccm span:last-child {
display: block;
}
.accm input ~ .showaccm span:first-child {
display: block;
}
.accm input ~ .showaccm span:last-child {
display: none;
}
.hljs {
background: transparent !important;
}
.note {
@apply mt-4 rounded-md border bg-background p-3 text-sm shadow-sm;
}
.note.danger {
border: 1px solid #e3342f;
color: #e3342f;
}
.note.info {
border: 1px solid #3490dc;
color: #3490dc;
}
+778 -47
View File
@@ -1,106 +1,837 @@
/*one is the class for dotted background*/
.one {
position: absolute;
top: 0px;
left: 0;
width: 100%;
z-index: 0;
background-repeat: no-repeat;
background-size: 100%;
height: 100svh;
background: linear-gradient(177deg, rgba(255, 137, 131, 0.5) 0%, rgba(35, 136, 224, 0.05) 60%);
clip-path: polygon(0 0, 100% 0, 100% 54%, 0% 100%);
.dots-pattern {
position: absolute;
top: 0px;
left: 0;
width: 100%;
z-index: 0;
background-repeat: no-repeat;
background-size: 100%;
height: 100svh;
background: linear-gradient(177deg, rgba(255, 137, 131, 0.5) 0%, rgba(35, 136, 224, 0.05) 60%);
clip-path: polygon(0 0, 100% 0, 100% 54%, 0% 100%);
}
.one::after {
content: "";
position: absolute;
background-image: radial-gradient(rgba(0, 0, 0, 0) 1.5px, var(--background-kener) 1px);
background-size: 14px 14px;
width: 100%;
height: 100vh;
top: 0;
transform: blur(3px);
left: 0;
.dots-pattern::after {
content: "";
position: absolute;
background-image: radial-gradient(rgba(0, 0, 0, 0) 1.5px, var(--background-kener) 1px);
background-size: 14px 14px;
width: 100%;
height: 100vh;
top: 0;
transform: blur(3px);
left: 0;
}
.squares-pattern {
position: absolute;
top: 0;
left: 0;
width: 100%;
z-index: 0;
background-repeat: no-repeat;
background-size: 100%;
height: 100svh;
clip-path: polygon(0 0, 100% 0, 100% 54%, 0% 100%);
transform: blur(3px);
}
.squares-pattern::after {
content: "";
position: absolute;
background-image: linear-gradient(#444cf7 1px, transparent 1px),
linear-gradient(to right, #444cf7 1px, var(--background-kener) 1px);
-webkit-mask-image: linear-gradient(
180deg,
rgba(0, 0, 0, 1) 0%,
rgba(0, 0, 0, 0.8) 20%,
rgba(0, 0, 0, 0.6) 40%,
rgba(0, 0, 0, 0.4) 60%,
rgba(0, 0, 0, 0.2) 80%,
rgba(0, 0, 0, 0) 100%
);
mask-image: linear-gradient(
180deg,
rgba(0, 0, 0, 0.2) 0%,
rgba(0, 0, 0, 0.1) 20%,
rgba(0, 0, 0, 0.05) 40%,
rgba(0, 0, 0, 0.025) 60%,
rgba(0, 0, 0, 0.0125) 80%,
rgba(0, 0, 0, 0) 100%
);
background-size: 32px 32px;
width: 100%;
height: 100vh;
top: 0;
left: 0;
}
.dark .squares-pattern::after {
-webkit-mask-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.5) 0%,
rgba(0, 0, 0, 0.25) 20%,
rgba(0, 0, 0, 0.125) 40%,
rgba(0, 0, 0, 0.0625) 60%,
rgba(0, 0, 0, 0.03125) 80%,
rgba(0, 0, 0, 0) 100%
);
mask-image: linear-gradient(
180deg,
rgba(0, 0, 0, 0.5) 0%,
rgba(0, 0, 0, 0.25) 20%,
rgba(0, 0, 0, 0.125) 40%,
rgba(0, 0, 0, 0.0625) 60%,
rgba(0, 0, 0, 0.03125) 80%,
rgba(0, 0, 0, 0) 100%
);
background-image: linear-gradient(#616fbf 1px, transparent 1px),
linear-gradient(to right, #616fbf 1px, var(--background-kener) 1px);
}
/*Needed to overlay content on top of dotted bg*/
section {
position: relative;
z-index: 1;
position: relative;
z-index: 1;
}
/*Needed overlay content on top of dotted bg*/
.blurry-bg {
background-color: var(--background-kener-rgba);
box-shadow: 0 0 64px 64px var(--background-kener-rgba);
/* background-color: var(--background-kener-rgba);
box-shadow: 0 0 64px 64px var(--background-kener-rgba); */
}
:root {
--up-color: #4ead94;
--down-color: #ca3038;
--degraded-color: #e6ca61;
}
/*Colors for something UP*/
.bg-api-up {
background-color: #00dfa2;
background-color: var(--up-color);
}
.text-api-up {
color: #0aca97;
color: var(--up-color);
}
/*Colors for something DOWN*/
.bg-api-down {
background-color: #ff0060;
background-color: var(--down-color);
}
.bg-api-down-10 {
background: linear-gradient(0deg, var(--down-color) 10%, var(--up-color) 1%);
}
.bg-api-down-20 {
background: linear-gradient(0deg, var(--down-color) 20%, var(--up-color) 1%);
}
.bg-api-down-40 {
background: linear-gradient(0deg, var(--down-color) 40%, var(--up-color) 1%);
}
.bg-api-down-60 {
background: linear-gradient(0deg, var(--down-color) 60%, var(--up-color) 1%);
}
.bg-api-down-80 {
background: linear-gradient(0deg, var(--down-color) 80%, var(--up-color) 1%);
}
.bg-api-down-90 {
background: linear-gradient(0deg, var(--down-color) 90%, var(--up-color) 1%);
}
.text-api-down {
color: #ff0060;
color: var(--down-color);
}
/*Colors for something Not there*/
.bg-api-nodata {
background-color:#f1f5f8;
background-color: #ededed;
}
.text-api-nodata {
color: #b8bcbe;
color: #a0a0a0;
}
.dark .bg-api-nodata {
background-color: rgba(100, 100, 100, .4);
background-color: rgba(100, 100, 100, 0.4);
}
/*Colors for something degraded*/
.bg-api-degraded {
background-color: #ffb84c;
background-color: var(--degraded-color);
}
.text-api-degraded {
color: #ffb84c;
color: var(--degraded-color);
}
.bg-api-degraded-10 {
background: linear-gradient(0deg, var(--degraded-color) 10%, var(--up-color) 1%);
}
.bg-api-degraded-20 {
background: linear-gradient(0deg, var(--degraded-color) 20%, var(--up-color) 1%);
}
.bg-api-degraded-40 {
background: linear-gradient(0deg, var(--degraded-color) 40%, var(--up-color) 1%);
}
.bg-api-degraded-60 {
background: linear-gradient(0deg, var(--degraded-color) 60%, var(--up-color) 1%);
}
.bg-api-degraded-80 {
background: linear-gradient(0deg, var(--degraded-color) 80%, var(--up-color) 1%);
}
.bg-api-degraded-90 {
background: linear-gradient(0deg, var(--degraded-color) 90%, var(--up-color) 1%);
}
/*Needed to show markdown properly*/
.prose :where(code):not(:where([class~="not-prose"], [class~="not-prose"] *))::before{
.prose :where(code):not(:where([class~="not-prose"], [class~="not-prose"] *))::before {
content: "";
}
.prose :where(code):not(:where([class~="not-prose"], [class~="not-prose"] *))::after{
.prose :where(code):not(:where([class~="not-prose"], [class~="not-prose"] *))::after {
content: "";
}
/*Needed to show monitor stacked properly*/
.monitors-card .monitor {
padding: 1.3em 1em;
padding: 1.2em 3.4em 0.875em 3.4em;
border-bottom: 1px solid;
border-color: hsl(var(--border) / var(--tw-border-opacity));
}
.monitors-card .monitor:last-child {
border-bottom: none;
}
.monitors-card.embed .monitor {
padding: 0em 0.87em 0.875em 0.87em;
border-bottom: none;
padding-bottom: 0;
}
/*Tag Color*/
.tag-maintenance{
background-color: #A076F9;
.tag-maintenance {
background-color: #a076f9;
color: #09090b;
}
.tag-resolved{
background-color: #2CD3E1;
.tag-maintenance-text {
color: #a076f9;
}
.tag-resolved {
background-color: #2cd3e1;
color: #09090b;
}
.tag-indetified{
background-color: #FEFFAC;
.tag-resolved-text {
color: #1fbac8;
}
.tag-indetified {
background-color: #feffac;
color: #09090b;
}
}
.tag-identified-text {
color: #ddac25;
}
.tag-siren-text {
color: #dd256f;
}
.daygrid90 {
-ms-overflow-style: none; /* Internet Explorer 10+ */
scrollbar-width: none; /* Firefox */
}
.daygrid90::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}
.oneline {
transition: transform 0.1s ease-in;
cursor: pointer;
position: relative;
}
.oneline .oneline-in {
transition: transform 0.1s ease-in;
}
.oneline:hover .oneline-in {
transform: scaleY(1.2);
}
.show-hover {
top: 40px;
padding: 0px;
text-align: left;
}
.today-sq + .hiddenx .message {
position: absolute;
white-space: nowrap;
}
.today-sq + .hiddenx {
visibility: hidden;
z-index: 30;
}
.today-sq:hover + .hiddenx {
visibility: visible;
}
.today-sq:hover {
/* transform: scale(1.1); */
box-shadow:
rgba(50, 50, 105, 0.15) 0px 2px 5px 0px,
rgba(0, 0, 0, 0.05) 0px 1px 1px 0px;
opacity: 0.75;
transition: all 0.1s ease-in;
cursor: pointer;
}
.today-sq {
position: relative;
z-index: 0;
}
.comein {
animation: comein 1s ease-in-out;
}
@keyframes comein {
0% {
bottom: -6px;
}
25% {
bottom: 0px;
}
50% {
bottom: -4px;
}
75% {
bottom: -2px;
}
100% {
bottom: -3px;
}
}
/* animate wiggle */
.wiggle:hover {
-webkit-animation-name: wiggle;
-ms-animation-name: wiggle;
animation-name: wiggle;
animation-duration: 1000ms;
-ms-animation-duration: 1000ms;
-webkit-animation-duration: 1000ms;
animation-iteration-count: 1;
-webkit-animation-iteration-count: 1;
-ms-animation-iteration-count: 1;
animation-timing-function: ease-in-out;
-webkit-animation-timing-function: ease-in-out;
-ms-animation-timing-function: ease-in-out;
}
@-webkit-keyframes wiggle {
0% {
-webkit-transform: rotate(10deg);
}
25% {
-webkit-transform: rotate(-10deg);
}
50% {
-webkit-transform: rotate(20deg);
}
75% {
-webkit-transform: rotate(-5deg);
}
100% {
-webkit-transform: rotate(0deg);
}
}
@-ms-keyframes wiggle {
0% {
-ms-transform: rotate(1deg);
}
25% {
-ms-transform: rotate(-1deg);
}
50% {
-ms-transform: rotate(1.5deg);
}
75% {
-ms-transform: rotate(-5deg);
}
100% {
-ms-transform: rotate(0deg);
}
}
@keyframes wiggle {
0% {
transform: rotate(10deg);
}
25% {
transform: rotate(-10deg);
}
50% {
transform: rotate(20deg);
}
75% {
transform: rotate(-5deg);
}
100% {
transform: rotate(0deg);
}
}
/* animate background color between three colors */
@keyframes changeColor {
0% {
background-color: #b8bcbe;
}
50% {
background-color: #f1f5f8;
}
}
.dark .animatebg {
animation: changeColorDark 2s infinite;
}
@keyframes changeColorDark {
0% {
background-color: #333;
}
50% {
background-color: #2d2d2f;
}
}
.animatebg {
animation: changeColor 2s infinite;
}
textarea::placeholder {
color: hsl(219, 14%, 47%);
}
.dark textarea::placeholder {
color: hsl(0, 0%, 40%);
}
.badge-MONITORING {
color: #ffaf4d;
}
.badge-INVESTIGATING {
color: #fe588a;
}
.badge-RESOLVED {
color: #7aba78;
}
.badge-IDENTIFIED {
color: #b987f7;
}
.newincidents .newincident:last-child .accor .border-b {
border-bottom: none !important;
}
.bounce-right:hover .arrow {
animation: bounceRight 1s;
}
@keyframes bounceRight {
0%,
100% {
transform: translateX(0);
}
20% {
transform: translateX(-4px);
}
40% {
transform: translateX(4px);
}
60% {
transform: translateX(-2px);
}
80% {
transform: translateX(2px);
}
}
.bounce-left:hover .arrow {
animation: bounceLeft 1s;
}
@keyframes bounceLeft {
0%,
100% {
transform: translateX(0);
}
20% {
transform: translateX(4px);
}
40% {
transform: translateX(-4px);
}
60% {
transform: translateX(2px);
}
80% {
transform: translateX(-2px);
}
}
.mesh {
background-image: linear-gradient(
323deg,
rgba(255, 255, 255, 0.01) 0%,
rgba(255, 255, 255, 0.01) 16.667%,
rgba(46, 46, 46, 0.01) 16.667%,
rgba(46, 46, 46, 0.01) 33.334%,
rgba(226, 226, 226, 0.01) 33.334%,
rgba(226, 226, 226, 0.01) 50.001000000000005%,
rgba(159, 159, 159, 0.01) 50.001%,
rgba(159, 159, 159, 0.01) 66.668%,
rgba(149, 149, 149, 0.01) 66.668%,
rgba(149, 149, 149, 0.01) 83.33500000000001%,
rgba(43, 43, 43, 0.01) 83.335%,
rgba(43, 43, 43, 0.01) 100.002%
),
linear-gradient(
346deg,
rgba(166, 166, 166, 0.03) 0%,
rgba(166, 166, 166, 0.03) 25%,
rgba(240, 240, 240, 0.03) 25%,
rgba(240, 240, 240, 0.03) 50%,
rgba(121, 121, 121, 0.03) 50%,
rgba(121, 121, 121, 0.03) 75%,
rgba(40, 40, 40, 0.03) 75%,
rgba(40, 40, 40, 0.03) 100%
),
linear-gradient(
347deg,
rgba(209, 209, 209, 0.01) 0%,
rgba(209, 209, 209, 0.01) 25%,
rgba(22, 22, 22, 0.01) 25%,
rgba(22, 22, 22, 0.01) 50%,
rgba(125, 125, 125, 0.01) 50%,
rgba(125, 125, 125, 0.01) 75%,
rgba(205, 205, 205, 0.01) 75%,
rgba(205, 205, 205, 0.01) 100%
),
linear-gradient(
84deg,
rgba(195, 195, 195, 0.01) 0%,
rgba(195, 195, 195, 0.01) 14.286%,
rgba(64, 64, 64, 0.01) 14.286%,
rgba(64, 64, 64, 0.01) 28.572%,
rgba(67, 67, 67, 0.01) 28.572%,
rgba(67, 67, 67, 0.01) 42.858%,
rgba(214, 214, 214, 0.01) 42.858%,
rgba(214, 214, 214, 0.01) 57.144%,
rgba(45, 45, 45, 0.01) 57.144%,
rgba(45, 45, 45, 0.01) 71.42999999999999%,
rgba(47, 47, 47, 0.01) 71.43%,
rgba(47, 47, 47, 0.01) 85.71600000000001%,
rgba(172, 172, 172, 0.01) 85.716%,
rgba(172, 172, 172, 0.01) 100.002%
),
linear-gradient(
73deg,
rgba(111, 111, 111, 0.03) 0%,
rgba(111, 111, 111, 0.03) 16.667%,
rgba(202, 202, 202, 0.03) 16.667%,
rgba(202, 202, 202, 0.03) 33.334%,
rgba(57, 57, 57, 0.03) 33.334%,
rgba(57, 57, 57, 0.03) 50.001000000000005%,
rgba(197, 197, 197, 0.03) 50.001%,
rgba(197, 197, 197, 0.03) 66.668%,
rgba(97, 97, 97, 0.03) 66.668%,
rgba(97, 97, 97, 0.03) 83.33500000000001%,
rgba(56, 56, 56, 0.03) 83.335%,
rgba(56, 56, 56, 0.03) 100.002%
),
linear-gradient(
132deg,
rgba(88, 88, 88, 0.03) 0%,
rgba(88, 88, 88, 0.03) 20%,
rgba(249, 249, 249, 0.03) 20%,
rgba(249, 249, 249, 0.03) 40%,
rgba(2, 2, 2, 0.03) 40%,
rgba(2, 2, 2, 0.03) 60%,
rgba(185, 185, 185, 0.03) 60%,
rgba(185, 185, 185, 0.03) 80%,
rgba(196, 196, 196, 0.03) 80%,
rgba(196, 196, 196, 0.03) 100%
),
linear-gradient(
142deg,
rgba(160, 160, 160, 0.03) 0%,
rgba(160, 160, 160, 0.03) 12.5%,
rgba(204, 204, 204, 0.03) 12.5%,
rgba(204, 204, 204, 0.03) 25%,
rgba(108, 108, 108, 0.03) 25%,
rgba(108, 108, 108, 0.03) 37.5%,
rgba(191, 191, 191, 0.03) 37.5%,
rgba(191, 191, 191, 0.03) 50%,
rgba(231, 231, 231, 0.03) 50%,
rgba(231, 231, 231, 0.03) 62.5%,
rgba(70, 70, 70, 0.03) 62.5%,
rgba(70, 70, 70, 0.03) 75%,
rgba(166, 166, 166, 0.03) 75%,
rgba(166, 166, 166, 0.03) 87.5%,
rgba(199, 199, 199, 0.03) 87.5%,
rgba(199, 199, 199, 0.03) 100%
),
linear-gradient(
238deg,
rgba(116, 116, 116, 0.02) 0%,
rgba(116, 116, 116, 0.02) 20%,
rgba(141, 141, 141, 0.02) 20%,
rgba(141, 141, 141, 0.02) 40%,
rgba(152, 152, 152, 0.02) 40%,
rgba(152, 152, 152, 0.02) 60%,
rgba(61, 61, 61, 0.02) 60%,
rgba(61, 61, 61, 0.02) 80%,
rgba(139, 139, 139, 0.02) 80%,
rgba(139, 139, 139, 0.02) 100%
),
linear-gradient(
188deg,
rgba(227, 227, 227, 0.01) 0%,
rgba(227, 227, 227, 0.01) 20%,
rgba(105, 105, 105, 0.01) 20%,
rgba(105, 105, 105, 0.01) 40%,
rgba(72, 72, 72, 0.01) 40%,
rgba(72, 72, 72, 0.01) 60%,
rgba(33, 33, 33, 0.01) 60%,
rgba(33, 33, 33, 0.01) 80%,
rgba(57, 57, 57, 0.01) 80%,
rgba(57, 57, 57, 0.01) 100%
),
linear-gradient(90deg, hsl(237, 0%, 13%), hsl(237, 0%, 13%));
}
.dark .mesh {
background-image: linear-gradient(
158deg,
rgba(84, 84, 84, 0.03) 0%,
rgba(84, 84, 84, 0.03) 20%,
rgba(219, 219, 219, 0.03) 20%,
rgba(219, 219, 219, 0.03) 40%,
rgba(54, 54, 54, 0.03) 40%,
rgba(54, 54, 54, 0.03) 60%,
rgba(99, 99, 99, 0.03) 60%,
rgba(99, 99, 99, 0.03) 80%,
rgba(92, 92, 92, 0.03) 80%,
rgba(92, 92, 92, 0.03) 100%
),
linear-gradient(
45deg,
rgba(221, 221, 221, 0.02) 0%,
rgba(221, 221, 221, 0.02) 14.286%,
rgba(8, 8, 8, 0.02) 14.286%,
rgba(8, 8, 8, 0.02) 28.572%,
rgba(52, 52, 52, 0.02) 28.572%,
rgba(52, 52, 52, 0.02) 42.858%,
rgba(234, 234, 234, 0.02) 42.858%,
rgba(234, 234, 234, 0.02) 57.144%,
rgba(81, 81, 81, 0.02) 57.144%,
rgba(81, 81, 81, 0.02) 71.42999999999999%,
rgba(239, 239, 239, 0.02) 71.43%,
rgba(239, 239, 239, 0.02) 85.71600000000001%,
rgba(187, 187, 187, 0.02) 85.716%,
rgba(187, 187, 187, 0.02) 100.002%
),
linear-gradient(
109deg,
rgba(33, 33, 33, 0.03) 0%,
rgba(33, 33, 33, 0.03) 12.5%,
rgba(147, 147, 147, 0.03) 12.5%,
rgba(147, 147, 147, 0.03) 25%,
rgba(131, 131, 131, 0.03) 25%,
rgba(131, 131, 131, 0.03) 37.5%,
rgba(151, 151, 151, 0.03) 37.5%,
rgba(151, 151, 151, 0.03) 50%,
rgba(211, 211, 211, 0.03) 50%,
rgba(211, 211, 211, 0.03) 62.5%,
rgba(39, 39, 39, 0.03) 62.5%,
rgba(39, 39, 39, 0.03) 75%,
rgba(55, 55, 55, 0.03) 75%,
rgba(55, 55, 55, 0.03) 87.5%,
rgba(82, 82, 82, 0.03) 87.5%,
rgba(82, 82, 82, 0.03) 100%
),
linear-gradient(
348deg,
rgba(42, 42, 42, 0.02) 0%,
rgba(42, 42, 42, 0.02) 20%,
rgba(8, 8, 8, 0.02) 20%,
rgba(8, 8, 8, 0.02) 40%,
rgba(242, 242, 242, 0.02) 40%,
rgba(242, 242, 242, 0.02) 60%,
rgba(42, 42, 42, 0.02) 60%,
rgba(42, 42, 42, 0.02) 80%,
rgba(80, 80, 80, 0.02) 80%,
rgba(80, 80, 80, 0.02) 100%
),
linear-gradient(
120deg,
rgba(106, 106, 106, 0.03) 0%,
rgba(106, 106, 106, 0.03) 14.286%,
rgba(67, 67, 67, 0.03) 14.286%,
rgba(67, 67, 67, 0.03) 28.572%,
rgba(134, 134, 134, 0.03) 28.572%,
rgba(134, 134, 134, 0.03) 42.858%,
rgba(19, 19, 19, 0.03) 42.858%,
rgba(19, 19, 19, 0.03) 57.144%,
rgba(101, 101, 101, 0.03) 57.144%,
rgba(101, 101, 101, 0.03) 71.42999999999999%,
rgba(205, 205, 205, 0.03) 71.43%,
rgba(205, 205, 205, 0.03) 85.71600000000001%,
rgba(53, 53, 53, 0.03) 85.716%,
rgba(53, 53, 53, 0.03) 100.002%
),
linear-gradient(
45deg,
rgba(214, 214, 214, 0.03) 0%,
rgba(214, 214, 214, 0.03) 16.667%,
rgba(255, 255, 255, 0.03) 16.667%,
rgba(255, 255, 255, 0.03) 33.334%,
rgba(250, 250, 250, 0.03) 33.334%,
rgba(250, 250, 250, 0.03) 50.001000000000005%,
rgba(231, 231, 231, 0.03) 50.001%,
rgba(231, 231, 231, 0.03) 66.668%,
rgba(241, 241, 241, 0.03) 66.668%,
rgba(241, 241, 241, 0.03) 83.33500000000001%,
rgba(31, 31, 31, 0.03) 83.335%,
rgba(31, 31, 31, 0.03) 100.002%
),
linear-gradient(
59deg,
rgba(224, 224, 224, 0.03) 0%,
rgba(224, 224, 224, 0.03) 12.5%,
rgba(97, 97, 97, 0.03) 12.5%,
rgba(97, 97, 97, 0.03) 25%,
rgba(143, 143, 143, 0.03) 25%,
rgba(143, 143, 143, 0.03) 37.5%,
rgba(110, 110, 110, 0.03) 37.5%,
rgba(110, 110, 110, 0.03) 50%,
rgba(34, 34, 34, 0.03) 50%,
rgba(34, 34, 34, 0.03) 62.5%,
rgba(155, 155, 155, 0.03) 62.5%,
rgba(155, 155, 155, 0.03) 75%,
rgba(249, 249, 249, 0.03) 75%,
rgba(249, 249, 249, 0.03) 87.5%,
rgba(179, 179, 179, 0.03) 87.5%,
rgba(179, 179, 179, 0.03) 100%
),
linear-gradient(
241deg,
rgba(58, 58, 58, 0.02) 0%,
rgba(58, 58, 58, 0.02) 25%,
rgba(124, 124, 124, 0.02) 25%,
rgba(124, 124, 124, 0.02) 50%,
rgba(254, 254, 254, 0.02) 50%,
rgba(254, 254, 254, 0.02) 75%,
rgba(52, 52, 52, 0.02) 75%,
rgba(52, 52, 52, 0.02) 100%
),
linear-gradient(90deg, #ffffff, #ffffff);
}
/* CSS */
.button-77 {
align-items: center;
appearance: none;
background-clip: padding-box;
background-color: initial;
background-image: none;
border-style: none;
box-sizing: border-box;
cursor: pointer;
display: inline-block;
flex-direction: row;
flex-shrink: 0;
font-size: 16px;
font-weight: 600;
justify-content: center;
line-height: 24px;
margin: 0;
min-height: 64px;
outline: none;
overflow: visible;
padding: 19px 26px;
pointer-events: auto;
text-align: center;
text-decoration: none;
text-transform: none;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
vertical-align: middle;
width: auto;
word-break: keep-all;
z-index: 10;
}
@media (min-width: 768px) {
.button-77 {
padding: 19px 32px;
}
}
.button-77:before,
.button-77:after {
border-radius: 80px;
}
.button-77:before {
background-color: rgba(249, 58, 19, 0.32);
content: "";
display: block;
height: 100%;
left: 0;
overflow: hidden;
position: absolute;
top: 0;
width: 100%;
z-index: -2;
}
.button-77:after {
background-color: initial;
background-image: linear-gradient(92.83deg, #ff7426 0, #f93a13 100%);
bottom: 4px;
content: "";
display: block;
left: 4px;
overflow: hidden;
position: absolute;
right: 4px;
top: 4px;
transition: all 100ms ease-out;
z-index: -1;
}
.button-77:hover:not(:disabled):after {
bottom: 0;
left: 0;
right: 0;
top: 0;
transition-timing-function: ease-in;
}
.button-77:active:not(:disabled) {
color: #ccc;
}
.button-77:active:not(:disabled):after {
background-image: linear-gradient(0deg, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2)),
linear-gradient(92.83deg, #ff7426 0, #f93a13 100%);
bottom: 4px;
left: 4px;
right: 4px;
top: 4px;
}
.button-77:disabled {
cursor: default;
opacity: 0.24;
}
@media (max-width: 767px) {
.main-content {
padding-left: 8px;
padding-right: 8px;
}
}
+13
View File
@@ -0,0 +1,13 @@
// @ts-ignore
export const analyticsEvent = (event, data) => {
// Do something with the event and data
document.dispatchEvent(
new CustomEvent("analyticsEvent", {
bubbles: true,
detail: {
event,
data
}
})
);
};
+288
View File
@@ -0,0 +1,288 @@
// @ts-nocheck
import { base } from "$app/paths";
function siteDataExtractFromDb(data, obj) {
let requestedObject = { ...obj };
for (const key in requestedObject) {
if (Object.prototype.hasOwnProperty.call(requestedObject, key)) {
const element = data[key];
if (data[key]) {
requestedObject[key] = data[key];
}
}
}
//remove any keys that are still null or empty
for (const key in requestedObject) {
if (Object.prototype.hasOwnProperty.call(requestedObject, key)) {
const element = requestedObject[key];
if (element === null || element === "") {
delete requestedObject[key];
}
}
}
return requestedObject;
}
//a function to make an api call to /manage/api/ to store site data
function storeSiteData(data) {
return fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ action: "storeSiteData", data })
});
}
const allRecordTypes = {
A: 1,
NS: 2,
MD: 3,
MF: 4,
CNAME: 5,
SOA: 6,
MB: 7,
MG: 8,
MR: 9,
NULL: 10,
WKS: 11,
PTR: 12,
HINFO: 13,
MINFO: 14,
MX: 15,
TXT: 16,
RP: 17,
AFSDB: 18,
X25: 19,
ISDN: 20,
RT: 21,
NSAP: 22,
NSAP_PTR: 23,
SIG: 24,
KEY: 25,
PX: 26,
GPOS: 27,
AAAA: 28,
LOC: 29,
NXT: 30,
EID: 31,
NIMLOC: 32,
SRV: 33,
ATMA: 34,
NAPTR: 35,
KX: 36,
CERT: 37,
A6: 38,
DNAME: 39,
SINK: 40,
OPT: 41,
APL: 42,
DS: 43,
SSHFP: 44,
IPSECKEY: 45,
RRSIG: 46,
NSEC: 47,
DNSKEY: 48,
DHCID: 49,
NSEC3: 50,
NSEC3PARAM: 51,
TLSA: 52,
SMIMEA: 53,
HIP: 55,
NINFO: 56,
RKEY: 57,
TALINK: 58,
CDS: 59,
CDNSKEY: 60,
OPENPGPKEY: 61,
CSYNC: 62,
SPF: 99,
UINFO: 100,
UID: 101,
GID: 102,
UNSPEC: 103,
NID: 104,
L32: 105,
L64: 106,
LP: 107,
EUI48: 108,
EUI64: 109,
TKEY: 249,
TSIG: 250,
IXFR: 251,
AXFR: 252,
MAILB: 253,
MAILA: 254,
ANY: 255
};
const ValidateIpAddress = function (input) {
// Check if input is a valid IPv4 address with an optional port
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}(:\d{1,5})?$/;
if (ipv4Regex.test(input)) {
return "IPv4";
}
// Improved IPv6 regex that better handles compressed notation
const ipv6Regex =
/^(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){0,7}:|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){6}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:)|(?:[0-9a-fA-F]{1,4}:){1,7}(?::|:[0-9a-fA-F]{1,4})|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?:(?::[0-9a-fA-F]{1,4}){1,6})|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:))(?::\d{1,5})?$/;
if (ipv6Regex.test(input)) {
return "IPv6";
}
// Check if input is a valid domain name with an optional port
const domainRegex = /^[a-zA-Z0-9]+([\-\.]{1}[a-zA-Z0-9]+)*\.[a-zA-Z]{2,}(:\d{1,5})?$/;
if (domainRegex.test(input)) {
return "Domain Name";
}
// If none of the above conditions match, the input is invalid
return "Invalid";
};
function IsValidHost(domain) {
const regex = /^[a-zA-Z0-9]+([\-\.]{1}[a-zA-Z0-9]+)*\.[a-zA-Z]{2,}$/;
return regex.test(domain);
}
function IsValidNameServer(nameServer) {
//8.8.8.8 example
const regex = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/;
return regex.test(nameServer);
}
const IsValidURL = function (url) {
return /^(http|https):\/\/[^ "]+$/.test(url);
};
function ValidateCronExpression(cronExp) {
// Check if expression is provided and is a string
if (!cronExp || typeof cronExp !== "string") {
return { isValid: false, message: "Cron expression must be a non-empty string" };
}
// Split the expression into its components
const fields = cronExp.trim().split(/\s+/);
// Standard cron should have 5 or 6 fields
// minute hour day-of-month month day-of-week [year]
if (fields.length < 5 || fields.length > 6) {
return {
isValid: false,
message: "Cron expression must have 5 or 6 fields"
};
}
// Define field constraints
const fieldConstraints = [
{ name: "minute", min: 0, max: 59 },
{ name: "hour", min: 0, max: 23 },
{ name: "day", min: 1, max: 31 },
{ name: "month", min: 1, max: 12 },
{ name: "weekday", min: 0, max: 6 },
{ name: "year", min: 1970, max: 2099 } // Optional field
];
// Valid characters in cron expressions
const validChars = /^[\d/*,\-]+$/;
// Validate each field
for (let i = 0; i < fields.length; i++) {
const field = fields[i];
const constraint = fieldConstraints[i];
// Check for valid characters
if (!validChars.test(field)) {
return {
isValid: false,
message: `Invalid characters in ${constraint.name} field`
};
}
// Handle special characters
if (field === "*") {
continue; // Asterisk is valid for all fields
}
// Handle lists (comma-separated values)
if (field.includes(",")) {
const values = field.split(",");
for (const value of values) {
if (!isValidRange(value, constraint.min, constraint.max)) {
return {
isValid: false,
message: `Invalid value in ${constraint.name} field: ${value}`
};
}
}
continue;
}
// Handle ranges (with hyphens)
if (field.includes("-")) {
const [start, end] = field.split("-").map(Number);
if (
start == null ||
end == null ||
start < constraint.min ||
end > constraint.max ||
start > end
) {
return {
isValid: false,
message: `Invalid range in ${constraint.name} field: ${field}`
};
}
continue;
}
// Handle steps (with forward slash)
if (field.includes("/")) {
const [range, step] = field.split("/");
if (range !== "*" && !isValidRange(range, constraint.min, constraint.max)) {
return {
isValid: false,
message: `Invalid range in ${constraint.name} field: ${range}`
};
}
if (!isValidRange(step, 1, constraint.max)) {
return {
isValid: false,
message: `Invalid step value in ${constraint.name} field: ${step}`
};
}
continue;
}
// Handle plain numbers
if (!isValidRange(field, constraint.min, constraint.max)) {
return {
isValid: false,
message: `Invalid value in ${constraint.name} field: ${field}`
};
}
}
return { isValid: true, message: "Valid cron expression" };
}
function isValidRange(value, min, max) {
const num = Number(value);
return !isNaN(num) && num >= min && num <= max;
}
function SortMonitor(monitorSort, resp) {
let monitors = [];
if (!!monitorSort && monitorSort.length > 0) {
monitors = monitorSort.map((id) => resp.find((m) => m.id == id)).filter((m) => !!m);
//append any new monitors
monitors = [...monitors, ...resp.filter((m) => !monitorSort.includes(m.id))];
} else {
monitors = resp;
}
return monitors;
}
export {
siteDataExtractFromDb,
storeSiteData,
allRecordTypes,
ValidateIpAddress,
IsValidHost,
IsValidNameServer,
IsValidURL,
ValidateCronExpression,
SortMonitor
};
+14
View File
@@ -0,0 +1,14 @@
// @ts-nocheck
import { GetAllSiteData } from "$lib/server/controllers/controller.js";
async function StatusColor(status) {
let site = await GetAllSiteData();
return {
UP: site.colors?.UP || "#00dfa2",
DEGRADED: site.colors?.DEGRADED || "#e6ca61",
DOWN: site.colors?.DOWN || "#ca3038",
NO_DATA: "#f1f5f8"
};
}
export default StatusColor;
+137
View File
@@ -0,0 +1,137 @@
<script>
import { formatDistanceToNow, formatDistance } from "date-fns";
import * as Accordion from "$lib/components/ui/accordion";
import { l, f, fd, fdn } from "$lib/i18n/client";
import { base } from "$app/paths";
export let incident;
export let index;
export let lang;
export let selectedLang = "en";
let startTime = new Date(incident.start_date_time * 1000);
let endTime = new Date();
let nowTime = new Date();
if (incident.end_date_time) {
endTime = new Date(incident.end_date_time * 1000);
}
const lastedFor = fd(startTime, endTime, selectedLang);
const startedAt = fdn(startTime, selectedLang);
let isFuture = false;
//is future incident
if (nowTime < startTime) {
isFuture = true;
}
</script>
<div class="newincident relative grid w-full grid-cols-12 gap-2 px-0 py-0 last:border-b-0">
<div class="col-span-12">
<Accordion.Root bind:value={index} class="accor">
<Accordion.Item value="incident-0">
<Accordion.Trigger class="px-4 hover:bg-muted hover:no-underline">
<div class="justify-start text-left hover:no-underline">
<p
class="scroll-m-20 text-xs font-semibold leading-5 tracking-normal badge-{incident.state}"
>
{l(lang, incident.state)}
</p>
<p class="scroll-m-20 text-lg font-medium tracking-tight">
{incident.title}
</p>
<p
class="scroll-m-20 text-sm font-medium tracking-wide text-muted-foreground"
>
{#if !isFuture && incident.state != "RESOLVED"}
<span>
{l(lang, "Started about %startedAt, still ongoing", {
startedAt
})}
</span>
{:else if !isFuture && incident.state == "RESOLVED"}
<span>
{l(
lang,
"Started about %startedAt, lasted for about %lastedFor",
{ startedAt, lastedFor }
)}
</span>
{:else if isFuture && incident.state != "RESOLVED"}
<span>
{l(lang, "Starts in %startedAt", { startedAt })}
</span>
{:else if isFuture && incident.state == "RESOLVED"}
<span>
{l(
lang,
"Starts in %startedAt, will last for about %lastedFor",
{ startedAt, lastedFor }
)}
</span>
{/if}
</p>
</div>
</Accordion.Trigger>
<Accordion.Content>
<div class="px-4 pt-2">
{#if incident.monitors.length > 0}
<div class="flex gap-2">
{#each incident.monitors as monitor}
<div
class="tag-affected-text flex gap-x-2 rounded-md bg-secondary px-1 py-1 pr-2"
>
<div
class="bg-api-{monitor.impact_type.toLowerCase()} rounded px-1.5 py-1 text-xs font-semibold text-primary-foreground"
>
{monitor.impact_type}
</div>
{#if monitor.image}
<img src={base + monitor.image} class="mt-1 h-4 w-4" />
{/if}
<div class="mt-0.5 font-medium">
{monitor.name}
</div>
</div>
{/each}
</div>
{/if}
<p class="my-3 text-xs font-semibold uppercase text-muted-foreground">
{l(lang, "Updates")}
</p>
{#if incident.comments.length > 0}
<ol class="relative mt-2 pl-14">
{#each incident.comments as comment}
<li class="relative border-l pb-4 pl-[4.5rem] last:border-0">
<div
class="absolute top-0 w-28 -translate-x-32 rounded border bg-secondary px-1.5 py-1 text-center text-xs font-semibold"
>
{l(lang, comment.state)}
</div>
<time
class=" mb-1 text-sm font-medium leading-none text-muted-foreground"
>
{f(
new Date(comment.commented_at * 1000),
"MMMM do yyyy, h:mm:ss a",
selectedLang
)}
</time>
<p class="mb-4 text-sm font-normal">
{comment.comment}
</p>
</li>
{/each}
</ol>
{:else}
<p class="text-sm font-medium">
{l(lang, "No Updates Yet")}
</p>
{/if}
</div>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
</div>
</div>
-166
View File
@@ -1,166 +0,0 @@
<script>
import * as Card from "$lib/components/ui/card";
import { Separator } from "$lib/components/ui/separator";
import { StatusObj } from "$lib/helpers.js";
import moment from "moment";
import { Button } from "$lib/components/ui/button";
import { Badge } from "$lib/components/ui/badge";
import { ChevronDown } from "lucide-svelte";
import * as Collapsible from "$lib/components/ui/collapsible";
import { l } from '$lib/i18n/client';
import axios from "axios";
import { Skeleton } from "$lib/components/ui/skeleton";
import { base } from '$app/paths';
export let incident;
export let variant = "title+body+comments+monitor";
export let state = "open";
export let monitor;
export let lang;
let blinker = "bg-transparent";
let incidentPriority = "";
let incidentDuration = 0;
if (incident.labels.includes("incident-down")) {
blinker = "bg-red-500";
incidentPriority = "DOWN";
} else if (incident.labels.includes("incident-degraded")) {
blinker = "bg-yellow-500";
incidentPriority = "DEGRADED";
}
let incidentState = incident.state;
let incidentClosedAt = incident.incident_end_time;
let incidentCreatedAt = incident.incident_start_time;
let incidentMessage = "";
if (!!incidentClosedAt && !!incidentCreatedAt) {
//incidentDuration between closed_at and created_at
incidentDuration = moment(incidentClosedAt * 1000)
.add(1, "minutes")
.diff(moment(incidentCreatedAt * 1000), "minutes");
} else if (!!incidentCreatedAt) {
//incidentDuration between now and created_at
incidentDuration = moment().diff(moment(incidentCreatedAt * 1000), "minutes");
}
//find a replace /\[start_datetime:(\d+)\]/ empty in incident.body
//find a replace /\[end_datetime:(\d+)\]/ empty in incident.body
incident.body = incident.body.replace(/\[start_datetime:(\d+)\]/g, "");
incident.body = incident.body.replace(/\[end_datetime:(\d+)\]/g, "");
//fetch comments
incident.comments = [];
let commentsLoading = true
function getComments(){
state = (state=='open'? 'close':'open');
if(incident.comments.length > 0) return;
if(commentsLoading === false) return;
axios.get(`${base}/incident/${incident.number}/comments`).then((response) => {
incident.comments = response.data;
commentsLoading = false;
}).catch((error) => {
// console.log(error);
});
}
</script>
<div class="grid grid-cols-3 gap-4 mb-8 w-full incident-div">
<div class="col-span-3">
<Card.Root>
<Card.Header>
<Card.Title class="relative">
{#if incidentPriority != "" && incidentDuration > 0}
<p class="leading-10 absolute -top-11 -translate-y-1">
<Badge class="text-[rgba(0,0,0,.6)] -ml-3 bg-card text-sm font-semibold text-{StatusObj[incidentPriority]} "> {incidentPriority} for {incidentDuration} Minute{incidentDuration > 1 ? "s" : ""} </Badge>
</p>
{/if}
{#if variant.includes("monitor")}
<div class="pb-4">
<div class="scroll-m-20 text-2xl font-semibold tracking-tight">
{#if monitor.image}
<img src="{monitor.image}" class="w-6 h-6 inline" alt="" srcset="" />
{/if} {monitor.name}
</div>
</div>
{/if} {#if variant.includes("title")} {incident.title} {/if}
{#if incidentState == 'open'}
<span class="animate-ping absolute -left-[24px] -top-[24px] w-[8px] h-[8px] inline-flex rounded-full {blinker} opacity-75"></span>
{/if}
{#if variant.includes("body") || variant.includes("comments")}
<div class="absolute right-4 toggle {state}">
<Button variant="outline" class="rounded-full" size="icon" on:click="{getComments}">
<ChevronDown class="text-muted-foreground" size="{24}" />
</Button>
</div>
{/if}
</Card.Title>
<Card.Description>
{moment(incidentCreatedAt * 1000).format("MMMM Do YYYY, h:mm:ss a")}
<p class="mt-2 leading-8">
{#if incident.labels.includes("identified")}
<span class="mt-1 text-xs font-semibold me-2 px-2.5 py-1 uppercase leading-3 inline-block rounded tag-indetified">
{l(lang,'incident.identified')}
</span>
{/if} {#if incident.labels.includes("resolved")}
<span class=" text-xs font-semibold me-2 px-2.5 py-1 leading-3 inline-block rounded uppercase tag-resolved">
{l(lang,'incident.resolved')}
</span>
{/if} {#if incident.labels.includes("maintenance")}
<span class="text-xs font-semibold me-2 px-2.5 py-1 leading-3 inline-block rounded uppercase tag-maintenance">
{l(lang,'incident.maintenance')}
</span>
{/if}
</p>
</Card.Description>
</Card.Header>
{#if (variant.includes("body") || variant.includes("comments")) && state == "open"}
<Card.Content>
{#if variant.includes("body")}
<div class="prose prose-stone dark:prose-invert max-w-none prose-code:px-[0.3rem] prose-code:py-[0.2rem] prose-code:font-mono prose-code:text-sm prose-code:rounded">
{@html incident.body}
</div>
{/if}
{#if variant.includes("comments") && incident.comments?.length > 0}
<div class="ml-4 mt-8">
<ol class="relative border-s border-secondary">
{#each incident.comments as comment}
<li class="mb-10 ms-4">
<div class="absolute w-3 h-3 rounded-full mt-1.5 -start-1.5 border bg-secondary border-secondary"></div>
<time class="mb-1 text-sm font-normal leading-none text-muted-foreground"> {moment(comment.created_at).format("MMMM Do YYYY, h:mm:ss a")} </time>
<div
class="mb-4 text-base font-normal wysiwyg dark:prose-invert prose prose-stone max-w-none prose-code:px-[0.3rem] prose-code:py-[0.2rem] prose-code:font-mono prose-code:text-sm prose-code:rounded"
>
{@html comment.body}
</div>
</li>
{/each}
</ol>
</div>
{:else if commentsLoading}
<Skeleton class="w-[100px] h-[20px] rounded-full" />
{/if}
</Card.Content>
{/if}
</Card.Root>
</div>
</div>
<style>
.toggle {
display: none;
}
.toggle{
transition: all 0.15s ease-in-out;
}
.toggle.open{
transform: rotate(180deg);
}
.incident-div:hover .toggle {
display: block;
}
</style>
+11
View File
@@ -0,0 +1,11 @@
<script>
let i = 24 * 60;
</script>
<!-- Loop i times -->
{#each Array.from({ length: i }, (ii, i) => i) as k}
<div
class="today-sq animatebg m-[1px] h-[10px] w-[10px]"
style="animation-delay:{Math.random(0, 1) * (k * 15)}ms;"
></div>
{/each}
+209
View File
@@ -0,0 +1,209 @@
<script>
import { onMount } from "svelte";
import { base } from "$app/paths";
import moment from "moment";
import { Loader, ChevronLeft, ChevronRight } from "lucide-svelte";
export let data;
let pageNo = 1;
let limit = 20;
let alerts = [];
let totalPages = 0;
let loadingData = false;
let incidentURL = "";
if (data.siteData.github && data.siteData.github.owner && data.siteData.github.repo) {
incidentURL = `https://github.com/${data.siteData.github.owner}/${data.siteData.github.repo}/issues`;
}
async function loadData() {
loadingData = true;
try {
let apiResp = await fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
action: "getAllAlertsPaginated",
data: { page: pageNo, limit }
})
});
let resp = await apiResp.json();
alerts = resp.alerts;
totalPages = Math.ceil(resp.total.count / limit);
} catch (error) {
alert("Error: " + error);
} finally {
loadingData = false;
}
}
onMount(async () => {
await loadData();
});
</script>
<div class="mt-4">
<div class="mb-4 flex justify-between">
<div>
{#if loadingData}
<Loader class="mt-6 h-4 w-4 animate-spin" />
{/if}
</div>
<!-- Pagination -->
{#if totalPages > 1}
<nav class="flex max-w-2xl items-center gap-x-1" aria-label="Pagination">
<button
disabled={pageNo === 1}
type="button"
class="inline-flex min-h-[38px] min-w-[38px] items-center justify-center gap-x-2 rounded-lg border border-transparent px-2.5 py-2 text-sm text-gray-800 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none disabled:pointer-events-none disabled:opacity-50 dark:border-transparent dark:text-white dark:hover:bg-white/10 dark:focus:bg-white/10"
aria-label="Previous"
on:click={() => {
pageNo--;
loadData();
}}
>
<ChevronLeft class="size-3.5 shrink-0" />
<span class="sr-only">Previous</span>
</button>
<div class="flex max-w-[380px] items-center gap-x-1 overflow-x-auto">
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
<button
on:click={() => {
pageNo = page;
loadData();
}}
type="button"
class="flex min-h-[38px] min-w-[38px] items-center justify-center rounded-lg border {page ==
pageNo
? 'border-gray-200'
: 'border-transparent'} px-3 py-2 text-sm text-gray-800 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none disabled:pointer-events-none disabled:opacity-50 dark:border-transparent dark:text-white dark:hover:bg-white/10 dark:focus:bg-white/10"
>
{page}
</button>
{/each}
</div>
<button
type="button"
disabled={pageNo === totalPages}
class="inline-flex min-h-[38px] min-w-[38px] items-center justify-center gap-x-2 rounded-lg border border-transparent px-2.5 py-2 text-sm text-gray-800 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none disabled:pointer-events-none disabled:opacity-50 dark:border-transparent dark:text-white dark:hover:bg-white/10 dark:focus:bg-white/10"
aria-label="Next"
on:click={() => {
pageNo++;
loadData();
}}
>
<span class="sr-only">Next</span>
<ChevronRight class="size-3.5 shrink-0" />
</button>
</nav>
{/if}
<!-- End Pagination -->
</div>
<div class="flex flex-col">
<div class="-m-1.5 overflow-x-auto">
<div class="inline-block min-w-full p-1.5 align-middle">
<div class="overflow-hidden rounded-lg border dark:border-neutral-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-neutral-700">
<thead>
<tr>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
>ID</th
>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
>Type</th
>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
>Monitor</th
>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
>Status</th
>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
>Checks</th
>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
>Incident</th
>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
>Created At</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-neutral-700">
{#each alerts as alert}
<tr>
<td class="whitespace-nowrap px-6 py-4 text-sm font-medium">
{alert.id}
</td>
<td class="whitespace-nowrap px-6 py-4 text-xs font-semibold">
{#if alert.monitor_status === "DOWN"}
<span class="text-red-500">
{alert.monitor_status}
</span>
{:else}
<span class="text-yellow-500">
{alert.monitor_status}
</span>
{/if}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm">
{alert.monitor_tag}
</td>
<td class="whitespace-nowrap px-6 py-4 text-xs font-semibold">
{#if alert.alert_status === "RESOLVED"}
<span class="text-blue-500">
{alert.alert_status}
</span>
{:else}
<span class="text-fuchsia-500">
{alert.alert_status}
</span>
{/if}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm">
{alert.health_checks}
</td>
<td class="whitespace-nowrap px-6 py-4 text-xs font-semibold">
{#if !!alert.incident_number}
<a
target="_blank"
href="{incidentURL}/{alert.incident_number}"
class="text-cyan-500 hover:underline focus:outline-none"
>
{alert.incident_number}
</a>
{:else}
-
{/if}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm">
{moment
.utc(alert.created_at, "YYYY-MM-DD HH:mm:ss")
.local()
.format("YYYY-MM-DD HH:mm:ss")}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
+271
View File
@@ -0,0 +1,271 @@
<script>
import { onMount } from "svelte";
import { base } from "$app/paths";
import moment from "moment";
import { Button } from "$lib/components/ui/button";
import { Plus, X, Settings, Bell, Loader, Copy, Check } from "lucide-svelte";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
let apiKeys = [];
let loaderLoadingAll = false;
let loaderCreateNew = false;
let newAPIKeyName = "";
let newKeyResp = {};
let showCreateModal = false;
async function loadAPIKeys() {
loaderLoadingAll = true;
try {
let apiResp = await fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
action: "getAPIKeys",
data: {}
})
});
let resp = await apiResp.json();
apiKeys = resp;
} catch (error) {
alert("Error: " + error);
} finally {
loaderLoadingAll = false;
}
}
async function createNew() {
newKeyResp = {};
loaderCreateNew = true;
try {
let apiResp = await fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
action: "createNewApiKey",
data: {
name: newAPIKeyName
}
})
});
newKeyResp = await apiResp.json();
loadAPIKeys();
showCreateModal = false;
} catch (error) {
alert("Error: " + error);
} finally {
loaderCreateNew = false;
}
}
onMount(() => {
loadAPIKeys();
});
function copyKey() {
navigator.clipboard.writeText(newKeyResp.apiKey);
}
function updateStatus(apiKey) {
apiKey.status = apiKey.status == "ACTIVE" ? "INACTIVE" : "ACTIVE";
fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
action: "updateApiKeyStatus",
data: {
id: apiKey.id,
status: apiKey.status
}
})
});
}
</script>
<div class="mt-4">
<div class="mb-4 flex justify-between">
<div>
{#if loaderLoadingAll}
<Loader class="mt-6 h-4 w-4 animate-spin" />
{/if}
</div>
<div>
<Button
on:click={(e) => {
showCreateModal = true;
}}
>
<Plus class="mr-2 h-4 w-4" /> Create New API Key
</Button>
</div>
</div>
</div>
{#if !!newKeyResp && !!newKeyResp.apiKey}
<div class="my-4 rounded-lg border border-green-700 bg-green-800 bg-opacity-20 p-4">
<p class="font-medium">
<picture class="mr-1 inline-block">
<source
srcset="https://fonts.gstatic.com/s/e/notoemoji/latest/1f389/512.webp"
type="image/webp"
/>
<img
src="https://fonts.gstatic.com/s/e/notoemoji/latest/1f389/512.gif"
alt="🎉"
width="24"
height="24"
/>
</picture>
API Key Created
</p>
<p
class="relative my-2 rounded-sm border bg-card px-4 py-2 pr-8 font-mono text-sm font-medium"
>
{newKeyResp.apiKey}
<Button
size="icon"
variant="ghost"
class="copybtn absolute right-2 top-2 h-5 w-5 p-1"
on:click={copyKey}
>
<Check class="check-btn absolute left-0 top-0 h-4 w-4 text-green-500" />
<Copy class="copy-btn absolute left-0 top-0 h-4 w-4 " />
</Button>
</p>
<p class="text-xs text-muted-foreground">
Your new API key has been created. It will be <b class="uppercase underline"
>not be shown again</b
>, so make sure to save it.
</p>
</div>
{/if}
<div class="flex flex-col">
<div class="-m-1.5 overflow-x-auto">
<div class="inline-block min-w-full p-1.5 align-middle">
<div class="overflow-hidden rounded-lg border dark:border-neutral-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-neutral-700">
<thead>
<tr>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
>Name</th
>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
>Key</th
>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
>Created At</th
>
<th
scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase text-gray-500 dark:text-neutral-500"
></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-neutral-700">
{#each apiKeys as apiKey}
<tr>
<td
class="whitespace-nowrap px-6 py-4 text-sm font-medium text-gray-800 dark:text-neutral-200"
>
{apiKey.name}
</td>
<td
class="whitespace-nowrap px-6 py-4 text-xs font-semibold text-gray-800 dark:text-neutral-200"
>
{apiKey.masked_key.slice(-32)}
</td>
<td
class="whitespace-nowrap px-6 py-4 text-sm text-gray-800 dark:text-neutral-200"
>
{moment
.utc(apiKey.created_at, "YYYY-MM-DD HH:mm:ss")
.local()
.format("YYYY-MM-DD HH:mm:ss")}
</td>
<td
class="whitespace-nowrap px-6 py-4 text-xs font-semibold text-gray-800 dark:text-neutral-200"
>
<label class="inline-flex cursor-pointer items-center">
<input
type="checkbox"
value=""
class="peer sr-only"
checked={apiKey.status == "ACTIVE"}
on:change={() => {
updateStatus(apiKey);
}}
/>
<div
class="peer relative h-6 w-11 rounded-full bg-gray-200 after:absolute after:start-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rtl:peer-checked:after:-translate-x-full dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"
></div>
</label>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
</div>
{#if showCreateModal}
<div
class="moldal-container fixed left-0 top-0 z-30 h-screen w-full bg-card bg-opacity-30 backdrop-blur-sm"
>
<div
class="absolute left-1/2 top-1/2 h-fit w-full max-w-xl -translate-x-1/2 -translate-y-1/2 rounded-md border bg-background shadow-lg backdrop-blur-lg"
>
<Button
variant="ghost"
on:click={() => {
showCreateModal = false;
}}
class="absolute right-2 top-2 z-40 h-6 w-6 rounded-full border bg-background p-1"
>
<X class="h-4 w-4 text-muted-foreground" />
</Button>
<div class="content px-4 py-4">
<h2 class="text-lg font-semibold">Create a new API Key</h2>
<p class="text-xs text-muted-foreground">
API keys are used to authenticate your requests to the API. They are unique to
your account and should be kept secret.
</p>
<hr class="my-4" />
<form on:submit|preventDefault={createNew}>
<div class="flex flex-col gap-4">
<div>
<Label for="newAPIKeyName">Name</Label>
<Input
bind:value={newAPIKeyName}
class="mt-2"
type="text"
id="newAPIKeyName"
placeholder="eg. My API Key"
/>
</div>
</div>
<div class="mt-4 flex justify-end">
<Button variant="secondary" type="submit" disabled={loaderCreateNew}>
Create
{#if loaderCreateNew}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</div>
</div>
</div>
{/if}
+600
View File
@@ -0,0 +1,600 @@
<script>
import * as Card from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { siteDataExtractFromDb, storeSiteData } from "$lib/clientTools.js";
import { base } from "$app/paths";
import * as Select from "$lib/components/ui/select";
import locales from "$lib/locales/locales.json?raw";
import { Loader, X, Plus, Info, Play } from "lucide-svelte";
import { Tooltip } from "bits-ui";
export let data;
let formStateHero = "idle";
let formStateNav = "idle";
let formStateFooter = "idle";
let formStateIncident = "idle";
let formStatei18n = "idle";
let formStateCategories = "idle";
let hero = {
title: "",
subtitle: ""
};
let footerHTML = "";
let homeIncidentCount = 10;
let homeIncidentStartTimeWithin = 30;
let nav = [];
let categories = [];
let i18n = {
defaultLocale: "en",
locales: JSON.parse(locales).map((el) => {
return {
code: el.code,
name: el.name,
selected: true,
disabled: false
};
})
};
if (data.siteData?.nav) {
nav = data.siteData.nav;
}
if (data.siteData?.categories) {
categories = data.siteData.categories;
}
if (data.siteData?.hero) {
hero = data.siteData.hero;
}
if (data.siteData.homeIncidentCount) {
homeIncidentCount = data.siteData.homeIncidentCount;
}
if (data.siteData.homeIncidentStartTimeWithin) {
homeIncidentStartTimeWithin = data.siteData.homeIncidentStartTimeWithin;
}
if (data.siteData.footerHTML) {
footerHTML = data.siteData.footerHTML;
}
if (data.siteData.i18n) {
i18n = data.siteData.i18n;
//add locales that are not present in the data
JSON.parse(locales).forEach((el) => {
if (!i18n.locales.find((locale) => locale.code === el.code)) {
i18n.locales.push({
code: el.code,
name: el.name,
selected: false,
disabled: false
});
}
});
}
async function formSubmitHero() {
formStateHero = "loading";
let resp = await storeSiteData({
hero: JSON.stringify(hero)
});
//print data
let data = await resp.json();
formStateHero = "idle";
if (data.error) {
alert(data.error);
return;
}
}
let navErrorMessage = "";
async function formSubmitNav() {
navErrorMessage = "";
formStateNav = "loading";
let resp = await storeSiteData({
nav: JSON.stringify(nav)
});
//print data
let data = await resp.json();
formStateNav = "idle";
if (data.error) {
navErrorMessage = data.error;
return;
}
}
async function formSubmitCategories() {
formStateCategories = "loading";
let resp = await storeSiteData({
categories: JSON.stringify(categories)
});
//print data
let data = await resp.json();
formStateCategories = "idle";
if (data.error) {
alert(data.error);
return;
}
}
async function formSubmitFooter() {
formStateFooter = "loading";
let resp = await storeSiteData({
footerHTML: footerHTML
});
//print data
let data = await resp.json();
formStateFooter = "idle";
if (data.error) {
alert(data.error);
return;
}
}
async function formSubmitIncident() {
formStateIncident = "loading";
let resp = await storeSiteData({
homeIncidentCount: homeIncidentCount,
homeIncidentStartTimeWithin: homeIncidentStartTimeWithin
});
//print data
let data = await resp.json();
formStateIncident = "idle";
if (data.error) {
alert(data.error);
return;
}
}
async function handleFileChangeLogo(event, i) {
const file = event.target.files[0];
if (!file) {
alert("Please select a file to upload.");
return;
}
if (file.size > 100000) {
alert("File size should be less than 100KB");
return;
}
nav[i].uploading = true;
const formData = new FormData();
formData.append("image", file);
try {
const response = await fetch(base + "/manage/app/upload", {
method: "POST",
body: formData
});
if (response.ok) {
const result = await response.json();
nav[i].iconURL = "/uploads/" + result.filename;
} else {
alert("Failed to upload file.");
}
} catch (error) {
alert("An error occurred while uploading the file.");
} finally {
nav[i].uploading = false;
}
}
function addNewRow() {
nav = [
...nav,
{
name: "",
url: "",
iconURL: ""
}
];
}
function addNewCategory() {
categories = [
...categories,
{
name: "",
description: ""
}
];
}
function removeRow(i) {
nav = nav.filter((_, index) => index !== i);
}
function i18nChange(e) {
i18n.defaultLocale = e.value;
}
async function formSubmiti18n() {
formStatei18n = "loading";
let resp = await storeSiteData({
i18n: JSON.stringify(i18n)
});
//print data
let data = await resp.json();
formStatei18n = "idle";
if (data.error) {
alert(data.error);
return;
}
}
</script>
<Card.Root class="mt-4">
<Card.Header class="border-b">
<Card.Title>Hero Section</Card.Title>
<Card.Description>
Configure the hero section of your site. This is the first section that appears on your
site.
</Card.Description>
</Card.Header>
<Card.Content>
<form class="mx-auto mt-4 space-y-4" on:submit|preventDefault={formSubmitHero}>
<div class="flex w-full flex-row justify-evenly gap-2">
<div class="w-full">
<Label for="hero_title">
Title
<Tooltip.Root openDelay={100}>
<Tooltip.Trigger class="">
<Info class="inline-block h-4 w-4 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="max-w-md">
<div
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
>
You can set the title of the hero section here. This will be
displayed on the top of the page.
</div>
</Tooltip.Content>
</Tooltip.Root>
</Label>
<Input
bind:value={hero.title}
class="mt-2"
type="text"
id="hero_title"
placeholder="Status Page for Kener"
/>
</div>
</div>
<div class="flex w-full flex-row justify-evenly gap-2">
<div class="w-full">
<Label for="hero_subtitle">
Subtitle
<Tooltip.Root openDelay={100}>
<Tooltip.Trigger class="">
<Info class="inline-block h-4 w-4 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="max-w-md">
<div
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
>
You can set the subtitle of the hero section here. This will be
displayed below the title.
</div>
</Tooltip.Content>
</Tooltip.Root>
</Label>
<Input
bind:value={hero.subtitle}
class="mt-2"
type="text"
id="hero_subtitle"
placeholder="Welcome to our status page"
/>
</div>
</div>
<div class="flex w-full justify-end">
<Button type="submit" disabled={formStateHero === "loading"}>
Save
{#if formStateHero === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</Card.Content>
</Card.Root>
<Card.Root class="mt-4">
<Card.Header class="border-b">
<Card.Title>Incidents</Card.Title>
<Card.Description>
Control how many recent updated incidents you want to show on the homepage.
</Card.Description>
</Card.Header>
<Card.Content>
<form class="mx-auto mt-4 space-y-4" on:submit|preventDefault={formSubmitIncident}>
<div class="flex max-w-md flex-row justify-start gap-2">
<div class="">
<Label for="hero_title">Maximum Number to Show</Label>
<Input
bind:value={homeIncidentCount}
class="mt-2"
type="text"
id="homeIncidentCount"
placeholder="10"
/>
</div>
<div class="">
<Label for="hero_title">Start time within x days</Label>
<Input
bind:value={homeIncidentStartTimeWithin}
class="mt-2"
type="text"
id="homeIncidentStartTimeWithin"
placeholder="2 days"
/>
</div>
<div class="pt-8">
<Button type="submit" disabled={formStateIncident === "loading"}>
Save
{#if formStateIncident === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</div>
<p class="text-sm text-muted-foreground">
Translates to "Show {homeIncidentCount} incidents that have started in the last {homeIncidentStartTimeWithin}
days in the past or going to start in {homeIncidentStartTimeWithin} days in the future"
</p>
</form>
</Card.Content>
</Card.Root>
<Card.Root class="mt-4">
<Card.Header class="border-b">
<Card.Title>Navigation</Card.Title>
<Card.Description>
Configure the navigation of your site. This is the first section that appears on your
site.
</Card.Description>
</Card.Header>
<Card.Content>
<form class="mx-auto mt-4 space-y-4" on:submit|preventDefault={formSubmitNav}>
{#each nav as navItem, i}
<div class="mb-8 flex w-full flex-row justify-evenly gap-2">
<div class=" grid grid-cols-12 gap-x-2">
<div class="relative col-span-3 {!!navItem.iconURL ? 'pl-12' : ''}">
{#if !!navItem.iconURL}
<div
class="absolute left-0 top-5 mt-2 h-[30px] w-[30px] rounded-sm border p-1"
>
<img src={base + navItem.iconURL} class="" alt="" />
<Button
variant="secondary"
class="absolute -right-2.5 -top-1.5 h-4 w-4 rounded-full p-0"
on:click={() => (navItem.iconURL = "")}
>
<X class="h-3 w-3" />
</Button>
</div>
{/if}
<Label for="logo">Icon</Label>
<Input
class=""
id="logo"
disabled={!!navItem.uploading}
type="file"
accept=".jpg, .jpeg, .png"
on:change={(e) => {
handleFileChangeLogo(e, i);
}}
/>
</div>
<div class="col-span-3">
<Label for="key">Title</Label>
<Input
bind:value={navItem.name}
type="text"
id="key"
placeholder="Documentation"
/>
</div>
<div class="col-span-5">
<Label for="value">URL</Label>
<Input
bind:value={navItem.url}
type="text"
id="value"
placeholder="/docs"
/>
</div>
<div class="col-span-1">
<Button variant="ghost" class="mt-6" on:click={() => removeRow(i)}>
<X class="h-4 w-4" />
</Button>
</div>
</div>
</div>
<hr />
{/each}
<div class="relative">
<hr class="border-1 border-border-input relative top-4 h-px border-dashed" />
<Button
on:click={addNewRow}
variant="secondary"
class="absolute left-1/2 h-8 w-8 -translate-x-1/2 p-0 "
>
<Plus class="h-4 w-4 " />
</Button>
</div>
<div class="flex w-full justify-end" style="margin-top:32px">
<p class="px-4 pt-1">
<span class="text-xs text-red-500"> {navErrorMessage} </span>
</p>
<Button type="submit" disabled={formStateHero === "loading"}>
Save
{#if formStateHero === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</Card.Content>
</Card.Root>
<Card.Root class="mt-4">
<Card.Header class="border-b">
<Card.Title>Internationalization</Card.Title>
<Card.Description>
Configure the languages of your site. This is the first section that appears on your
site.
</Card.Description>
</Card.Header>
<Card.Content>
<form class="mx-auto mt-4 space-y-4" on:submit|preventDefault={formSubmiti18n}>
<p class="text-sm font-medium">All available languages</p>
<div class="mb-8 flex w-full flex-row flex-wrap gap-x-4">
{#each i18n.locales as locale}
<div>
<label>
<input
on:change={(e) => {
locale.selected = e.target.checked;
}}
type="checkbox"
checked={locale.selected}
disabled={i18n.defaultLocale === locale.code}
/>
{locale.name}
</label>
</div>
{/each}
</div>
<p class="text-sm font-medium">Select Default Language</p>
<Select.Root portal={null} onSelectedChange={i18nChange}>
<Select.Trigger class="w-[180px]">
<Select.Value
placeholder={i18n.locales.find((el) => el.code == i18n.defaultLocale).name}
/>
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.Label>Languages</Select.Label>
{#each i18n.locales as locale}
<Select.Item
value={locale.code}
label={locale.name}
disabled={!locale.selected}
class="text-sm font-medium"
>
{locale.name}
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
<div class="flex w-full justify-end">
<Button type="submit" disabled={formStatei18n === "loading"}>
Save
{#if formStatei18n === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</Card.Content>
</Card.Root>
<Card.Root class="mt-4">
<Card.Header class="border-b">
<Card.Title>Site Categories</Card.Title>
<Card.Description>
Configure the categories of your site. This is the first section that appears on your
site.
</Card.Description>
</Card.Header>
<Card.Content>
<form class="mx-auto mt-4 space-y-4" on:submit|preventDefault={formSubmitCategories}>
{#each categories as cat, i}
<div class="grid grid-cols-4 gap-2">
<div class="col-span-1">
<Label for="{i}catkey" class="block w-full">Name</Label>
<Input
bind:value={cat.name}
class="mt-2"
type="text"
id="{i}catkey"
placeholder="Category Name"
disabled={i === 0}
/>
</div>
<div class="relative col-span-3 pr-8">
<Label for="{i}catdsc" class="block w-full">Description</Label>
<Input
bind:value={cat.description}
class="mt-2"
type="text"
id="{i}catdsc"
placeholder="Category Description"
disabled={i === 0}
/>
<Button
variant="ghost"
class="absolute right-0 top-8 ml-2 h-6 w-6 p-1 "
disabled={i === 0}
on:click={() =>
(categories = categories.filter((_, index) => index !== i))}
>
<X class="h-5 w-5" />
</Button>
</div>
</div>
{/each}
<div class="relative pb-8">
<hr class="border-1 border-border-input relative top-4 h-px border-dashed" />
<Button
on:click={addNewCategory}
variant="secondary"
class="absolute left-1/2 h-8 w-8 -translate-x-1/2 p-0 "
>
<Plus class="h-4 w-4 " />
</Button>
</div>
<div class="flex w-full justify-end">
<Button type="submit" disabled={formStateCategories === "loading"}>
Save
{#if formStateCategories === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</Card.Content>
</Card.Root>
<Card.Root class="mt-4">
<Card.Header class="border-b">
<Card.Title>Site Footer</Card.Title>
<Card.Description>
Configure the footer of your site. This is the first section that appears on your site.
</Card.Description>
</Card.Header>
<Card.Content>
<form class="mx-auto mt-4 space-y-4" on:submit|preventDefault={formSubmitFooter}>
<div class="flex w-full flex-row justify-evenly gap-2">
<div class="w-full">
<textarea
bind:value={footerHTML}
class="mt-2 h-48 w-full rounded-sm border p-2"
placeholder="eg. <p>Powered by Kener</p>"
></textarea>
</div>
</div>
<div class="flex w-full justify-end">
<Button type="submit" disabled={formStateFooter === "loading"}>
Save
{#if formStateFooter === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</Card.Content>
</Card.Root>
@@ -0,0 +1,837 @@
<script>
import { Button } from "$lib/components/ui/button";
import { Plus, X, Loader } from "lucide-svelte";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { clickOutsideAction, slide } from "svelte-legos";
import { ChevronRight, Trash } from "lucide-svelte";
import { base } from "$app/paths";
import * as Select from "$lib/components/ui/select";
import { createEventDispatcher } from "svelte";
import {
allRecordTypes,
ValidateIpAddress,
ValidateCronExpression,
IsValidHost,
IsValidNameServer
} from "$lib/clientTools.js";
const dispatch = createEventDispatcher();
const defaultEval = `(function (statusCode, responseTime, responseData) {
let statusCodeShort = Math.floor(statusCode/100);
if(statusCode == 429 || (statusCodeShort >=2 && statusCodeShort <= 3)) {
return {
status: 'UP',
latency: responseTime,
}
}
return {
status: 'DOWN',
latency: responseTime,
}
})`;
export let categories = [];
let formState = "idle";
export let newMonitor;
let loadingLogo = false;
let selectedCategory = categories.find(
(category) => category.name === newMonitor.category_name
);
async function handleFileChangeLogo(event) {
const file = event.target.files[0];
if (!file) {
event.target.value = "";
alert("Please select a file to upload.");
return;
}
if (file.size > 100000) {
event.target.value = "";
alert("File size should be less than 100KB");
return;
}
loadingLogo = true;
const formData = new FormData();
formData.append("image", file);
try {
const response = await fetch(base + "/manage/app/upload", {
method: "POST",
body: formData
});
if (response.ok) {
const result = await response.json();
newMonitor.image = "/uploads/" + result.filename;
} else {
alert("Failed to upload file.");
}
} catch (error) {
alert("An error occurred while uploading the file.");
} finally {
loadingLogo = false;
}
}
function addHeader() {
newMonitor.apiConfig.headers = [...newMonitor.apiConfig.headers, { key: "", value: "" }];
}
let invalidFormMessage = "";
function isValidEval() {
try {
let evalResp = eval(newMonitor.apiConfig.eval + `(200, 1000, "e30=")`);
if (
evalResp === undefined ||
evalResp === null ||
evalResp.status === undefined ||
evalResp.status === null ||
evalResp.latency === undefined ||
evalResp.latency === null
) {
return false;
}
} catch (error) {
invalidFormMessage = error.message + " in eval.";
return false;
}
return true;
}
const IsValidURL = function (url) {
return /^(http|https):\/\/[^ "]+$/.test(url);
};
async function saveOrUpdateMonitor() {
invalidFormMessage = "";
//tag should alphanumeric hyphen underscore only
const tagRegex = /^[a-zA-Z0-9_-]+$/;
if (!tagRegex.test(newMonitor.tag)) {
invalidFormMessage = "Tag should be alphanumeric, hyphen, underscore only";
return;
}
//name should be present
if (!newMonitor.name) {
invalidFormMessage = "Name is required";
return;
}
//day_degraded_minimum_count should be positive number
if (newMonitor.day_degraded_minimum_count < 1) {
invalidFormMessage = "Day Degraded Minimum Count should be greater than 0";
return;
}
//day_down_minimum_count should be positive number
if (newMonitor.day_down_minimum_count < 1) {
invalidFormMessage = "Day Down Minimum Count should be greater than 0";
return;
}
//validating cron
const cron = newMonitor.cron;
let cronValidation = ValidateCronExpression(cron);
if (cronValidation.isValid === false) {
invalidFormMessage = "Cron invalid: " + cronValidation.message;
return;
}
//if monitor type is API
if (newMonitor.monitor_type === "API") {
//validating url
if (!newMonitor.apiConfig.url) {
invalidFormMessage = "URL is required";
return;
}
if (!IsValidURL(newMonitor.apiConfig.url)) {
invalidFormMessage = "Invalid URL";
return;
}
//timeout should be positive number
if (newMonitor.apiConfig.timeout < 1) {
invalidFormMessage = "Timeout should be greater than 0";
return;
}
//if evali ends with semicolor throw error
if (!!newMonitor.apiConfig.eval && newMonitor.apiConfig.eval.endsWith(";")) {
invalidFormMessage = "Eval should not end with semicolon";
return;
}
//validating eval
if (!!newMonitor.apiConfig.eval && !isValidEval()) {
invalidFormMessage = invalidFormMessage + "Invalid eval";
return;
}
newMonitor.type_data = JSON.stringify(newMonitor.apiConfig);
} else if (newMonitor.monitor_type === "PING") {
//validating hostsV4
let hostsV4 = newMonitor.pingConfig.hostsV4;
let hostsV6 = newMonitor.pingConfig.hostsV6;
let hasV4 = false;
let hasV6 = false;
if (hostsV4 && Array.isArray(hostsV4) && hostsV4.length > 0) {
for (let i = 0; i < hostsV4.length; i++) {
if (ValidateIpAddress(hostsV4[i]) == "Invalid") {
invalidFormMessage = `hostsV4 ${hostsV4[i]} is not valid`;
return;
}
}
hasV4 = true;
}
if (hostsV6 && Array.isArray(hostsV6) && hostsV6.length > 0) {
for (let i = 0; i < hostsV6.length; i++) {
if (ValidateIpAddress(hostsV6[i]) == "Invalid") {
invalidFormMessage = `hostsV6 ${hostsV6[i]} is not valid`;
return;
}
}
hasV6 = true;
}
if (!hasV4 && !hasV6) {
invalidFormMessage = "hostsV4 or hostsV6 is required";
return;
}
newMonitor.type_data = JSON.stringify(newMonitor.pingConfig);
} else if (newMonitor.monitor_type === "DNS") {
//validating host
if (!newMonitor.dnsConfig.host) {
invalidFormMessage = "Host is required";
return;
}
if (!IsValidHost(newMonitor.dnsConfig.host)) {
invalidFormMessage = "Invalid Host";
return;
}
//validating nameServer
if (!newMonitor.dnsConfig.nameServer) {
invalidFormMessage = "Name Server is required";
return;
}
if (!IsValidNameServer(newMonitor.dnsConfig.nameServer)) {
invalidFormMessage = "Invalid Name Server";
return;
}
//atleast one value should be present
if (
!newMonitor.dnsConfig.values ||
!Array.isArray(newMonitor.dnsConfig.values) ||
newMonitor.dnsConfig.values.length === 0
) {
invalidFormMessage = "Atleast one value is required";
return;
}
newMonitor.type_data = JSON.stringify(newMonitor.dnsConfig);
}
formState = "loading";
try {
let data = await fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ action: "storeMonitorData", data: newMonitor })
});
let resp = await data.json();
if (resp.error) {
invalidFormMessage = resp.error;
} else {
dispatch("closeModal", {});
}
} catch (error) {
invalidFormMessage = "Error while saving data";
} finally {
formState = "idle";
}
}
</script>
<div class="fixed left-0 top-0 z-50 h-screen w-screen bg-card bg-opacity-20 backdrop-blur-sm">
<div
transition:slide={{ direction: "right", duration: 200 }}
use:clickOutsideAction
on:clickoutside={(e) => {
dispatch("closeModal", {});
}}
class="absolute right-0 top-0 h-screen w-[800px] bg-background px-3 shadow-xl"
>
<Button
variant="outline"
size="icon"
class="absolute right-[785px] top-8 z-10 h-8 w-8 rounded-md"
on:click={(e) => {
dispatch("closeModal", {});
}}
>
<ChevronRight class="h-6 w-6 " />
</Button>
<div class="absolute top-0 flex h-12 w-full justify-between gap-2 border-b p-3">
{#if newMonitor.id}
<h2 class="text-lg font-medium">Edit Monitor</h2>
{:else}
<h2 class="text-lg font-medium">Add Monitor</h2>
{/if}
<div>
<label class="inline-flex cursor-pointer items-center">
<input
type="checkbox"
value=""
class="peer sr-only"
checked={newMonitor.status == "ACTIVE"}
on:change={() => {
newMonitor.status =
newMonitor.status == "ACTIVE" ? "INACTIVE" : "ACTIVE";
}}
/>
<div
class="peer relative h-6 w-11 rounded-full bg-gray-200 after:absolute after:start-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rtl:peer-checked:after:-translate-x-full dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"
></div>
</label>
</div>
</div>
<div class="mt-12 w-full overflow-y-auto p-3" style="height: calc(100vh - 7rem);">
<div class="grid grid-cols-3 gap-x-2 gap-y-4">
<div class="col-span-3">
<Label for="logo">Image</Label>
{#if !!newMonitor.image}
<div class="relative mb-2 h-[48px] w-[48px] rounded-sm border p-1">
<img src={base + newMonitor.image} class="" alt="" />
<Button
variant="secondary"
class="absolute -right-2.5 -top-1.5 h-5 w-5 rounded-full p-0"
on:click={() => (newMonitor.image = "")}
>
<X class="h-4 w-4" />
</Button>
</div>
{/if}
<Input
class="w-1/2"
id="logo"
type="file"
disabled={loadingLogo}
accept=".jpg, .jpeg, .png"
on:change={(e) => {
handleFileChangeLogo(e);
}}
/>
</div>
<div class="col-span-1">
<Label for="tag">
Tag
<span class="text-red-500">*</span>
</Label>
<Input bind:value={newMonitor.tag} id="tag" placeholder="Add a Tag" />
</div>
<div class="col-span-1">
<Label for="name">Name <span class="text-red-500">*</span></Label>
<Input bind:value={newMonitor.name} id="name" placeholder="Add a Name" />
</div>
<div class="col-span-3">
<Label for="description">Description</Label>
<Input
bind:value={newMonitor.description}
id="description"
placeholder="Add a description for the monitor"
/>
</div>
<div class="col-span-1">
<Label for="cron">Cron <span class="text-red-500">*</span></Label>
<Input bind:value={newMonitor.cron} id="cron" />
</div>
<div class="col-span-1">
<Label for="default_status">Default Status</Label>
<Select.Root
portal={null}
onSelectedChange={(e) => (newMonitor.default_status = e.value)}
>
<Select.Trigger id="default_status">
<Select.Value placeholder={newMonitor.default_status} />
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.Label>Status</Select.Label>
<Select.Item value="NONE" label="NONE" class="text-sm font-medium">
NONE
</Select.Item>
<Select.Item value="UP" label="UP" class="text-sm font-medium">
UP
</Select.Item>
<Select.Item value="DOWN" label="DOWN" class="text-sm font-medium">
DOWN
</Select.Item>
<Select.Item
value="DEGRADED"
label="DEGRADED"
class="text-sm font-medium"
>
DEGRADED
</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
</div>
<div class="col-span-1">
<Label for="category_name">Category Name</Label>
<Select.Root
portal={null}
bind:value={selectedCategory}
onSelectedChange={(e) => (newMonitor.category_name = e.value)}
>
<Select.Trigger id="category_name">
<Select.Value placeholder={newMonitor.category_name} />
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.Label>Category</Select.Label>
{#each categories as category}
<Select.Item
value={category.name}
label={category.name}
class="text-sm font-medium"
>
{category.name}
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</div>
<div class="col-span-1">
<Label for="day_degraded_minimum_count">
Day Degraded Minimum Count <span class="text-red-500">*</span>
</Label>
<Input
bind:value={newMonitor.day_degraded_minimum_count}
id="day_degraded_minimum_count"
/>
</div>
<div class="col-span-1">
<Label for="day_down_minimum_count">
Day Down Minimum Count <span class="text-red-500">*</span>
</Label>
<Input
bind:value={newMonitor.day_down_minimum_count}
id="day_down_minimum_count"
/>
</div>
<div class="col-span-1">
<Label for="include_degraded_in_downtime">Include Degraded In Downtime</Label>
<Select.Root
portal={null}
onSelectedChange={(e) =>
(newMonitor.include_degraded_in_downtime = e.value)}
>
<Select.Trigger id="include_degraded_in_downtime">
<Select.Value placeholder={newMonitor.include_degraded_in_downtime} />
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.Label>Select YES or NO</Select.Label>
<Select.Item value="YES" label="YES" class="text-sm font-medium">
YES
</Select.Item>
<Select.Item value="NO" label="NO" class="text-sm font-medium">
NO
</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
</div>
<div class="col-span-1">
<Label for="monitor_type">Monitor Type</Label>
<Select.Root
portal={null}
onSelectedChange={(e) => (newMonitor.monitor_type = e.value)}
>
<Select.Trigger id="monitor_type">
<Select.Value placeholder={newMonitor.monitor_type} />
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.Label>Type</Select.Label>
<Select.Item value="NONE" label="NONE" class="text-sm font-medium">
NONE
</Select.Item>
<Select.Item value="API" label="API" class="text-sm font-medium">
API
</Select.Item>
<Select.Item value="PING" label="PING" class="text-sm font-medium">
PING
</Select.Item>
<Select.Item value="DNS" label="DNS" class="text-sm font-medium">
DNS
</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
</div>
</div>
{#if newMonitor.monitor_type === "API"}
<div class="mt-4 grid grid-cols-6 gap-2">
<div class="col-span-1">
<Label for="timeout">
Timeout(ms)
<span class="text-red-500">*</span>
</Label>
<Input bind:value={newMonitor.apiConfig.timeout} id="timeout" />
</div>
<div class="col-span-1">
<Label for="method">Method</Label>
<Select.Root
portal={null}
onSelectedChange={(e) => (newMonitor.apiConfig.method = e.value)}
>
<Select.Trigger id="method">
<Select.Value placeholder={newMonitor.apiConfig.method} />
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.Label>Method</Select.Label>
<Select.Item
value="GET"
label="GET"
class="text-sm font-medium"
>
GET
</Select.Item>
<Select.Item
value="POST"
label="POST"
class="text-sm font-medium"
>
POST
</Select.Item>
<Select.Item
value="PUT"
label="PUT"
class="text-sm font-medium"
>
PUT
</Select.Item>
<Select.Item
value="PATCH"
label="PATCH"
class="text-sm font-medium"
>
PATCH
</Select.Item>
<Select.Item
value="DELETE"
label="DELETE"
class="text-sm font-medium"
>
DELETE
</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
</div>
<div class="col-span-4">
<Label for="url">URL <span class="text-red-500">*</span></Label>
<Input
bind:value={newMonitor.apiConfig.url}
id="url"
placeholder="https://example.com/api/users"
/>
</div>
<div class="col-span-6 mt-2">
<Label for="url">Headers</Label>
<div class="grid grid-cols-6 gap-2">
{#each newMonitor.apiConfig.headers as header, index}
<div class="col-span-2">
<Input
bind:value={header.key}
id="header"
placeholder="Content-Type"
/>
</div>
<div class="col-span-3">
<Input
bind:value={header.value}
id="header"
placeholder="application/json"
/>
</div>
<div class="col-span-1 pt-2">
<Button
class=" h-6 w-6 p-1"
variant="secondary"
on:click={() => {
newMonitor.apiConfig.headers =
newMonitor.apiConfig.headers.filter(
(_, i) => i !== index
);
}}
>
<X class="h-5 w-5" />
</Button>
</div>
{/each}
</div>
<div class="relative pb-8 pt-2">
<hr
class="border-1 border-border-input relative top-4 h-px border-dashed"
/>
<Button
on:click={addHeader}
variant="secondary"
class="absolute left-1/2 h-8 -translate-x-1/2 p-2 text-xs "
>
<Plus class="mr-2 h-4 w-4" /> Add Header
</Button>
</div>
</div>
{#if newMonitor.apiConfig.method[0] == "P"}
<div class="col-span-6">
<Label for="body">Body</Label>
<textarea
bind:value={newMonitor.apiConfig.body}
id="body"
class="h-48 w-full rounded-sm border p-2"
placeholder={JSON.stringify({ name: "Kener" }, null, 2)}
></textarea>
</div>
{/if}
<div class="col-span-6">
<Label for="eval">Eval</Label>
<p class="my-1 text-xs text-muted-foreground">
You can write a custom eval function to evaluate the response. The
function should return an object with status and latency. <a
class="font-medium text-primary"
href="https://kener.ing/docs/monitors-api#eval">Read the docs</a
> to learn
</p>
<textarea
bind:value={newMonitor.apiConfig.eval}
id="eval"
class="h-96 w-full rounded-sm border p-2"
placeholder="Leave blank or write a custom eval function"
></textarea>
</div>
</div>
{:else if newMonitor.monitor_type == "PING"}
<div class="mt-4 grid grid-cols-6 gap-2">
<div class="col-span-6">
<Label for="hostsV4">Hosts V4</Label>
<div class="mb-2 grid grid-cols-7 gap-2">
{#each newMonitor.pingConfig.hostsV4 as host, index}
<div class="relative col-span-2">
<Input
bind:value={host}
id="hostsV4"
class="pr-10"
placeholder="172.12.14.42"
/>
<Button
class="absolute right-3 top-2 h-6 w-6 p-1"
variant="secondary"
on:click={() => {
newMonitor.pingConfig.hostsV4 =
newMonitor.pingConfig.hostsV4.filter(
(_, i) => i !== index
);
}}
>
<X class="h-5 w-5" />
</Button>
</div>
{/each}
<div class="col-span-1 pt-2">
<Button
class="h-6 w-6 p-1"
variant="secondary"
on:click={() => {
newMonitor.pingConfig.hostsV4 = [
...newMonitor.pingConfig.hostsV4,
""
];
}}
>
<Plus class="h-5 w-5" />
</Button>
</div>
</div>
<Label for="hostsV6">Hosts V6</Label>
<div class="grid grid-cols-5 gap-2">
{#each newMonitor.pingConfig.hostsV6 as host, index}
<div class="relative col-span-2">
<Input
bind:value={host}
id="hostsV6"
class="pr-10"
placeholder="2001:0db8:85a3:0000:0000:8a2e:0370:7334"
/>
<Button
class="absolute right-3 top-2 h-6 w-6 p-1"
variant="secondary"
on:click={() => {
newMonitor.pingConfig.hostsV6 =
newMonitor.pingConfig.hostsV6.filter(
(_, i) => i !== index
);
}}
>
<X class="h-5 w-5" />
</Button>
</div>
{/each}
<div class="col-span-1 pt-2">
<Button
class="h-6 w-6 p-1"
variant="secondary"
on:click={() => {
newMonitor.pingConfig.hostsV6 = [
...newMonitor.pingConfig.hostsV6,
""
];
}}
>
<Plus class="h-5 w-5" />
</Button>
</div>
</div>
</div>
</div>
{:else if newMonitor.monitor_type == "DNS"}
<div class="mt-4 grid grid-cols-4 gap-2">
<div class="col-span-2">
<Label for="host">Host</Label>
<Input bind:value={newMonitor.dnsConfig.host} id="host" />
</div>
<div class="col-span-2">
<Label for="lookupRecord">Lookup Record</Label>
<Select.Root
portal={null}
onSelectedChange={(e) => (newMonitor.dnsConfig.lookupRecord = e.value)}
>
<Select.Trigger id="lookupRecord">
<Select.Value placeholder={newMonitor.dnsConfig.lookupRecord} />
</Select.Trigger>
<Select.Content class="max-h-56 overflow-y-auto">
<Select.Group>
<Select.Label>Record</Select.Label>
{#each Object.keys(allRecordTypes) as record}
<Select.Item
value={record}
label={record}
class="text-sm font-medium"
>
{record}
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</div>
<div class="col-span-2">
<Label for="nameServer">Name Server</Label>
<Input bind:value={newMonitor.dnsConfig.nameServer} id="nameServer" />
</div>
<div class="col-span-2">
<Label for="matchType">Match Type</Label>
<Select.Root
portal={null}
onSelectedChange={(e) => (newMonitor.dnsConfig.matchType = e.value)}
>
<Select.Trigger id="matchType">
<Select.Value placeholder={newMonitor.dnsConfig.matchType} />
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.Label>Match Type</Select.Label>
<Select.Item
value="ANY"
label="ANY"
class="text-sm font-medium"
>
ANY
</Select.Item>
<Select.Item
value="ALL"
label="ALL"
class="text-sm font-medium"
>
ALL
</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
</div>
<div class="col-span-4">
<Label for="values">Expected Values</Label>
<div class="grid grid-cols-7 gap-2">
{#each newMonitor.dnsConfig.values as value, index}
<div class="relative col-span-3">
<Input
bind:value
id="values"
class="pr-10"
placeholder="rajnandan1.github.io"
/>
<Button
class="absolute right-3 top-2 h-6 w-6 p-1"
variant="secondary"
on:click={() => {
newMonitor.dnsConfig.values =
newMonitor.dnsConfig.values.filter(
(_, i) => i !== index
);
}}
>
<X class="h-5 w-5" />
</Button>
</div>
{/each}
<div class="col-span-1 pt-2">
<Button
class="h-6 w-6 p-1"
variant="secondary"
on:click={() => {
newMonitor.dnsConfig.values = [
...newMonitor.dnsConfig.values,
""
];
}}
>
<Plus class="h-5 w-5" />
</Button>
</div>
</div>
</div>
</div>
{/if}
</div>
<div class="absolute bottom-0 grid h-16 w-full grid-cols-6 justify-end gap-2 border-t p-3">
<div class="col-span-5 py-2.5">
<p class="text-right text-xs font-medium text-red-500">{invalidFormMessage}</p>
</div>
<div class="col-span-1">
<Button
class=" w-full"
on:click={saveOrUpdateMonitor}
disabled={formState === "loading"}
>
Save
{#if formState === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,597 @@
<script>
import { Button } from "$lib/components/ui/button";
import { Plus, X, Settings, Bell, Loader, ArrowDownUp, Grip } from "lucide-svelte";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { base } from "$app/paths";
import MonitorsSheet from "$lib/components/manage/monitorSheet.svelte";
import { onMount } from "svelte";
import * as Card from "$lib/components/ui/card";
import * as Select from "$lib/components/ui/select";
import { storeSiteData, SortMonitor } from "$lib/clientTools.js";
import { dndzone } from "svelte-dnd-action";
import { flip } from "svelte/animate";
export let categories = [];
export let colorDown = "#777";
export let colorDegraded = "#777";
export let monitorSort = [];
let monitors = [];
let status = "ACTIVE";
let showAddMonitor = false;
let formState = "idle";
let loadingData = false;
let triggers = [];
function showAddMonitorSheet() {
resetNewMonitor();
showAddMonitor = true;
}
let newMonitor;
function resetNewMonitor() {
newMonitor = {
id: 0,
tag: "",
name: "",
description: "",
image: "",
cron: "* * * * *",
default_status: "NONE",
status: "ACTIVE",
category_name: "Home",
monitor_type: "NONE",
type_data: "",
day_degraded_minimum_count: 1,
day_down_minimum_count: 1,
include_degraded_in_downtime: "NO",
apiConfig: {
url: "",
method: "GET",
headers: [],
body: "",
timeout: 10000,
eval: "",
hideURLForGet: "NO"
},
pingConfig: {
hostsV4: [],
hostsV6: []
},
dnsConfig: {
host: "",
lookupRecord: "",
nameServer: "8.8.8.8",
matchType: "ANY",
values: []
}
};
}
function showUpdateMonitorSheet(m) {
resetNewMonitor();
newMonitor = { ...newMonitor, ...m };
if (newMonitor.monitor_type == "API") {
newMonitor.apiConfig = JSON.parse(newMonitor.type_data);
} else if (newMonitor.monitor_type == "PING") {
newMonitor.pingConfig = JSON.parse(newMonitor.type_data);
} else if (newMonitor.monitor_type == "DNS") {
newMonitor.dnsConfig = JSON.parse(newMonitor.type_data);
}
showAddMonitor = true;
}
async function loadData() {
loadingData = true;
try {
let apiResp = await fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ action: "getMonitors", data: { status: status } })
});
let resp = await apiResp.json();
resp = resp.map((m) => {
m.down_trigger = JSON.parse(m.down_trigger);
m.degraded_trigger = JSON.parse(m.degraded_trigger);
return m;
});
monitors = SortMonitor(monitorSort, resp);
} catch (error) {
alert("Err2or: " + error);
} finally {
loadingData = false;
}
}
async function loadTriggersData() {
try {
let apiResp = await fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ action: "getTriggers", data: { status: "ACTIVE" } })
});
triggers = await apiResp.json();
} catch (error) {
alert("Error: " + error);
}
}
onMount(async () => {
loadData();
loadTriggersData();
});
let shareMenusToggle = false;
let monitorTriggers = {
down_trigger: {
failureThreshold: 1,
trigger_type: "DOWN",
successThreshold: 1,
description: "The monitor is down",
createIncident: "NO",
active: false,
triggers: [],
severity: "critical"
},
degraded_trigger: {
failureThreshold: 1,
trigger_type: "DEGRADED",
successThreshold: 1,
active: false,
description: "The monitor is degraded",
createIncident: "NO",
triggers: [],
severity: "warning"
}
};
async function saveTriggers() {
let data = {
id: currentAlertMonitor.id,
down_trigger: JSON.stringify(monitorTriggers.down_trigger),
degraded_trigger: JSON.stringify(monitorTriggers.degraded_trigger)
};
formState = "loading";
//updateMonitorTriggers
try {
let apiResp = await fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ action: "updateMonitorTriggers", data })
});
} catch (error) {
alert("Error: " + error);
} finally {
formState = "idle";
loadData();
shareMenusToggle = false;
}
}
let currentAlertMonitor;
function openAlertMenu(m) {
currentAlertMonitor = m;
if (m.down_trigger) {
monitorTriggers.down_trigger = m.down_trigger;
}
if (m.degraded_trigger) {
monitorTriggers.degraded_trigger = m.degraded_trigger;
}
shareMenusToggle = true;
}
const flipDurationMs = 200;
function handleSort(e) {
dropTargetStyle = {
border: "1px solid transparent"
};
monitors = e.detail.items;
monitorSort = monitors.map((m) => m.id);
storeSiteData({
monitorSort: JSON.stringify(monitorSort)
});
}
let dropTargetStyle;
let draggableMenu = false;
</script>
{#if showAddMonitor}
<MonitorsSheet
{categories}
{newMonitor}
on:closeModal={(e) => {
showAddMonitor = false;
loadData();
}}
/>
{/if}
{#if draggableMenu}
<div
class="moldal-container fixed left-0 top-0 z-50 h-screen w-full bg-card bg-opacity-30 backdrop-blur-sm"
>
<div
class="absolute left-1/2 top-1/2 h-fit w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-md border bg-background shadow-lg backdrop-blur-lg"
>
<Button
variant="ghost"
on:click={() => {
draggableMenu = false;
}}
class="absolute right-2 top-2 z-40 h-6 w-6 rounded-full border bg-background p-1"
>
<X class="h-4 w-4 text-muted-foreground" />
</Button>
<div class="content px-4 py-4">
<h2 class="text-lg font-semibold">Rearrange Monitors</h2>
<div
class="mt-4"
use:dndzone={{ items: monitors, flipDurationMs, dropTargetStyle }}
on:consider={handleSort}
on:finalize={handleSort}
>
{#each monitors as monitor (monitor.id)}
<div
animate:flip={{ duration: flipDurationMs }}
class="mb-2 rounded-md bg-card p-2"
>
<Grip class="mr-2 inline h-4 w-4" />
{#if !!monitor.image}
<img
src={base + monitor.image}
alt={monitor.name}
class="mr-1 inline-block h-4 w-4"
/>
{/if}
{monitor.name}
</div>
{/each}
</div>
</div>
</div>
</div>
{/if}
<div class="mt-4 flex justify-between">
<div class="flex w-40">
<Select.Root
portal={null}
onSelectedChange={(e) => {
status = e.value;
loadData();
}}
>
<Select.Trigger id="statusmonitor">
<Select.Value placeholder={status} />
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.Label>Status</Select.Label>
<Select.Item value="ACTIVE" label="ACTIVE" class="text-sm font-medium">
ACTIVE
</Select.Item>
<Select.Item value="INACTIVE" label="INACTIVE" class="text-sm font-medium">
INACTIVE
</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
{#if loadingData}
<Loader class="ml-2 mt-2 inline h-6 w-6 animate-spin" />
{/if}
</div>
<div>
{#if status == "ACTIVE"}
<Button
size="icon"
variant="secondary"
on:click={() => (draggableMenu = !draggableMenu)}
>
<ArrowDownUp class=" " />
</Button>
{/if}
<Button on:click={showAddMonitorSheet}>
<Plus class="mr-2 inline h-6 w-6" />
Add Monitor
</Button>
</div>
</div>
<div class="mt-4">
{#each monitors as monitor}
<Card.Root class="mb-4">
<Card.Header class="relative">
<Card.Title>
{#if !!monitor.image}
<img
src={base + monitor.image}
alt={monitor.name}
class="mr-2 inline-block h-8 w-8"
/>
{/if}
{monitor.name}
</Card.Title>
{#if !!monitor.description}
<Card.Description>{@html monitor.description}</Card.Description>
{/if}
<div class="absolute right-2 top-0.5">
<Button
variant="secondary"
class="h-8 w-8 p-2"
on:click={() => openAlertMenu(monitor)}
>
<Bell class="inline h-4 w-4" />
</Button>
<Button
variant="secondary"
class="h-8 w-8 p-2"
on:click={() => showUpdateMonitorSheet(monitor)}
>
<Settings class="inline h-4 w-4" />
</Button>
</div>
</Card.Header>
<Card.Content>
<div class="flex justify-between gap-4">
<div class="">
<Label class="text-xs font-semibold text-muted-foreground">Tag</Label>
<p class="text-sm font-semibold">
{monitor.tag}
</p>
</div>
<div class="">
<Label class="text-xs font-semibold text-muted-foreground"
>Monitor Type</Label
>
<p class="text-sm font-semibold">
{monitor.monitor_type}
</p>
</div>
<div class="">
<Label class="text-xs font-semibold text-muted-foreground">Cron</Label>
<p class="text-sm font-semibold">
{monitor.cron}
</p>
</div>
<div class="">
<Label class="text-xs font-semibold text-muted-foreground">Category</Label>
<p class="text-sm font-semibold">
{!!monitor.category_name ? monitor.category_name : "-"}
</p>
</div>
</div>
</Card.Content>
</Card.Root>
{/each}
</div>
{#if shareMenusToggle}
<div
class="moldal-container fixed left-0 top-0 z-50 h-screen w-full bg-card bg-opacity-30 backdrop-blur-sm"
>
<div
class="absolute left-1/2 top-1/2 h-fit w-full max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded-md border bg-background shadow-lg backdrop-blur-lg"
>
<Button
variant="ghost"
on:click={() => {
shareMenusToggle = false;
}}
class="absolute right-2 top-2 z-40 h-6 w-6 rounded-full border bg-background p-1"
>
<X class="h-4 w-4 text-muted-foreground" />
</Button>
<div class="content px-4 py-4">
<h2 class="text-lg font-semibold">
Add Alert Triggers for {currentAlertMonitor.name}
</h2>
<p class="text-xs text-muted-foreground">
Alert triggers are used to notify you when your monitor is down or degraded.
</p>
<hr class="my-4" />
{#each Object.entries(monitorTriggers) as [key, data]}
<div class="flex justify-between">
<h3
class="font-semibold"
style="color:{data.trigger_type == 'DOWN' ? colorDown : colorDegraded};"
>
If Monitor {data.trigger_type}
</h3>
<div>
<label class="inline-flex cursor-pointer items-center">
<input
type="checkbox"
value=""
class="peer sr-only"
checked={data.active}
on:change={() => {
data.active = !data.active;
}}
/>
<div
class="peer relative h-6 w-11 rounded-full bg-gray-200 after:absolute after:start-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rtl:peer-checked:after:-translate-x-full dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"
></div>
</label>
</div>
</div>
<div class="grid grid-cols-4 gap-2">
<div class="col-span-1">
<Label for="{key}failureThreshold">
Failure Threshold
<span class="text-red-500">*</span>
</Label>
<Input
bind:value={data.failureThreshold}
min="1"
id="{key}failureThreshold"
type="number"
/>
</div>
<div class="col-span-1">
<div class="col-span-1">
<Label for="{key}successThreshold">
Success Threshold
<span class="text-red-500">*</span>
</Label>
<Input
bind:value={data.successThreshold}
min="1"
id="{key}successThreshold"
type="number"
/>
</div>
</div>
<div class="col-span-1">
<Label for="{key}createIncident">Create Incident</Label>
<Select.Root
portal={null}
onSelectedChange={(e) => (data.createIncident = e.value)}
selected={{
value: data.createIncident,
label: data.createIncident
}}
>
<Select.Trigger id="{key}createIncident">
<Select.Value
bind:value={data.createIncident}
placeholder={data.createIncident}
/>
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.Item
value="YES"
label="YES"
class="text-sm font-medium"
>
YES
</Select.Item>
<Select.Item
value="NO"
label="NO"
class="text-sm font-medium"
>
NO
</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
</div>
<div class="col-span-1">
<Label for="{key}createInseveritycident">
Severity
<span class="text-red-500">*</span>
</Label>
<Select.Root
portal={null}
onSelectedChange={(e) => (data.severity = e.value)}
selected={{
value: data.severity,
label: data.severity.toUpperCase()
}}
>
<Select.Trigger id="{key}severity">
<Select.Value
bind:value={data.severity}
placeholder={data.severity}
/>
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.Item
value="critical"
label="CRITICAL"
class="text-sm font-medium"
>
CRITICAL
</Select.Item>
<Select.Item
value="warning"
label="WARNING"
class="text-sm font-medium"
>
WARNING
</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
</div>
<div class="col-span-4">
<Label for="{key}description">
Add Custom Alert Message
<span class="text-red-500">*</span>
</Label>
<Input
id="{key}description"
bind:value={data.description}
type="text"
/>
</div>
<p class="col-span-4 mt-2 text-sm font-medium">Choose Triggers</p>
{#each triggers as trigger}
<div
class="col-span-1 overflow-hidden overflow-ellipsis whitespace-nowrap"
>
<label class="cursor-pointer">
<input
type="checkbox"
class="text-sm"
checked={data.triggers.indexOf(trigger.id) > -1}
on:change={() => {
data.triggers = data.triggers.includes(trigger.id)
? data.triggers.filter((t) => t != trigger.id)
: [...data.triggers, trigger.id];
}}
/>
{#if trigger.trigger_type == "webhook"}
<img
src={base + "/webhooks.svg"}
alt={trigger.trigger_type}
class="ml-2 inline-block h-4 w-4"
/>
{:else if trigger.trigger_type == "email"}
<img
src={base + "/email.png"}
alt={trigger.trigger_type}
class="ml-2 inline-block h-4 w-4"
/>
{:else if trigger.trigger_type == "slack"}
<img
src={base + "/slack.svg"}
alt={trigger.trigger_type}
class="ml-2 inline-block h-4 w-4"
/>
{:else if trigger.trigger_type == "discord"}
<img
src={base + "/discord.svg"}
alt={trigger.trigger_type}
class="ml-2 inline-block h-4 w-4"
/>
{/if}
{trigger.name}
</label>
</div>
{/each}
</div>
<hr class="my-4" />
{/each}
<div class="flex justify-end">
<Button
class="w-full"
on:click={saveTriggers}
disabled={formState === "loading"}
>
Save
{#if formState === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</div>
</div>
</div>
{/if}
+221
View File
@@ -0,0 +1,221 @@
<script>
import * as Card from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Plus, X } from "lucide-svelte";
import autoAnimate from "@formkit/auto-animate";
import { siteDataExtractFromDb, storeSiteData } from "$lib/clientTools.js";
import { base } from "$app/paths";
import { Loader, Info } from "lucide-svelte";
import { Tooltip } from "bits-ui";
export let data;
let metaTags = [];
function addNewRow() {
metaTags = [
...metaTags,
{
key: "",
value: ""
}
];
}
let analyticsSupported = [
{
id: "",
type: "GA",
name: "Google Analytics",
script: "https://unpkg.com/@analytics/google-analytics@1.0.7/dist/@analytics/google-analytics.min.js"
},
{
id: "",
type: "AMPLITUDE",
name: "Amplitude",
script: "https://unpkg.com/@analytics/amplitude@0.1.3/dist/@analytics/amplitude.min.js"
},
{
id: "",
type: "MIXPANEL",
name: "MixPanel",
script: "https://unpkg.com/@analytics/mixpanel@0.4.0/dist/@analytics/mixpanel.min.js"
}
];
function removeRow(i) {
metaTags = metaTags.filter((_, index) => index !== i);
}
metaTags = siteDataExtractFromDb(data.siteData, {
metaTags: JSON.stringify(metaTags)
}).metaTags;
let analytics = siteDataExtractFromDb(data.siteData, {
analytics: []
}).analytics;
//merge analyticsSupported with analytics
analytics = analyticsSupported.map((supported) => {
let found = analytics.find((a) => a.type === supported.type);
if (found) {
return found;
}
return supported;
});
let formState = "idle";
let formStateAnalytics = "idle";
async function formSubmit() {
formState = "loading";
let resp = await storeSiteData({
metaTags: JSON.stringify(metaTags)
});
//print data
let data = await resp.json();
formState = "idle";
if (data.error) {
alert(data.error);
return;
}
}
async function formSubmitAnalytics() {
formStateAnalytics = "loading";
let resp = await storeSiteData({
analytics: JSON.stringify(analytics)
});
//print data
let data = await resp.json();
formStateAnalytics = "idle";
if (data.error) {
alert(data.error);
return;
}
}
</script>
<Card.Root class="mt-4">
<Card.Header class="border-b">
<Card.Title>Analytics</Card.Title>
<Card.Description>
Add your analytics ID/Key here. You can add multiple analytics providers.
</Card.Description>
</Card.Header>
<Card.Content>
<form class="mx-auto mt-4 space-y-4" on:submit|preventDefault={formSubmitAnalytics}>
<div class="grid grid-cols-2 gap-2">
{#each analytics as analytic}
<div class="col-span-1">
<Label for={analytic.type}>
{analytic.name}
<Tooltip.Root openDelay={100}>
<Tooltip.Trigger class="">
<Info class="inline-block h-4 w-4 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<div
class=" flex items-center justify-center rounded border bg-gray-800 p-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
>
Add your {analytic.name} ID/Key here.
</div>
</Tooltip.Content>
</Tooltip.Root>
</Label>
<Input
bind:value={analytic.id}
class="mt-2"
type="text"
id={analytic.type}
placeholder="{analytic.type} ID/Key"
/>
</div>
{/each}
</div>
<div class="flex w-full justify-end">
<Button type="submit" disabled={formStateAnalytics === "loading"}>
Save
{#if formStateAnalytics === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</Card.Content>
</Card.Root>
<Card.Root class="mt-4">
<Card.Header class="border-b">
<Card.Title>Search Engine Optimization</Card.Title>
<Card.Description>
Add your meta tags here. You can add multiple meta tags.
</Card.Description>
</Card.Header>
<Card.Content>
<form class="mx-auto mt-4 space-y-4" use:autoAnimate on:submit|preventDefault={formSubmit}>
{#each metaTags as metaTag, i}
<div class="flex w-full flex-row justify-between gap-2">
<div class="grid grid-cols-12 gap-x-2">
<div class="col-span-4">
<Label for="key">
Meta Name
<Tooltip.Root openDelay={100}>
<Tooltip.Trigger class="">
<Info class="inline-block h-4 w-4 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="max-w-md">
<div
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
>
&#x3C;meta name=&#x22;{metaTag.key}&#x22; content=&#x22;{metaTag.value}&#x22;&#x3E;
</div>
</Tooltip.Content>
</Tooltip.Root>
</Label>
<Input
bind:value={metaTag.key}
class="mt-2"
type="text"
id="key"
placeholder="eg. kener"
/>
</div>
<div class="col-span-7">
<Label for="value">Content</Label>
<Input
bind:value={metaTag.value}
class="mt-2"
type="text"
id="value"
placeholder="eg. kener"
/>
</div>
<div class="col-span-1">
<Button variant="ghost" class="mt-8" on:click={() => removeRow(i)}>
<X class="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/each}
<div class="relative">
<hr class="border-1 border-border-input relative top-4 h-px border-dashed" />
<Button
on:click={addNewRow}
variant="secondary"
class="absolute left-1/2 h-8 w-8 -translate-x-1/2 p-0 "
>
<Plus class="h-4 w-4 " />
</Button>
</div>
<div class="flex w-full justify-end" style="margin-top:32px">
<Button type="submit" disabled={formState === "loading"}>
Save
{#if formState === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</Card.Content>
</Card.Root>
+381
View File
@@ -0,0 +1,381 @@
<script>
import * as Card from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { siteDataExtractFromDb, storeSiteData } from "$lib/clientTools.js";
import { tooltipAction } from "svelte-legos";
import { base } from "$app/paths";
import { Loader, Info, X } from "lucide-svelte";
import { Tooltip } from "bits-ui";
export let data;
let siteInformation = {
title: "",
siteName: "",
siteURL: "",
home: "",
logo: "",
favicon: ""
};
let logoFile;
let formErrorMessage = "";
siteInformation = siteDataExtractFromDb(data.siteData, siteInformation);
let formState = "idle";
async function formSubmit() {
formErrorMessage = "";
formState = "loading";
let resp = await storeSiteData(siteInformation);
//print data
let data = await resp.json();
formState = "idle";
if (data.error) {
formErrorMessage = data.error;
return;
}
}
function handleFileChangeFavicon(event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = () => {
siteInformation.favicon = reader.result;
uploadFunction(reader.result);
};
reader.readAsDataURL(file);
}
}
let uploadLogoStatus = "";
let uploadingLogo = false;
async function handleFileChangeLogo(event) {
event.preventDefault();
uploadLogoStatus = "";
const file = event.target.files[0];
if (!file) {
uploadLogoStatus = "Please select a file to upload.";
return;
}
if (file.size > 100000) {
uploadLogoStatus = "File size should be less than 100KB.";
return;
}
const formData = new FormData();
formData.append("image", file);
uploadingLogo = true;
try {
const response = await fetch(base + "/manage/app/upload", {
method: "POST",
body: formData
});
if (response.ok) {
const result = await response.json();
siteInformation.logo = "/uploads/" + result.filename;
} else {
uploadLogoStatus = "Failed to upload file.";
}
} catch (error) {
uploadLogoStatus = "An error occurred while uploading the file.";
} finally {
uploadingLogo = false;
}
}
let uploadFaviconStatus = "";
let uploadingFavicon = false;
async function handleFaviconChangeLogo(event) {
event.preventDefault();
uploadFaviconStatus = "";
const file = event.target.files[0];
if (!file) {
uploadFaviconStatus = "Please select a file to upload.";
return;
}
if (file.size > 20000) {
uploadFaviconStatus = "File size should be less than 20KB.";
return;
}
const formData = new FormData();
formData.append("image", file);
uploadingFavicon = true;
try {
const response = await fetch(base + "/manage/app/upload", {
method: "POST",
body: formData
});
if (response.ok) {
const result = await response.json();
siteInformation.favicon = "/uploads/" + result.filename;
} else {
uploadFaviconStatus = "Failed to upload file.";
}
} catch (error) {
uploadFaviconStatus = "An error occurred while uploading the file.";
} finally {
uploadingFavicon = false;
}
}
</script>
<Card.Root class="mt-4">
<Card.Header class="border-b">
<Card.Title>Site Configuration</Card.Title>
<Card.Description>Configure your site information here.</Card.Description>
</Card.Header>
<Card.Content>
<form class="mx-auto mt-4 space-y-4" on:submit|preventDefault={formSubmit}>
<div class="flex w-full flex-row justify-evenly gap-2">
<div class="w-full">
<Label for="title" class="">
Site Title
<span class="text-red-500">*</span>
<Tooltip.Root openDelay={100}>
<Tooltip.Trigger class="">
<Info class="inline-block h-4 w-4 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<div
class=" flex items-center justify-center rounded border bg-input p-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
>
Site Title will be the page title of your site.
&lt;title&gt;&lt;/title&gt;
</div>
</Tooltip.Content>
</Tooltip.Root>
</Label>
<Input
bind:value={siteInformation.title}
class="mt-2"
type="text"
id="title"
placeholder="eg. kener"
required
/>
</div>
<div class="w-full">
<Label for="siteName">
Site Name
<span class="text-red-500">*</span>
<Tooltip.Root openDelay={100}>
<Tooltip.Trigger class="">
<Info class="inline-block h-4 w-4 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<div
class=" flex items-center justify-center rounded border bg-input p-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
>
This is the name of your site.<br /> It will be shown in the nav
bar as brand name.
</div>
</Tooltip.Content>
</Tooltip.Root>
</Label>
<Input
bind:value={siteInformation.siteName}
class="mt-2"
type="text"
id="siteName"
placeholder="eg. Kener.ing"
required
/>
</div>
</div>
<div class="flex w-full flex-row justify-evenly gap-2">
<div class="w-full">
<Label for="siteURL">
Site URL
<span class="text-red-500">*</span>
<Tooltip.Root openDelay={100}>
<Tooltip.Trigger class="">
<Info class="inline-block h-4 w-4 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<div
class=" flex items-center justify-center rounded border bg-input p-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
>
The URL your kener is hosted on.
</div>
</Tooltip.Content>
</Tooltip.Root>
</Label>
<Input
bind:value={siteInformation.siteURL}
class="mt-2"
type="text"
id="siteURL"
placeholder="eg. https://status.example.com"
required
/>
</div>
</div>
<div class="flex w-full flex-row justify-evenly gap-2">
<div class="w-full">
<Label for="siteURL">
Home Location
<span class="text-red-500">*</span>
<Tooltip.Root openDelay={100}>
<Tooltip.Trigger class="">
<Info class="inline-block h-4 w-4 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<div
class=" flex items-center justify-center rounded border bg-input p-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
>
Where to go when someone clicks on the brand logo in the navbar.
</div>
</Tooltip.Content>
</Tooltip.Root>
</Label>
<Input
bind:value={siteInformation.home}
class="mt-2"
type="text"
id="home"
placeholder="/ or https://example.com"
required
/>
</div>
</div>
<div class="flex w-full flex-col justify-evenly gap-2">
<div>
<Label for="logo">Logo</Label>
</div>
<div class="flex gap-x-2">
{#if !!siteInformation.logo}
<div class="relative">
<Label
for="logo"
class="inline-block h-[60px] w-[60px] cursor-pointer rounded-sm border p-1"
>
<img
src={base + siteInformation.logo}
class="w-fit hover:scale-95"
alt=""
/>
</Label>
<div class="absolute -right-2 -top-2">
<Button
variant="secondary"
class="h-5 w-5 rounded-full border p-1"
size="icon"
on:click={() => (siteInformation.logo = "")}
>
<X class="h-4 w-4" />
</Button>
</div>
{#if uploadingLogo}
<div
class="absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 transform rounded-sm bg-[rgba(0,0,0,0.5)] p-0.5"
>
<Loader class="h-4 w-4 animate-spin" />
</div>
{/if}
</div>
{/if}
<div>
<Input
class=""
id="logo"
type="file"
accept=".jpg, .jpeg, .png"
disabled={uploadingLogo}
on:change={handleFileChangeLogo}
/>
<p class="mt-1 text-xs font-medium">
Please upload a square image of max size 100KB
</p>
<p class="mt-1 text-xs font-medium text-destructive">
{uploadLogoStatus}
</p>
</div>
</div>
<div>
<Label for="favicon">Favicon</Label>
</div>
<div class="flex gap-x-2">
{#if !!siteInformation.favicon}
<div class="relative">
<Label
for="favicon"
class="inline-block h-[40px] w-[40px] cursor-pointer rounded-sm border p-1"
>
<img
src={base + siteInformation.favicon}
class="w-fit hover:scale-95"
alt=""
/>
</Label>
<div class="absolute -right-2 -top-2">
<Button
variant="secondary"
class="h-5 w-5 rounded-full border p-1"
size="icon"
on:click={() => (siteInformation.favicon = "")}
>
<X class="h-4 w-4" />
</Button>
</div>
{#if uploadingFavicon}
<div
class="absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 transform rounded-sm bg-[rgba(0,0,0,0.5)] p-0.5"
>
<Loader class="h-4 w-4 animate-spin" />
</div>
{/if}
</div>
{/if}
<div>
<Input
class=""
id="logo"
type="file"
accept=".jpg, .jpeg, .png, .ico"
disabled={uploadingFavicon}
on:change={handleFaviconChangeLogo}
/>
<p class="mt-1 text-xs font-medium">
Please upload a square image of max size 20KB
</p>
<p class="mt-1 text-xs font-medium text-destructive">
{uploadFaviconStatus}
</p>
</div>
</div>
</div>
<div class="flex w-full justify-end">
<p class="px-4 pt-3 text-sm">
<span class="text-red-500">
{formErrorMessage}
</span>
</p>
<Button type="submit" disabled={formState === "loading"}>
Save
{#if formState === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</Card.Content>
</Card.Root>
<!-- GitHub Settings -->
+378
View File
@@ -0,0 +1,378 @@
<script>
import * as Card from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Plus, X, Info } from "lucide-svelte";
import autoAnimate from "@formkit/auto-animate";
import { siteDataExtractFromDb, storeSiteData } from "$lib/clientTools.js";
import { base } from "$app/paths";
import { Loader } from "lucide-svelte";
import * as RadioGroup from "$lib/components/ui/radio-group";
import { Tooltip } from "bits-ui";
import ColorPicker from "svelte-awesome-color-picker";
export let data;
let formState = "idle";
let themeData = {
pattern: "none",
theme: "light",
themeToggle: "YES",
barStyle: "PARTIAL",
barRoundness: "ROUNDED",
summaryStyle: "CURRENT",
colorsJ: {
UP: "#4ead94",
DOWN: "#ca3038",
DEGRADED: "#e6ca61"
},
fontJ: {
cssSrc: "https://fonts.googleapis.com/css2?family=Albert+Sans:ital,wght@0,100..900;1,100..900&display=swap",
family: "Albert Sans"
},
colors: "",
font: "",
customCSS: ""
};
themeData = siteDataExtractFromDb(data.siteData, themeData);
if (data.siteData.colors) {
themeData.colorsJ = data.siteData.colors;
}
if (data.siteData.font) {
themeData.fontJ = data.siteData.font;
}
async function formSubmit() {
formState = "loading";
let themeDataAPI = { ...themeData };
themeDataAPI.colors = JSON.stringify(themeDataAPI.colorsJ);
themeDataAPI.font = JSON.stringify(themeDataAPI.fontJ);
delete themeDataAPI.colorsJ;
delete themeDataAPI.fontJ;
let resp = await storeSiteData(themeDataAPI);
//print data
let data = await resp.json();
formState = "idle";
if (data.error) {
alert(data.error);
return;
}
}
</script>
<Card.Root class="mt-4">
<Card.Header class="border-b">
<Card.Title>Theme Customization</Card.Title>
<Card.Description>
You can customize the theme of your site here. You can change the pattern, theme,
colors, font, and monitor style.
</Card.Description>
</Card.Header>
<Card.Content>
<form class="mx-auto mt-4 space-y-4" on:submit|preventDefault={formSubmit}>
<p class="font-medium">
Home Page Pattern
<Tooltip.Root openDelay={100}>
<Tooltip.Trigger class="">
<Info class="inline-block h-4 w-4 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="max-w-md">
<div
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
>
Choose the pattern for the home page. You can choose between squares,
dots, or none.
</div>
</Tooltip.Content>
</Tooltip.Root>
</p>
<div class="flex">
<RadioGroup.Root
bind:value={themeData.pattern}
onValueChange={(e) => (themeData.pattern = e)}
>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="squares" id="rsq" />
<Label for="rsq">Squares</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="dots" id="rdot" />
<Label for="rdot">Dots</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="none" id="rnone" />
<Label for="rnone">None</Label>
</div>
</RadioGroup.Root>
</div>
<hr />
<p class="font-medium">
Default Theme
<Tooltip.Root openDelay={100}>
<Tooltip.Trigger class="">
<Info class="inline-block h-4 w-4 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="max-w-md">
<div
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
>
Choose the default theme for the site. You can choose between light,
dark, or system.
</div>
</Tooltip.Content>
</Tooltip.Root>
</p>
<div class="flex">
<RadioGroup.Root
bind:value={themeData.theme}
onValueChange={(e) => (themeData.theme = e)}
>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="none" id="none_theme" />
<Label for="none_theme">None</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="light" id="light_theme" />
<Label for="light_theme">Light</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="dark" id="dark_theme" />
<Label for="dark_theme">Dark</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="system" id="system_theme" />
<Label for="system_theme">System</Label>
</div>
</RadioGroup.Root>
</div>
<p class="font-medium">
<label>
<input
on:change={(e) => {
themeData.themeToggle = e.target.checked === true ? "YES" : "NO";
}}
type="checkbox"
checked={themeData.themeToggle === "YES"}
/>
Let Users toggle theme between light and dark
</label>
</p>
<hr />
<p class="font-medium">Monitor Style</p>
<div class="flex gap-x-24">
<RadioGroup.Root
bind:value={themeData.barStyle}
onValueChange={(e) => (themeData.barStyle = e)}
>
<p class="text-sm font-medium text-muted-foreground">
Status bar
<Tooltip.Root openDelay={100}>
<Tooltip.Trigger class="">
<Info class="inline-block h-4 w-4 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="max-w-md">
<div
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
>
Choose the style for the monitor. You can choose between partial
or full.
</div>
</Tooltip.Content>
</Tooltip.Root>
</p>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="PARTIAL" id="barStylePARTIAL" />
<Label for="barStylePARTIAL">PARTIAL</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="FULL" id="barStyleFULL" />
<Label for="barStyleFULL">FULL</Label>
</div>
</RadioGroup.Root>
<RadioGroup.Root
bind:value={themeData.barRoundness}
onValueChange={(e) => (themeData.barRoundness = e)}
>
<p class="text-sm font-medium text-muted-foreground">
Roundness
<Tooltip.Root openDelay={100}>
<Tooltip.Trigger class="">
<Info class="inline-block h-4 w-4 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="max-w-md">
<div
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
>
Choose the roundness for the monitor. You can choose between
rounded or sharp.
</div>
</Tooltip.Content>
</Tooltip.Root>
</p>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="SHARP" id="barRoundnessSHARP" />
<Label for="barRoundnessSHARP">SHARP</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="ROUNDED" id="barRoundnessRound" />
<Label for="barRoundnessRound">ROUNDED</Label>
</div>
</RadioGroup.Root>
<RadioGroup.Root
bind:value={themeData.summaryStyle}
onValueChange={(e) => (themeData.summaryStyle = e)}
>
<p class="text-sm font-medium text-muted-foreground">
Summary Type
<Tooltip.Root openDelay={100}>
<Tooltip.Trigger class="">
<Info class="inline-block h-4 w-4 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="max-w-md">
<div
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
>
Choose the summary type for the monitor. You can choose between
current or day.
</div>
</Tooltip.Content>
</Tooltip.Root>
</p>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="CURRENT" id="summaryStyle1" />
<Label for="summaryStyle1">CURRENT</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="DAY" id="summaryStyle2" />
<Label for="summaryStyle2">DAY</Label>
</div>
</RadioGroup.Root>
</div>
<hr />
<p class="font-medium">
Colors
<Tooltip.Root openDelay={100}>
<Tooltip.Trigger class="">
<Info class="inline-block h-4 w-4 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="max-w-md">
<div
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
>
Choose the colors for the monitor. You can choose between UP, DEGRADED,
and DOWN.
</div>
</Tooltip.Content>
</Tooltip.Root>
</p>
<div class="flex w-full flex-row justify-evenly gap-2">
<div class="relative w-full">
<ColorPicker
bind:hex={themeData.colorsJ.UP}
position="responsive"
label="UP"
--cp-input-color="#777"
--cp-button-hover-color="#767676"
on:input={(event) => {
themeData.colorsJ.UP = event.detail.hex;
}}
/>
</div>
<div class="relative w-full">
<ColorPicker
bind:hex={themeData.colorsJ.DEGRADED}
position="responsive"
label="DEGRADED"
--cp-input-color="#777"
--cp-button-hover-color="#767676"
on:input={(event) => {
themeData.colorsJ.DEGRADED = event.detail.hex;
}}
/>
</div>
<div class="relative w-full">
<ColorPicker
bind:hex={themeData.colorsJ.DOWN}
position="responsive"
label="DOWN"
--cp-input-color="#777"
--cp-button-hover-color="#767676"
on:input={(event) => {
themeData.colorsJ.DOWN = event.detail.hex;
}}
/>
</div>
</div>
<hr />
<p class="font-medium">
Font
<Tooltip.Root openDelay={100}>
<Tooltip.Trigger class="">
<Info class="inline-block h-4 w-4 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="max-w-md">
<div
class=" flex items-center justify-center rounded border bg-gray-800 px-2 py-1.5 text-xs font-medium text-gray-300 shadow-popover outline-none"
>
Choose the font for the monitor. You can choose the font URL and font
family name.
</div>
</Tooltip.Content>
</Tooltip.Root>
</p>
<div class="flex w-full flex-row justify-evenly gap-2">
<div class="w-full">
<Label for="cssSrc" class="text-sm text-muted-foreground">Font URL</Label>
<Input
bind:value={themeData.fontJ.cssSrc}
class="mt-2"
type="text"
id="cssSrc"
placeholder="https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap"
/>
</div>
</div>
<div class="flex w-full flex-row justify-start gap-2">
<div class="w-48">
<Label for="fontName" class="text-sm text-muted-foreground"
>Font Family Name</Label
>
<Input
bind:value={themeData.fontJ.family}
class="mt-2"
type="text"
id="fontName"
placeholder="Lato"
/>
</div>
</div>
<hr />
<div class="flex w-full flex-row justify-start gap-2">
<div class="w-full">
<Label for="customCSS" class="text-sm text-muted-foreground">Custom CSS</Label>
<textarea
bind:value={themeData.customCSS}
id="customCSS"
class="h-48 w-full rounded-sm border p-2"
placeholder=".className&#123;color: red;&#125;"
></textarea>
</div>
</div>
<hr />
<div class="flex w-full justify-end">
<Button type="submit" disabled={formState === "loading"}>
Save
{#if formState === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</form>
</Card.Content>
</Card.Root>
@@ -0,0 +1,543 @@
<script>
import { Button } from "$lib/components/ui/button";
import { Plus, X, Loader, Settings, Check } from "lucide-svelte";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { base } from "$app/paths";
import * as Select from "$lib/components/ui/select";
import { createEventDispatcher } from "svelte";
import { onMount } from "svelte";
import { IsValidURL } from "$lib/clientTools.js";
import * as Card from "$lib/components/ui/card";
let status = "ACTIVE";
let formState = "idle";
let showAddTrigger = false;
let triggers = [];
let loadingData = false;
export let data;
let newTrigger = {
id: 0,
name: "some name",
trigger_type: "webhook",
trigger_desc: "",
trigger_status: "ACTIVE",
trigger_meta: {
url: "",
headers: [],
to: "",
from: data.RESEND_SENDER_EMAIL || ""
}
};
let invalidFormMessage = "";
function resetTrigger() {
newTrigger = {
id: 0,
name: "",
trigger_type: "webhook",
trigger_desc: "",
trigger_status: "ACTIVE",
trigger_meta: {
url: "",
headers: [],
to: "",
from: data.RESEND_SENDER_EMAIL || ""
}
};
}
function selectChange(e) {
status = e.value;
loadData();
}
function addHeader() {
newTrigger.trigger_meta.headers = [
...newTrigger.trigger_meta.headers,
{ key: "", value: "" }
];
}
//validate this pattern Alerts <alert@example.com>
function validateNameEmailPattern(input) {
const pattern = /^([\w\s]+)\s*<([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>$/;
const match = input.match(pattern);
if (match) {
return {
isValid: true,
name: match[1].trim(),
email: match[2]
};
}
return {
isValid: false,
name: null,
email: null
};
}
async function addNewTrigger() {
invalidFormMessage = "";
formState = "loading";
//newTrigger.trigger_type present not empty
if (newTrigger.trigger_type == "") {
invalidFormMessage = "Trigger Type is required";
formState = "idle";
return;
}
if (newTrigger.trigger_type == "email") {
newTrigger.trigger_meta.url = "";
let emValid = validateNameEmailPattern(newTrigger.trigger_meta.from);
if (!emValid.isValid) {
invalidFormMessage = "Invalid Name and Email Address for Sender";
formState = "idle";
return;
}
}
//newTrigger.name present not empty
if (newTrigger.name == "") {
invalidFormMessage = "Trigger Name is required";
formState = "idle";
return;
}
//newTrigger.trigger_meta.url present not empty
if (newTrigger.trigger_type != "email" && newTrigger.trigger_meta.url == "") {
invalidFormMessage = "Trigger URL is required";
formState = "idle";
return;
}
if (newTrigger.trigger_meta.url != "" && !IsValidURL(newTrigger.trigger_meta.url)) {
invalidFormMessage = "Invalid URL";
formState = "idle";
return;
}
let toPost = {
id: newTrigger.id,
name: newTrigger.name,
trigger_type: newTrigger.trigger_type,
trigger_status: newTrigger.trigger_status,
trigger_desc: newTrigger.trigger_desc,
trigger_meta: JSON.stringify(newTrigger.trigger_meta)
};
try {
let data = await fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ action: "createUpdateTrigger", data: toPost })
});
let resp = await data.json();
if (resp.error) {
invalidFormMessage = resp.error;
} else {
showAddTrigger = false;
loadData();
}
} catch (error) {
invalidFormMessage = "Error while saving data";
} finally {
formState = "idle";
}
}
function addNewTriggerFn() {
resetTrigger();
showAddTrigger = true;
}
function showUpdateSheet(m) {
newTrigger = { ...newTrigger, ...m };
newTrigger.trigger_meta = JSON.parse(newTrigger.trigger_meta);
showAddTrigger = true;
}
async function testTrigger(i, trigger_id, status) {
triggers[i].testLoaders = "loading";
try {
let data = await fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ action: "testTrigger", data: { trigger_id, status } })
});
let resp = await data.json();
} catch (error) {
alert("Error: " + error);
} finally {
triggers[i].testLoaders = "success";
setTimeout(() => {
triggers[i].testLoaders = "idle";
}, 3000);
}
}
async function loadData() {
//fetch data
loadingData = true;
try {
let apiResp = await fetch(base + "/manage/app/api/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ action: "getTriggers", data: { status: status } })
});
triggers = await apiResp.json();
triggers = triggers.map((t) => {
t.testLoaders = "idle";
return t;
});
} catch (error) {
alert("Error: " + error);
} finally {
loadingData = false;
}
}
onMount(() => {
loadData();
});
</script>
<div class="mt-4 flex justify-between">
<div class="flex w-40">
<Select.Root portal={null} onSelectedChange={selectChange}>
<Select.Trigger id="statusmonitor2">
<Select.Value placeholder={status} />
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.Label>Status</Select.Label>
<Select.Item value="ACTIVE" label="ACTIVE" class="text-sm font-medium">
ACTIVE
</Select.Item>
<Select.Item value="INACTIVE" label="INACTIVE" class="text-sm font-medium">
INACTIVE
</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
{#if loadingData}
<Loader class="ml-2 mt-2 inline h-6 w-6 animate-spin" />
{/if}
</div>
<Button on:click={addNewTriggerFn}>
<Plus class="mr-2 inline h-6 w-6" />
New Trigger
</Button>
</div>
<div class="mt-4">
{#each triggers as trigger, i}
<Card.Root class="mb-4">
<Card.Header class="relative">
<Card.Title>
{#if trigger.trigger_type == "webhook"}
<img
src={base + "/webhooks.svg"}
alt={trigger.trigger_type}
class="mr-2 inline-block h-6 w-6"
/>
{:else if trigger.trigger_type == "slack"}
<img
src={base + "/slack.svg"}
alt={trigger.trigger_type}
class="mr-2 inline-block h-6 w-6"
/>
{:else if trigger.trigger_type == "discord"}
<img
src={base + "/discord.svg"}
alt={trigger.trigger_type}
class="mr-2 inline-block h-6 w-6"
/>
{:else if trigger.trigger_type == "email"}
<img
src={base + "/email.png"}
alt={trigger.trigger_type}
class="mr-2 inline-block h-6 w-6"
/>
{/if}
{trigger.name}
</Card.Title>
<div class="absolute right-3 top-3 flex gap-x-2">
<Button
variant="secondary"
class="h-8 p-2 "
disabled={trigger.testLoaders == "loading"}
on:click={() => testTrigger(i, trigger.id, "TRIGGERED")}
>
{#if trigger.testLoaders == "loading"}
<Loader class="mr-1 inline h-3 w-3 animate-spin" />
{/if}
{#if trigger.testLoaders == "success"}
<Check class="mr-1 inline h-3 w-3 text-green-500 " />
{/if}
<span class="h-4 text-xs font-medium"> TEST </span>
</Button>
<Button
variant="secondary"
class=" h-8 w-8 p-2"
on:click={() => showUpdateSheet(trigger)}
>
<Settings class="inline h-4 w-4" />
</Button>
</div>
</Card.Header>
</Card.Root>
{/each}
</div>
{#if showAddTrigger}
<div class="fixed left-0 top-0 z-50 h-screen w-screen bg-card bg-opacity-20 backdrop-blur-sm">
<div class="absolute right-0 top-0 h-screen w-[800px] bg-background shadow-xl">
<div class="absolute top-0 flex h-12 w-full justify-between gap-2 border-b p-3">
{#if newTrigger.id}
<h2 class="text-lg font-medium">Edit Trigger</h2>
{:else}
<h2 class="text-lg font-medium">Add Trigger</h2>
{/if}
<div>
<label class="inline-flex cursor-pointer items-center">
<input
type="checkbox"
value=""
class="peer sr-only"
checked={newTrigger.trigger_status == "ACTIVE"}
on:change={() => {
newTrigger.trigger_status =
newTrigger.trigger_status == "ACTIVE" ? "INACTIVE" : "ACTIVE";
}}
/>
<div
class="peer relative h-6 w-11 rounded-full bg-gray-200 after:absolute after:start-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rtl:peer-checked:after:-translate-x-full dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"
></div>
</label>
</div>
</div>
<div class="mt-12 w-full overflow-y-auto p-3" style="height: calc(100vh - 7rem);">
<p class="mb-4">
Select the type of Trigger you want to add. You can add multiple Triggers of
different types.
</p>
<div class="flex justify-stretch gap-4">
<Button
variant={newTrigger.trigger_type != "webhook" ? "outline" : "secondary"}
class="border"
on:click={() => {
newTrigger.trigger_type = "webhook";
}}
>
<img src="{base}/webhooks.svg" title="webhook" class="mr-4 w-6" />
Webhooks
</Button>
<Button
variant={newTrigger.trigger_type != "discord" ? "outline" : "secondary"}
class="border"
on:click={() => {
newTrigger.trigger_type = "discord";
}}
>
<img src="{base}/discord.svg" title="discord" class="mr-4 w-6" />
Discord
</Button>
<Button
variant={newTrigger.trigger_type != "slack" ? "outline" : "secondary"}
class="border"
on:click={() => {
newTrigger.trigger_type = "slack";
}}
>
<img src="{base}/slack.svg" title="slack" class="mr-4 w-6" />
Slack
</Button>
<Button
variant={newTrigger.trigger_type != "email" ? "outline" : "secondary"}
class="border"
on:click={() => {
newTrigger.trigger_type = "email";
}}
>
<img src="{base}/email.png" title="email" class="mr-4 w-6" />
Email
</Button>
</div>
<div class="mt-4">
<div class="flex flex-row gap-4">
<div class="w-[300px]">
<Label class="text-sm">
Add a name for your <span class="underline"
>{newTrigger.trigger_type}</span
>
Trigger
<span class="text-red-500">*</span>
</Label>
<Input
class="mt-2"
bind:value={newTrigger.name}
placeholder="My Trigger"
/>
</div>
</div>
<div class="mt-4 w-full">
<Label class="text-sm">
Add description for your <span class="underline">
{newTrigger.trigger_type}
</span>
Trigger
</Label>
<Input
class="mt-2"
bind:value={newTrigger.trigger_desc}
placeholder="Example: This is a trigger."
/>
</div>
{#if newTrigger.trigger_type != "email"}
<div class="mt-4 w-full">
<Label class="text-sm">
Add URL for your <span class="underline"
>{newTrigger.trigger_type}</span
>
Trigger
<span class="text-red-500">*</span>
</Label>
<Input
class="mt-2"
bind:value={newTrigger.trigger_meta.url}
placeholder="https://example.com/url"
/>
</div>
{/if}
{#if newTrigger.trigger_type == "webhook"}
<div class="mt-4 w-full">
<Label for="url">Add Optional Headers for Webhooks</Label>
<div class="grid grid-cols-6 gap-2">
{#each newTrigger.trigger_meta.headers as header, index}
<div class="col-span-2">
<Input
bind:value={header.key}
id="header"
placeholder="Content-Type"
/>
</div>
<div class="col-span-3">
<Input
bind:value={header.value}
id="header"
placeholder="application/json"
/>
</div>
<div class="col-span-1 pt-2">
<Button
class=" h-6 w-6 p-1"
variant="secondary"
on:click={() => {
newTrigger.trigger_meta.headers =
newTrigger.trigger_meta.headers.filter(
(_, i) => i !== index
);
}}
>
<X class="h-5 w-5" />
</Button>
</div>
{/each}
</div>
<div class="relative pb-8 pt-2">
<hr
class="border-1 border-border-input relative top-4 h-px border-dashed"
/>
<Button
on:click={addHeader}
variant="secondary"
class="absolute left-1/2 h-8 -translate-x-1/2 p-2 text-xs "
>
<Plus class="mr-1 h-4 w-4" /> Add Headers
</Button>
</div>
</div>
{:else if newTrigger.trigger_type == "email"}
<div class="mt-4 w-full">
{#if !!!data.RESEND_API_KEY}
<div class="rounded-md border bg-card p-2 text-xs">
<p class="text-sm font-semibold">Email Trigger</p>
<p class="text-xs">
Kener uses <a
href="https://resend.com/"
class="text-blue-500"
target="_blank">resend</a
>
to send emails. Please make sure you have created an account
with resend. Also add the resend api key as environment variable
<span class="font-mono">RESEND_API_KEY</span>.
<span class="text-red-500"
>The RESEND_API_KEY is not set in your environment
variable. Please set it and restart the server</span
>.
</p>
</div>
{/if}
</div>
<div class="mt-4 w-full">
<Label class="text-sm">
Add To Email Addresses, (comma separated)
<span class="text-red-500">*</span>
</Label>
<Input
class="mt-2"
bind:value={newTrigger.trigger_meta.to}
placeholder="john@example.com, jane@example.com"
/>
</div>
<div class="mt-4 w-full">
<Label class="text-sm">
Add Name and Sender Email Address
<span class="text-red-500">*</span>
</Label>
<Input
class="mt-2"
bind:value={newTrigger.trigger_meta.from}
placeholder="Alerts <alert@example.com>"
/>
</div>
{/if}
</div>
</div>
<div class="absolute bottom-0 grid h-16 w-full grid-cols-6 gap-2 border-t p-3">
<div class="col-span-1">
<Button
variant="ghost"
class="col-span-1 w-full"
on:click={(e) => {
showAddTrigger = false;
}}>Cancel</Button
>
</div>
<div class="col-span-4 py-2.5">
<p class="text-right text-xs font-medium text-red-500">{invalidFormMessage}</p>
</div>
<div class="col-span-1">
<Button
class="col-span-1 w-full"
disabled={formState === "loading"}
on:click={addNewTrigger}
>
Save
{#if formState === "loading"}
<Loader class="ml-2 inline h-4 w-4 animate-spin" />
{/if}
</Button>
</div>
</div>
</div>
</div>
{/if}
+396 -557
View File
@@ -1,569 +1,408 @@
<script>
import { Badge } from "$lib/components/ui/badge";
import * as Popover from "$lib/components/ui/popover";
import { onMount } from "svelte";
import { Button } from "$lib/components/ui/button";
import { base } from '$app/paths';
import {
ArrowRight,
Share2,
Info,
Link,
CopyCheck,
Code,
TrendingUp,
Percent,
} from "lucide-svelte";
import { buttonVariants } from "$lib/components/ui/button";
import { Skeleton } from "$lib/components/ui/skeleton";
import { createEventDispatcher } from "svelte";
import { afterUpdate } from "svelte";
import * as RadioGroup from "$lib/components/ui/radio-group";
import { Label } from "$lib/components/ui/label";
import axios from "axios";
import { l, summaryTime, n, ampm } from "$lib/i18n/client";
const dispatch = createEventDispatcher();
import * as Popover from "$lib/components/ui/popover";
import { onMount } from "svelte";
import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button";
import { base } from "$app/paths";
import { sub, startOfDay, getUnixTime } from "date-fns";
/**
* @type {{ pageData: { _90Day: any; uptime0Day: any; uptime90Day: any; }; tag: string; embed: undefined; image: any; name: any; description: any; folderName: any; }}
*/
export let monitor;
/**
* @type {any}
*/
export let localTz;
export let lang;
import {
Share2,
Link,
CopyCheck,
Code,
TrendingUp,
Percent,
Loader,
ChevronLeft,
ChevronRight
} from "lucide-svelte";
import { buttonVariants } from "$lib/components/ui/button";
import { createEventDispatcher } from "svelte";
import { afterUpdate } from "svelte";
import axios from "axios";
import { l, summaryTime, f } from "$lib/i18n/client";
import { hoverAction, clickOutsideAction, slide } from "svelte-legos";
import LoaderBoxes from "$lib/components/loaderbox.svelte";
import NumberFlow from "@number-flow/svelte";
import Incident from "$lib/components/IncidentNew.svelte";
let _0Day = {};
let _90Day = monitor.pageData._90Day;
let uptime0Day = monitor.pageData.uptime0Day;
let uptime90Day = monitor.pageData.uptime90Day;
let theme = "light";
let embedType = "js";
let todayDD = Object.keys(_90Day)[Object.keys(_90Day).length - 1];
let view = "90day";
let copiedLink = false;
let copiedEmbed = false;
let copiedBadgeStatus = false;
let copiedBadgeUptime = false;
const dispatch = createEventDispatcher();
function copyLinkToClipboard() {
//get domain with port number
let domain = window.location.host;
//get protocol
let protocol = window.location.protocol;
let path = `${base}/monitor-${monitor.tag}`;
navigator.clipboard.writeText(protocol + "//" + domain + path);
copiedLink = true;
setTimeout(function () {
copiedLink = false;
}, 1500);
}
function copyUptimeBadge() {
let domain = window.location.host;
let protocol = window.location.protocol;
let path = `${base}/badge/${monitor.tag}/uptime`;
navigator.clipboard.writeText(protocol + "//" + domain + path);
copiedBadgeUptime = true;
setTimeout(function () {
copiedBadgeUptime = false;
}, 1500);
}
function copyStatusBadge() {
let domain = window.location.host;
let protocol = window.location.protocol;
let path = `${base}/badge/${monitor.tag}/status`;
navigator.clipboard.writeText(protocol + "//" + domain + path);
copiedBadgeStatus = true;
setTimeout(function () {
copiedBadgeStatus = false;
}, 1500);
}
export let monitor;
function copyScriptTagToClipboard() {
//get domain with port number
let domain = window.location.host;
//get protocol
let protocol = window.location.protocol;
let path = `${base}/embed-${monitor.tag}`;
let scriptTag =
`<script async src="${protocol + "//" + domain + path}/js?theme=${theme}&monitor=${protocol + "//" + domain + path}"><` +
"/script>";
export let localTz;
export let lang;
export let embed = false;
export let selectedLang = "en";
if (embedType == "iframe") {
scriptTag = `<iframe src="${protocol + "//" + domain + path}?theme=${theme}" width="100%" height="200" allowfullscreen="allowfullscreen" allowpaymentrequest frameborder="0"></iframe>`;
}
navigator.clipboard.writeText(scriptTag);
copiedEmbed = true;
setTimeout(function () {
copiedEmbed = false;
}, 1500);
}
let _0Day = {};
let _90Day = monitor.pageData._90Day;
let uptime90Day = monitor.pageData.uptime90Day;
let incidents = {};
let dayIncidentsFull = [];
function getToday() {
//axios post using options application json
setTimeout(() => {
axios
.post(`${base}/api/today`, {
monitor: monitor,
localTz: localTz,
})
.then((response) => {
if (response.data) {
_0Day = response.data;
}
})
.catch((error) => {
console.log(error);
});
}, 1000 * 1);
}
function loadIncidents() {
axios
.post(`${base}/api/today/incidents`, {
tag: monitor.tag,
startTs: monitor.pageData.midnight90DaysAgo,
endTs: monitor.pageData.midnightTomorrow,
localTz: localTz
})
.then((response) => {
if (response.data) {
incidents = response.data;
}
})
.catch((error) => {
console.log(error);
});
}
function scrollToRight() {
setTimeout(() => {
let divs = document.querySelectorAll(".daygrid90");
divs.forEach((div) => {
div.scrollLeft = div.scrollWidth;
});
}, 1000 * 0.2);
}
function switchView(s) {
view = s;
if (Object.keys(_0Day).length == 0) {
getToday();
}
if (view == "90day") {
scrollToRight();
}
}
function getToday(startTs, incidentIDs) {
axios
.post(`${base}/api/today`, {
monitor: monitor,
localTz: localTz,
startTs: startTs,
incidentIDs: incidentIDs
})
.then((response) => {
if (response.data) {
_0Day = response.data._0Day;
dayUptime = response.data.uptime;
dayIncidentsFull = response.data.incidents;
}
loadingDayData = false;
})
.catch((error) => {
console.log(error);
loadingDayData = false;
});
}
onMount(async () => {
//getToday();
//for each div with class 90daygrid scroll to right most
scrollToRight();
});
afterUpdate(() => {
dispatch("heightChange", {});
});
function scrollToRight() {
setTimeout(() => {
let divs = document.querySelectorAll(".daygrid90");
divs.forEach((div) => {
div.scrollLeft = div.scrollWidth;
});
}, 1000 * 0.2);
}
let uptimesRollers = [
{
text: `${l(lang, "90 Days")}`,
startTs: getUnixTime(startOfDay(sub(new Date(), { days: 90 }))),
value: uptime90Day
},
{
text: `${l(lang, "60 Days")}`,
startTs: getUnixTime(startOfDay(sub(new Date(), { days: 59 })))
},
{
text: `${l(lang, "30 Days")}`,
startTs: getUnixTime(startOfDay(sub(new Date(), { days: 29 })))
},
{
text: `${l(lang, "14 Days")}`,
startTs: getUnixTime(startOfDay(sub(new Date(), { days: 13 })))
},
{
text: `${l(lang, "7 Days")}`,
startTs: getUnixTime(startOfDay(sub(new Date(), { days: 6 })))
},
{
text: l(lang, "Today"),
startTs: getUnixTime(startOfDay(new Date()))
}
];
//start of the week moment
let rolledAt = 0;
let rollerLoading = false;
async function rollSummary(r) {
let newRolledAt = (rolledAt + r) % uptimesRollers.length;
if (uptimesRollers[newRolledAt].value === undefined) {
rollerLoading = true;
uptimesRollers[newRolledAt].loading = true;
let resp = await axios.post(`${base}/api/today/aggregated`, {
monitor: monitor,
startTs: uptimesRollers[newRolledAt].startTs
});
uptimesRollers[newRolledAt].value = resp.data.uptime;
rollerLoading = false;
}
rolledAt = newRolledAt;
for (const key in _90Day) {
if (Object.prototype.hasOwnProperty.call(_90Day, key)) {
const element = _90Day[key];
if (key >= uptimesRollers[rolledAt].startTs) {
_90Day[key].border = true;
} else {
_90Day[key].border = false;
}
}
}
}
onMount(async () => {
scrollToRight();
loadIncidents();
});
afterUpdate(() => {
dispatch("heightChange", {});
});
function show90Inline(e, bar) {
if (e.detail.hover) {
_90Day[bar.timestamp].showDetails = true;
} else {
_90Day[bar.timestamp].showDetails = false;
}
}
let showDailyDataModal = false;
let dateFetchedFor = "";
let dayUptime = "NA";
let loadingDayData = false;
function dailyDataGetter(e, bar, incidentObj) {
if (embed) {
return;
}
let incidentIDs = incidentObj?.ids || [];
dayUptime = "NA";
dateFetchedFor = f(new Date(bar.timestamp * 1000), "EEEE, MMMM do, yyyy", selectedLang);
showDailyDataModal = true;
loadingDayData = true;
dayIncidentsFull = [];
setTimeout(() => {
getToday(bar.timestamp, incidentIDs);
}, 750);
}
</script>
<div class="grid grid-cols-12 gap-4 w-full monitor pb-4 md:w-[890px]">
{#if monitor.embed === undefined}
<div class="col-span-12 md:col-span-4">
<div class="pt-1">
<div class="scroll-m-20 text-2xl font-semibold tracking-tight">
{#if monitor.image}
<img
src={monitor.image}
class="w-6 h-6 inline"
alt={monitor.name}
srcset=""
/>
{/if}
<span> {monitor.name} </span>
<br />
{#if monitor.description}
<Popover.Root>
<Popover.Trigger>
<span
class="pt-0 pl-1 menu-monitor pr-0 pb-0 {buttonVariants(
{ variant: 'link' },
)}"
>
<Info
size={12}
class="text-muted-foreground"
/>
</span>
</Popover.Trigger>
<Popover.Content class="text-sm">
<h2 class="mb-2 text-lg font-semibold">
{monitor.name}
</h2>
<span class="text-muted-foreground text-sm">
{@html monitor.description}
</span>
</Popover.Content>
</Popover.Root>
{/if}
<Popover.Root>
<Popover.Trigger>
<span
class="pt-0 pl-1 pb-0 menu-monitor pr-0 {buttonVariants(
{ variant: 'link' },
)}"
>
<Share2
size={12}
class="text-muted-foreground"
/>
</span>
</Popover.Trigger>
<Popover.Content
class="pl-1 pr-1 pb-1 w-[375px] max-w-full"
>
<h2 class="mb-1 text-lg font-semibold px-2">
{l(lang, "monitor.share")}
</h2>
<p class="pl-2 mb-2 text-muted-foreground text-sm">
{l(lang, "monitor.share_desc")}
</p>
<Button
class="ml-2"
variant="secondary"
on:click={copyLinkToClipboard}
>
{#if !copiedLink}
<Link class="inline mr-2" size={12} />
<span class="text-sm font-medium">
{l(lang, "monitor.cp_link")}
</span>
{:else}
<CopyCheck class="inline mr-2" size={12} />
<span class="text-sm font-medium">
{l(lang, "monitor.cpd_link")}
</span>
{/if}
</Button>
<h2 class="mb-2 mt-4 text-lg font-semibold px-2">
{l(lang, "monitor.embed")}
</h2>
<p class="pl-2 mb-2 text-muted-foreground text-sm">
{l(lang, "monitor.embed_desc")}
</p>
<div class="grid grid-cols-2 gap-2">
<div class="col-span-1 pl-4">
<h3
class="text-sm mb-2 text-muted-foreground"
>
{l(lang, "monitor.theme")}
</h3>
<RadioGroup.Root bind:value={theme}>
<div
class="flex items-center space-x-2"
>
<RadioGroup.Item
value="light"
id="light-theme"
/>
<Label for="light-theme"
>{l(
lang,
"monitor.theme_light",
)}</Label
>
</div>
<div
class="flex items-center space-x-2"
>
<RadioGroup.Item
value="dark"
id="dark-theme"
/>
<Label for="dark-theme"
>{l(
lang,
"monitor.theme_dark",
)}</Label
>
</div>
<RadioGroup.Input name="theme" />
</RadioGroup.Root>
</div>
<div class="col-span-1 pl-2">
<h3
class="text-sm mb-2 text-muted-foreground"
>
{l(lang, "monitor.mode")}
</h3>
<RadioGroup.Root bind:value={embedType}>
<div
class="flex items-center space-x-2"
>
<RadioGroup.Item
value="js"
id="js-embed"
/>
<Label for="js-embed"
>&#x3C;script&#x3E;</Label
>
</div>
<div
class="flex items-center space-x-2"
>
<RadioGroup.Item
value="iframe"
id="iframe-embed"
/>
<Label for="iframe-embed"
>&#x3C;iframe&#x3E;</Label
>
</div>
<RadioGroup.Input name="embed" />
</RadioGroup.Root>
</div>
</div>
<Button
class="mb-2 mt-4 ml-2"
variant="secondary"
on:click={copyScriptTagToClipboard}
>
{#if !copiedEmbed}
<Code class="inline mr-2" size={12} />
<span class="text-sm font-medium">
{l(lang, "monitor.cp_code")}
</span>
{:else}
<CopyCheck class="inline mr-2" size={12} />
<span class="text-sm font-medium">
{l(lang, "monitor.cpd_code")}
</span>
{/if}
</Button>
<h2 class="mb-2 mt-2 text-lg font-semibold px-2">
{l(lang, "monitor.badge")}
</h2>
<p class="pl-2 mb-2 text-muted-foreground text-sm">
{l(lang, "monitor.badge_desc")}
</p>
<Button
class="mb-2 mt-2 ml-2"
variant="secondary"
on:click={copyStatusBadge}
>
{#if !copiedBadgeStatus}
<TrendingUp class="inline mr-2" size={12} />
<span class="text-sm font-medium">
{l(lang, "monitor.status")}
{l(lang, "monitor.badge")}</span
>
{:else}
<CopyCheck class="inline mr-2" size={12} />
<span class="text-sm font-medium">
{l(lang, "monitor.copied")}
{l(lang, "monitor.badge")}
</span>
{/if}
</Button>
<Button
class="mb-2 mt-2 ml-2"
variant="secondary"
on:click={copyUptimeBadge}
>
{#if !copiedBadgeUptime}
<Percent class="inline mr-2" size={12} />
<span class="text-sm font-medium">
{l(lang, "monitor.uptime")}
{l(lang, "monitor.badge")}
</span>
{:else}
<CopyCheck class="inline mr-2" size={12} />
<span class="text-sm font-medium">
{l(lang, "monitor.copied")}
{l(lang, "monitor.badge")}
</span>
{/if}
</Button>
</Popover.Content>
</Popover.Root>
</div>
</div>
<div class="">
<div class="grid grid-cols-2 gap-0">
<div class="col-span-1 -mt-2">
<a
href="{base}/incident/{monitor.folderName}#past_incident"
class="pt-0 pl-0 pb-0 text-indigo-500 text-left {buttonVariants(
{ variant: 'link' },
)}"
>
{l(lang, "root.recent_incidents")}
<ArrowRight size={16} />
</a>
</div>
</div>
</div>
</div>
{/if}
<div
class="col-span-12 md:w-[546px] {monitor.embed === undefined
? 'md:col-span-8'
: 'overflow-hidden'} pt-2"
>
<div class="col-span-12">
<div class="grid grid-cols-12">
<div
class="{monitor.embed === undefined
? 'col-span-12'
: 'col-span-8'} md:col-span-8 h-[32px]"
>
<button
class="inline-block"
on:click={(e) => {
switchView("90day");
}}
>
<Badge variant={view != "90day" ? "outline" : ""}>
{l(lang, "monitor.90_day")}{n(
lang,
uptime90Day,
)}%
</Badge>
</button>
<button
on:click={(e) => {
switchView("0day");
}}
>
<Badge variant={view != "0day" ? "outline" : ""}>
{l(lang, "monitor.today")}{n(lang, uptime0Day)}%
</Badge>
</button>
</div>
<div
class="{monitor.embed === undefined
? 'col-span-12'
: 'col-span-4'} md:col-span-4 text-right h-[32px]"
>
{#if _90Day[todayDD]}
<div
class="text-api-up text-sm truncate font-semibold mt-[4px] text-{_90Day[
todayDD
].cssClass}"
title={_90Day[todayDD].message}
>
{summaryTime(lang, _90Day[todayDD].message)}
</div>
{/if}
</div>
</div>
{#if view == "90day"}
<div class="chart-status relative mt-1 col-span-12">
<div
class="flex overflow-x-auto daygrid90 overflow-y-hidden py-1"
>
{#each Object.entries(_90Day) as [ts, bar]}
<div class="h-[30px] w-[6px] rounded-sm oneline">
<div
class="h-[30px] bg-{bar.cssClass} w-[4px] rounded-sm mr-[2px]"
></div>
</div>
<div
class="absolute show-hover text-sm bg-background"
>
<div class="text-{bar.cssClass} font-semibold">
{n(
lang,
new Date(
bar.timestamp * 1000,
).toLocaleDateString(),
)}
{summaryTime(lang, bar.message)}
</div>
</div>
{/each}
</div>
</div>
{:else}
<div class="chart-status relative mt-1 mb-4 col-span-12">
<div class="flex flex-wrap today-sq-div">
{#if Object.keys(_0Day).length == 0}
<Skeleton
class="md:w-[546px] w-full h-[20px] mr-1 rounded-full"
/>
{/if}
{#each Object.entries(_0Day) as [ts, bar]}
<div
data-index={bar.index}
class="h-[10px] bg-{bar.cssClass} w-[10px] today-sq m-[1px]"
></div>
<div class="hiddenx relative">
<div
data-index={ts.index}
class="p-2 text-sm rounded font-semibold message bg-black text-white border"
>
<p>
<span class="text-{bar.cssClass}">
</span>
{ampm(
lang,
n(
lang,
new Date(
bar.timestamp * 1000,
).toLocaleTimeString(),
),
)}
</p>
{#if bar.status != "NO_DATA"}
<p class="pl-4">
{l(lang, "statuses." + bar.status)}
</p>
{:else}
<p class="pl-4">-</p>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
<div class="monitor relative grid w-full grid-cols-12 gap-2 pb-2 pt-0 md:w-[655px]">
{#if !!!embed}
<div class="col-span-12 md:w-[546px]">
<div class="pt-0">
<div class="scroll-m-20 pr-5 text-xl font-medium tracking-tight">
{#if monitor.image}
<img
src={base + monitor.image}
class="absolute left-6 top-6 inline h-5 w-5"
alt={monitor.name}
srcset=""
/>
{/if}
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
{monitor.name}
</p>
<p class="mt-1 text-xs font-medium text-muted-foreground">
{#if !!monitor.description}
{@html monitor.description}
{/if}
</p>
<div class="absolute right-14 top-5">
<Button
class="wiggle h-5 p-0 text-muted-foreground hover:text-primary"
variant="link"
on:click={(e) => {
dispatch("show_shareMenu", {
monitor: monitor
});
}}
>
<Share2 class="h-4 w-4 " />
</Button>
</div>
</div>
</div>
</div>
{/if}
<div class="col-span-12 min-h-[94px] pt-2 md:w-[546px]">
<div class="col-span-12">
<div class="flex justify-between">
<div class=" ">
<div class="flex gap-x-1">
{#if rolledAt > 0}
<Button
variant="ghost"
class="h-5 w-5 p-0"
on:click={() => rollSummary(-1)}
>
<ChevronLeft class="h-4 w-4" />
</Button>
{/if}
<div class="flex text-xs font-semibold">
<span>
{uptimesRollers[rolledAt].text}
</span>
<span class="">
{#if rollerLoading}
<Loader class="mx-1 -mt-0.5 inline h-4 w-4 animate-spin" />
{:else}
<TrendingUp class="mx-1 -mt-0.5 inline h-3 w-3" />
{/if}
{#if isNaN(uptimesRollers[rolledAt].value)}
<span class="text-muted-foreground">-</span>
{:else}
<NumberFlow
value={uptimesRollers[rolledAt].value}
format={{
notation: "standard",
minimumFractionDigits: 4,
maximumFractionDigits: 4
}}
suffix="%"
/>
{/if}
</span>
</div>
{#if rolledAt < uptimesRollers.length - 1}
<Button
variant="ghost"
class="h-5 w-5 p-0"
on:click={() => rollSummary(1)}
>
<ChevronRight class="h-4 w-4" />
</Button>
{/if}
</div>
</div>
<div class="pt-0.5 text-right">
<div
class="text-api-up truncate text-xs font-semibold text-{monitor.pageData
.summaryColorClass}"
>
{monitor.pageData.summaryStatus}
</div>
</div>
</div>
<div
class="relative col-span-12 mt-1"
use:clickOutsideAction
on:clickoutside={(e) => {
showDailyDataModal = false;
}}
>
<div
class="daygrid90 flex min-h-[60px] justify-start overflow-x-auto overflow-y-hidden py-1"
>
{#each Object.entries(_90Day) as [ts, bar]}
<a
data-ts={ts}
use:hoverAction
on:hover={(e) => {
show90Inline(e, bar);
}}
on:click={(e) => {
dailyDataGetter(e, bar, incidents[ts]);
}}
href="#"
class="oneline h-[34px] w-[6px]
{bar.border ? 'opacity-100' : 'opacity-20'} pb-1"
>
<div
class="oneline-in h-[30px] bg-{bar.cssClass} mx-auto w-[4px] rounded-{monitor.pageData.barRoundness.toUpperCase() ==
'SHARP'
? 'none'
: 'sm'}"
></div>
{#if !!incidents[ts]}
<div
style="transition-delay: {Math.floor(
Math.random() * (1500 - 500 + 1)
) + 500}ms;"
class="bg-api-{incidents[
ts
].monitor_impact.toLowerCase()} comein absolute -bottom-[3px] left-[1px] h-[4px] w-[4px] rounded-full"
></div>
{/if}
</a>
{#if bar.showDetails}
<div class="show-hover absolute text-sm">
<div class="text-{bar.textClass} text-xs font-semibold">
{f(
new Date(bar.timestamp * 1000),
"EEEE, MMMM do, yyyy",
selectedLang
)}
-
{bar.summaryStatus}
</div>
</div>
{/if}
{/each}
</div>
{#if showDailyDataModal}
<div
transition:slide={{ direction: "bottom" }}
class="absolute -left-2 top-10 z-10 mx-auto rounded-sm border bg-card px-[7px] py-[7px] shadow-lg md:w-[560px]"
>
<div class="mb-2 flex justify-between text-xs font-semibold">
<span>{dateFetchedFor}</span>
{#if !loadingDayData}
<span>
<TrendingUp class="mx-1 inline" size={12} />
{dayUptime}%</span
>
{/if}
</div>
{#if dayIncidentsFull.length > 0}
<div class="-mx-2 mb-4 grid grid-cols-1">
<div class="col-span-1 px-2">
<Badge variant="outline" class="border-0 pl-0">
{l(lang, "Incident Updates")}
</Badge>
</div>
{#each dayIncidentsFull as incident, index}
<div class="col-span-1">
<Incident {incident} {lang} index="incident-{index}" />
</div>
{/each}
</div>
{/if}
<div class="flex flex-wrap">
{#if loadingDayData}
<LoaderBoxes />
{:else}
{#each Object.entries(_0Day) as [ts, bar]}
<div
data-index={bar.index}
class="bg-{bar.cssClass} today-sq m-[1px] h-[10px] w-[10px]"
></div>
<div class="hiddenx relative">
<div
data-index={ts.index}
class="message rounded border bg-black p-2 text-xs font-semibold text-white"
>
<p>
<span class="text-{bar.cssClass}"></span>
{f(
new Date(bar.timestamp * 1000),
"hh:mm a",
selectedLang
)}
</p>
{#if bar.status != "NO_DATA"}
<p class="pl-2">
{l(lang, bar.status)}
</p>
{:else}
<p class="pl-2">-</p>
{/if}
</div>
</div>
{/each}
{/if}
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
<style>
.daygrid90 {
-ms-overflow-style: none; /* Internet Explorer 10+ */
scrollbar-width: none; /* Firefox */
}
.daygrid90::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}
.oneline {
transition: transform 0.1s ease-in;
cursor: pointer;
}
.oneline:hover {
transform: scaleY(1.2);
}
.oneline:hover + .show-hover {
display: block !important;
}
.show-hover {
display: none;
top: 40px;
padding: 0px;
text-align: left;
}
.today-sq + .hiddenx .message {
position: absolute;
white-space: nowrap;
}
.today-sq + .hiddenx {
visibility: hidden;
z-index: 30;
}
.today-sq:hover + .hiddenx {
visibility: visible;
}
.today-sq:hover {
/* transform: scale(1.1); */
box-shadow:
rgba(50, 50, 105, 0.15) 0px 2px 5px 0px,
rgba(0, 0, 0, 0.05) 0px 1px 1px 0px;
opacity: 0.75;
transition: all 0.1s ease-in;
cursor: pointer;
}
.today-sq {
position: relative;
z-index: 0;
}
</style>

Some files were not shown because too many files have changed in this diff Show More