Compare commits

..

257 Commits

Author SHA1 Message Date
Raj Nandan Sharma db6cb6cf7d refactor: improve SQLite migration for monitor_alerts_config to ensure monitor_tag is nullable 2026-03-28 11:07:13 +05:30
Raj Nandan Sharma babeeb75b2 chore: update v4.0.22 changelog to note migration fix for sqlite3 from v4.0.21 2026-03-28 10:36:30 +05:30
Raj Nandan Sharma e0187605e7 chore: update changelog for v4.0.22 and remove v4.0.21 entry 2026-03-28 10:34:43 +05:30
Raj Nandan Sharma e2861f1e59 refactor: rebuild SQLite monitor_alerts_config table to make monitor_tag nullable 2026-03-28 10:32:50 +05:30
Raj Nandan Sharma 01aa4d9984 refactor: modify SQLite monitor_alerts_config to allow nullable monitor_tag 2026-03-28 09:50:08 +05:30
github-actions[bot] 555372f175 chore(release): bump version to 4.0.21 2026-03-27 17:58:55 +00:00
Raj Nandan Sharma bd5fd409d1 chore: update documentation for v4.0.21 changelog and navigation 2026-03-27 22:27:24 +05:30
Raj Nandan Sharma f61d82e13f Merge pull request #688 from rajnandan1/implement/672
refactor: implement global maintenance notification settings and upda…
2026-03-27 21:28:02 +05:30
Raj Nandan Sharma f39588fff7 refactor: enhance maintenance notification settings validation and update event status logic 2026-03-27 21:02:51 +05:30
Raj Nandan Sharma 0716f271df refactor: implement global maintenance notification settings and update related event handling 2026-03-27 20:46:00 +05:30
Raj Nandan Sharma 1085c3e561 Merge pull request #679 from rajnandan1/release-4.0.21
refactor: support multi-monitor alerts by updating alert configuratio…
2026-03-25 16:44:43 +05:30
Raj Nandan Sharma 93907e6d96 refactor: improve error handling in tcpEval and evalResp validation 2026-03-25 16:40:44 +05:30
Raj Nandan Sharma d03fd63c64 refactor: support multi-monitor alerts by updating alert configuration and database schema
- Modify alertToVariables function to accept an optional monitorTag parameter for better flexibility in alert naming.
- Update sendAlertNotifications function to pass monitorTag to alertToVariables.
- Enhance addWorker function to handle multiple monitors by querying and creating alerts with the appropriate monitorTag.
- Introduce a new junction table for many-to-many relationships between monitor alerts and monitors.
- Update database migration to accommodate the new schema and backfill existing data.
- Revise frontend components to support multiple monitor selections in alert configurations.
- Enhance documentation to include analytics provider setup instructions.

implements #675
2026-03-25 16:29:20 +05:30
Raj Nandan Sharma 3ff43af787 Merge pull request #678 from LMtx/feature/vulnerability-fixes
security: fix critical and high severity vulnerabilities
2026-03-25 08:47:08 +05:30
Raj Nandan Sharma 7f2fef8e8e refactor: correct script host URL construction in Google Tag Manager snippet 2026-03-24 11:59:34 +05:30
LMtx 7658170865 security: fix critical and high severity vulnerabilities
- Upgrade svelte to ^5.53.5 (CVE-2025-46223 SSR XSS fixes)
- Upgrade @sveltejs/kit to ^2.53.3 (deserialization DoS fixes)
- Add npm overrides for transitive dependencies:
  - fast-xml-parser ^5.5.6 (CVE-2025-46223 XXE bypass)
  - rollup ^4.59.0 (CVE-2025-46717 path traversal)
  - undici ^7.24.0 (WebSocket DoS fixes)
  - minimatch ^10.2.3 (ReDoS fixes)
  - devalue ^5.6.4 (prototype pollution fix)
  - dompurify ^3.3.2 (mXSS fix)
  - cookie ^0.7.0 (injection fix)
  - mailparser ^3.9.3 (ReDoS fix)

All npm audit checks pass with 0 vulnerabilities.
2026-03-23 11:55:45 +01:00
Raj Nandan Sharma b1f8a505a5 Merge pull request #677 from rajnandan1/maintenance-prominient
refactor: implement upcoming maintenances feature in dashboard and mo…
2026-03-23 09:19:16 +05:30
Raj Nandan Sharma 104da58646 refactor: implement upcoming maintenances feature in dashboard and monitor views. implements #665 and #674 2026-03-23 09:18:45 +05:30
Raj Nandan Sharma ed6e97e8c9 refactor: update Google Tag Manager script source and enhance configuration with transport URL 2026-03-22 23:11:43 +05:30
Raj Nandan Sharma 0b25274874 refactor: enhance Google Tag Manager integration with transport URL and script host options 2026-03-22 20:31:34 +05:30
Raj Nandan Sharma bb8ab41bfe Merge pull request #673 from danynocz/update-czech-slovakia-march
Update Czech and Slovak translation
2026-03-20 22:16:01 +05:30
_DANYNO_ b43a5fb343 Update Slovak translations 2026-03-20 15:23:22 +01:00
_DANYNO_ 554caa5018 Update Czech translation 2026-03-20 15:19:55 +01:00
Raj Nandan Sharma ffe7403043 refactor: remove unused <svelte:head> elements and associated metadata from documentation layouts 2026-03-20 16:41:58 +05:30
Raj Nandan Sharma a26d0ece59 Merge pull request #671 from rajnandan1/fixcustom-js-css-guide-link
Fixcustom js css guide link
2026-03-20 15:37:50 +05:30
Raj Nandan Sharma a4277f7ed0 refactor: update custom fonts guide link to point to the correct URL 2026-03-20 15:36:50 +05:30
Raj Nandan Sharma f75aaf9cef refactor: update custom CSS guide link to direct to the correct URL 2026-03-20 15:36:04 +05:30
Raj Nandan Sharma 4e1ecf41ee Merge pull request #670 from rajnandan1/fix-i18n-667
refactor: update "Included Monitors" label to include count in variou…
2026-03-20 15:28:35 +05:30
Raj Nandan Sharma f23fb5313c refactor: update "Included Monitors" label to include count in various components and localization files, implements #667 2026-03-20 14:48:06 +05:30
github-actions[bot] 18489c5339 chore(release): bump version to 4.0.20 2026-03-19 17:08:48 +00:00
Raj Nandan Sharma 4044bae26d chore: include v4.0.20 changelog with new features and improvements 2026-03-19 21:19:29 +05:30
Raj Nandan Sharma 02686caa78 chore: update font import in docs.css and create comparison guide for Kener vs other status pages 2026-03-19 15:09:59 +05:30
Raj Nandan Sharma 797aef80d6 Update typography styles for improved consistency and adjust font import URL 2026-03-19 13:55:32 +05:30
Raj Nandan Sharma a8841ad8a3 Implement support for HEIC/HEIF image formats and increase body size limit to 3M 2026-03-19 12:28:58 +05:30
Raj Nandan Sharma 0b2cd5fc8a Merge pull request #664 from rajnandan1/seo-opti
implement site and page-level SEO meta tags with social preview image…
2026-03-19 10:22:38 +05:30
Raj Nandan Sharma 9d349716e5 Update typography to use Bodoni Moda font for improved aesthetics 2026-03-19 10:21:35 +05:30
Raj Nandan Sharma 92d068ef49 Update font family to Inria Serif for improved typography consistency 2026-03-19 10:16:30 +05:30
Raj Nandan Sharma c6e3620151 Refactor API eval function syntax and improve clarity in examples 2026-03-19 10:09:22 +05:30
Raj Nandan Sharma d92165d0f8 resolved conflict 2026-03-19 04:18:55 +00:00
Raj Nandan Sharma a56bbead8d Merge pull request #666 from rajnandan1/sitemap-1
implement sitemap configuration and generation functionality
2026-03-19 09:38:47 +05:30
Raj Nandan Sharma db3fb923f0 implement sitemap configuration and generation functionality 2026-03-19 09:36:54 +05:30
Raj Nandan Sharma bb9d88a095 Update .prettierignore to include src/lib/components/ui directory 2026-03-18 22:19:03 +05:30
Raj Nandan Sharma 912da6b8f4 Remove unused llms.txt and sitemap.xml server handlers to streamline codebase 2026-03-18 22:17:34 +05:30
Raj Nandan Sharma 3b07623346 Implement SEO enhancements across documentation and application pages, including Open Graph and Twitter meta tags, improve robots.txt for AI crawlers, and streamline code formatting for better readability. 2026-03-18 12:20:42 +05:30
Raj Nandan Sharma 702ceca9b0 Implement Open Graph and Twitter card images for documentation page 2026-03-18 11:55:45 +05:30
Raj Nandan Sharma 21f2433919 implement SEO meta tags and social preview images for page and site configurations 2026-03-18 11:52:19 +05:30
Raj Nandan Sharma 4e7791b104 update documentation for API development and project overview, enhancing clarity on architecture and environment variables 2026-03-18 11:11:39 +05:30
Raj Nandan Sharma f79c24c80d remove outdated architecture documentation for code-context, database migrations, and group monitor cache flow 2026-03-18 10:59:40 +05:30
Raj Nandan Sharma 087c2f25fb implement site and page-level SEO meta tags with social preview image support 2026-03-18 10:56:39 +05:30
github-actions[bot] 36f2ae1f69 chore(release): bump version to 4.0.18 2026-03-17 06:14:16 +00:00
Raj Nandan Sharma dcd830eb82 implement v4.0.18 changelog with new features, improvements, and bug fixes 2026-03-17 09:12:14 +05:30
Raj Nandan Sharma 80637fd4aa Merge pull request #663 from rajnandan1/implement/662
Implements #662
2026-03-17 07:35:13 +05:30
Raj Nandan Sharma a7f0072f32 implement monitoring data deletion with optional tag and date range 2026-03-16 23:05:46 +05:30
Raj Nandan Sharma a39e676a23 implement monitoring data deletion functionality with date range support 2026-03-16 22:50:40 +05:30
Raj Nandan Sharma fbc926036e Merge pull request #661 from rajnandan1/add-incident-delete
Add incident delete
2026-03-16 17:24:14 +05:30
Raj Nandan Sharma 5b508d19bc implement incident deletion functionality and associated database updates 2026-03-16 17:20:23 +05:30
Raj Nandan Sharma 589281ce13 implement incident deletion functionality and associated database updates 2026-03-16 17:19:59 +05:30
Raj Nandan Sharma 36aec8c519 Merge pull request #659 from rajnandan1/translations-status-add
add missing translations
2026-03-16 10:59:53 +05:30
Raj Nandan Sharma 978bfb05f2 Merge pull request #660 from rajnandan1/fix-timezone-sw
refactor: update timestamp handling to respect selected timezone
2026-03-16 10:59:32 +05:30
Raj Nandan Sharma 3ffd0538a1 refactor: improve timezone handling by including UTC in available timezones and adjusting timestamp conversion 2026-03-16 10:58:44 +05:30
Raj Nandan Sharma 932a05a9ee refactor: update timestamp handling to respect selected timezone 2026-03-16 10:55:24 +05:30
Raj Nandan Sharma 15c62fa40f add missing translations 2026-03-16 09:59:49 +05:30
github-actions[bot] a5afd38520 chore(release): bump version to 4.0.17 2026-03-15 13:27:35 +00:00
Raj Nandan Sharma 5138f7fb6e chore: update documentation for v4.0.17 release with new features and improvements 2026-03-15 18:32:01 +05:30
Raj Nandan Sharma 1bed0538db refactor: remove unused Breadcrumb import and enhance date/time format suggestions 2026-03-15 16:37:54 +05:30
Raj Nandan Sharma bd582eaad3 document: include "Deploy to Render" button in README and relevant documentation 2026-03-15 13:57:19 +05:30
Raj Nandan Sharma 5803ccadca refactor: remove dockerCommand and redefine ORIGIN env variable sourcing 2026-03-15 13:50:57 +05:30
Raj Nandan Sharma 4f16b06a0f chore: update dockerCommand in render.yaml and remove unused ORIGIN env variable 2026-03-15 13:44:09 +05:30
Raj Nandan Sharma fad23e2a01 Merge pull request #657 from rajnandan1/implement/issue-620
Implement/issue 620
2026-03-15 13:23:11 +05:30
Raj Nandan Sharma 4b84e47d89 create render.yaml for service and database configuration 2026-03-15 13:22:17 +05:30
Raj Nandan Sharma 9e020c10f4 document: include pre-built image instructions for deployment under /status base path 2026-03-15 12:56:50 +05:30
Raj Nandan Sharma 6fd47963ec refactor: improve formatting consistency in internationalization documentation 2026-03-15 12:39:16 +05:30
Raj Nandan Sharma df9b36b242 refactor: remove AllMaintenanceMonitorGrid component and update date formatting across various components to use dynamic date and time formats 2026-03-15 12:39:03 +05:30
Raj Nandan Sharma f9fe74ef94 refactor: streamline documentation layout and enhance styling 2026-03-14 10:08:14 +05:30
Raj Nandan Sharma 823ea6eeb7 Merge pull request #656 from rajnandan1/fix/group-timeout
Fix/group timeout
2026-03-13 19:21:00 +05:30
Raj Nandan Sharma 6c7606d3b0 fix group call through APIS 2026-03-13 17:24:26 +05:30
Raj Nandan Sharma d2e9437cfa fix group call through APIS 2026-03-13 17:23:59 +05:30
github-actions[bot] a549eb5d1b chore(release): bump version to 4.0.16 2026-03-13 07:59:48 +00:00
Raj Nandan Sharma 648f8180e7 feat: include changelog for version 4.0.16 with new features, improvements, and bug fixes 2026-03-13 11:29:18 +05:30
Raj Nandan Sharma 9afb8947ac feat: implement gRPC monitor functionality and update related documentation, implements #505 2026-03-13 10:29:05 +05:30
Raj Nandan Sharma 0c8338e2a5 Merge pull request #655 from rajnandan1/order-m-pages
feat: implement position management for page monitors and update rela…
2026-03-13 09:42:40 +05:30
Raj Nandan Sharma df94755c6b feat: implement position management for page monitors and update related functionality 2026-03-13 09:40:54 +05:30
Raj Nandan Sharma 204419fccb Merge pull request #654 from TobiX/fix-json-api
fix(api): Stringify existing objects before inserting into database
2026-03-13 09:02:08 +05:30
Tobias Gruetzmacher a973494bc8 fix(api): Stringify existing objects before inserting into database 2026-03-12 22:33:33 +01:00
Raj Nandan Sharma ba459e61ad Merge pull request #653 from rajnandan1/fix/csrf-origin
Fix/csrf origin
2026-03-12 23:06:22 +05:30
Raj Nandan Sharma f92fc8ff73 refactor: streamline CSRF handler method checks for request types 2026-03-12 23:03:14 +05:30
Raj Nandan Sharma 91cb4850ca validate request origin in CSRF handler to prevent null origins 2026-03-12 23:03:02 +05:30
Raj Nandan Sharma fb7939a4dc implement CSRF protection with origin validation in API routes, fixes #570 2026-03-12 22:54:39 +05:30
Raj Nandan Sharma 1f352591a4 chore: update README to include DeepWiki badge 2026-03-12 20:22:45 +05:30
Raj Nandan Sharma 0085621900 Merge pull request #648 from pan93412/locales/zh-tw
feat(locales): add zh-TW translation
2026-03-12 14:50:42 +05:30
Raj Nandan Sharma 0eb789d89a Merge pull request #652 from rajnandan1/zeabur
chore: integrate Zeabur deployment options in documentation and templ…
2026-03-12 14:49:35 +05:30
Raj Nandan Sharma 7f21b27bb8 chore: integrate Zeabur deployment options in documentation and templates 2026-03-12 14:39:45 +05:30
github-actions[bot] 7abd6788de chore(release): bump version to 4.0.15 2026-03-12 09:00:01 +00:00
Raj Nandan Sharma 681b116e09 bug fix 2026-03-12 14:03:19 +05:30
github-actions[bot] 04b1d0716b chore(release): bump version to 4.0.14 2026-03-12 05:28:52 +00:00
Raj Nandan Sharma 668f12a0eb Merge pull request #646 from rajnandan1/release/4.0.14.1
chore(locales): Update translations for various languages to include …
2026-03-12 09:49:18 +05:30
Raj Nandan Sharma 394149a3d6 chore: update changelog for v4.0.14 with scoring model changes, new features, bug fixes, and improvements 2026-03-12 09:47:45 +05:30
Raj Nandan Sharma 57c8ff28ab chore: update user-agent header to include dynamic version from version.js 2026-03-12 09:40:34 +05:30
Raj Nandan Sharma 5b29af9e74 chore: enhance webhook processing by including webhookURL in environment secrets and update alert incident URL formatting in template. fixes #647 2026-03-12 09:36:57 +05:30
Raj Nandan Sharma 9f5b90cb93 added svg logo 2026-03-12 09:06:10 +05:30
Yi-Jyun Pan e4f001acf7 feat(locales): add zh-TW translation 2026-03-12 11:18:53 +08:00
Raj Nandan Sharma 604dbccaea chore: refactor button and input components for better readability 2026-03-11 23:01:16 +05:30
Raj Nandan Sharma 6b7d0335b7 chore: implement search functionality and filter options for monitors 2026-03-11 23:00:04 +05:30
Raj Nandan Sharma b55dd29f02 chore: update notification duration display to include ongoing status 2026-03-11 22:32:00 +05:30
Raj Nandan Sharma dcd7918a08 chore(locales): Update translations for various languages to include new status terms
feat(dashboard): Refactor notification payload to include event start and end times

refactor(controllers): Modify group call scoring model to use normalized scores for status

docs(changelog): Document changes in group monitor scoring model in v4.0.14 release notes

feat(badges): Implement locale selection for status badges in management interface

refactor(i18n): Create server-side i18n helper for translating strings outside of Svelte components
2026-03-11 11:45:59 +05:30
Raj Nandan Sharma 38e3a538ef chore: enhance page navigation and update deployment documentation 2026-03-10 11:58:18 +05:30
Raj Nandan Sharma 19c2275a7b chore: remove unused dependencies and update documentation for showcase 2026-03-10 10:00:22 +05:30
github-actions[bot] a53251454e chore(release): bump version to 4.0.13 2026-03-09 17:32:07 +00:00
Raj Nandan Sharma 8f39a0f649 Merge pull request #639 from otherwiseGG/main
Fix German Translation in de.json
2026-03-09 22:23:37 +05:30
Benjamin Thiele 674c310b67 Merge branch 'main' into main 2026-03-09 07:04:59 +01:00
Raj Nandan Sharma a550afca60 Merge pull request #644 from rajnandan1/fixes/4.0.13
Fixes/4.0.13
2026-03-09 11:22:22 +05:30
Raj Nandan Sharma e64d955ecb Translate status and incident labels to Hindi in locale files and update MaintenanceItem component for proper translation usage 2026-03-09 11:21:18 +05:30
Raj Nandan Sharma 57fda67ed7 Updated Translations 2026-03-09 11:16:59 +05:30
Raj Nandan Sharma 6d5a42aeb2 Implement cache deletion functionality and update documentation 2026-03-09 10:40:27 +05:30
Benjamin Thiele 5d9160c36d Fix Misspelled or incorrect German in de.json 2026-03-05 15:36:17 +01:00
github-actions[bot] b465f48c6e chore(release): bump version to 4.0.12 2026-03-05 11:20:13 +00:00
Raj Nandan Sharma 8c16d99f55 Merge pull request #632 from danynocz/czech-slovakia-update
Add missing Czech and Slovak translations and fix minor typos in cs.json and sk.json
2026-03-05 16:48:37 +05:30
Raj Nandan Sharma fdedbb78cf Merge pull request #638 from rajnandan1/fix/issue-636
Update default value initialization and button label for DNS monitor
2026-03-05 16:47:51 +05:30
Raj Nandan Sharma 416beca1a6 Update default value initialization and button label for DNS monitor 2026-03-05 16:45:03 +05:30
_DANYNO_ e261d3620e Update Slovak translations in sk.json 2026-03-03 11:52:28 +01:00
_DANYNO_ 1089133b97 Update Czech translations for COMPLETED and SCHEDULED 2026-03-03 11:44:47 +01:00
_DANYNO_ 90de4ae4cb Update Czech translations in cs.json 2026-03-03 11:42:03 +01:00
_DANYNO_ 4e263284c8 Add missing Slovak translations and fix minor typos in sk.json 2026-02-28 14:59:02 +01:00
_DANYNO_ 343368631f Add missing Czech translations and fix minor typos in cs.json 2026-02-28 14:56:11 +01:00
Raj Nandan Sharma f5f40e1438 Merge pull request #631 from danynocz/czech-slovakia-update
Update Czech and Slovak Language
2026-02-28 10:13:51 +05:30
_DANYNO_ 74f6601f5d Update Slovak Language 2026-02-27 22:02:21 +01:00
_DANYNO_ c4e03c1a83 Update Czech Language 2026-02-27 21:59:54 +01:00
Raj Nandan Sharma bb9124db0d Merge pull request #630 from danynocz/add-translation-labels 2026-02-28 00:35:10 +05:30
_DANYNO_ ce52506c95 Translate labels to use $t function 2026-02-27 18:47:54 +01:00
github-actions[bot] f20b74cb36 chore(release): bump version to 4.0.11 2026-02-26 18:43:37 +00:00
Raj Nandan Sharma f3a5839aae Merge pull request #627 from rajnandan1/features-n-fixes/4.0.11
Features n fixes/4.0.11
2026-02-26 23:08:52 +05:30
Raj Nandan Sharma 0896aed91e refactor: Update ManualUpdateUserData to include user ID and validate roles 2026-02-26 22:14:58 +05:30
Raj Nandan Sharma d8ae54da53 refactor: Implement email and name normalization and validation in user creation process 2026-02-26 21:57:22 +05:30
Raj Nandan Sharma 66bf012c87 refactor: Enhance user management with is_owner attribute and update permissions logic, implements #624 2026-02-26 21:35:56 +05:30
Raj Nandan Sharma 08fc54d2ea refactor: Sort page monitors by creation date in getPageMonitors methods. implements #625 2026-02-26 20:41:55 +05:30
Raj Nandan Sharma b36e68f7c8 refactor: Update redirect logic in signin load function and change sidebar link to site configurations, fixes #623 2026-02-26 20:16:54 +05:30
github-actions[bot] 045f32b7ce chore(release): bump version to 4.0.10 2026-02-26 11:25:52 +00:00
Raj Nandan Sharma 7ae869d98a Merge pull request #619 from rajnandan1/fix/v-4010
refactor: Improve environment variable handling and documentation for…
2026-02-26 16:55:12 +05:30
Raj Nandan Sharma efb04a4238 refactor: Improve environment variable handling and documentation for SMTP and Redis configurations. Fixes: #616, #617, #615, #614, #612 2026-02-26 16:40:50 +05:30
github-actions[bot] e6e586f6de chore(release): bump version to 4.0.9 2026-02-26 06:36:24 +00:00
Raj Nandan Sharma e13d7fb1f4 refactor: Implement monitor tag validation for URL-friendliness in monitor creation and update 2026-02-26 12:02:39 +05:30
Raj Nandan Sharma 23b0bae018 Merge pull request #611 from rajnandan1/feature/pages-ordering
Feature/pages ordering
2026-02-26 10:49:02 +05:30
Raj Nandan Sharma e581346f84 refactor: Enhance documentation for page ordering functionality in customizations 2026-02-26 10:45:44 +05:30
Raj Nandan Sharma 555fd3a8a2 Refactor: Implement monitor reordering functionality with up and down buttons 2026-02-26 10:42:59 +05:30
Raj Nandan Sharma 7a29d2f2ca refactor: Implement page ordering functionality and enhance page fetching logic 2026-02-26 09:45:34 +05:30
Raj Nandan Sharma 7a3fe20083 refactor: Simplify specification path determination in GET handler 2026-02-25 23:24:13 +05:30
Raj Nandan Sharma c16c119d65 Merge pull request #608 from rajnandan1/fix/mysql-indexname-long
refactor: Update site configuration documentation and enhance global …
2026-02-25 22:54:37 +05:30
Raj Nandan Sharma 048a12899d refactor: Enhance documentation guidelines for architecture knowledge preservation 2026-02-25 22:41:06 +05:30
Raj Nandan Sharma 5fd0e691b3 refactor: Improve down migration logic for global column removal 2026-02-25 22:37:37 +05:30
Raj Nandan Sharma 28932f8df5 refactor: Update event page navigation to use dynamic page path 2026-02-25 22:33:53 +05:30
Raj Nandan Sharma 4da29ac4e1 Merge branch 'main' into fix/mysql-indexname-long 2026-02-25 19:36:31 +05:30
Raj Nandan Sharma 396fc5e3c3 refactor: Update site configuration documentation and enhance global page visibility settings
- Revise site configuration documentation to clarify page visibility behavior.
- Introduce global page visibility settings with detailed descriptions and functionality.
- Modify API server to dynamically select the correct specification path based on environment.
- Streamline event fetching logic in event pages to improve performance and maintainability.
- Remove unused vault secret management code from the manage API.
- Enhance customizations page to support global page visibility settings.
- Create new guides for adding custom fonts and custom JavaScript/CSS.
- Implement server-side logic for handling events by month with improved date validation.
2026-02-25 19:33:34 +05:30
Raj Nandan Sharma af8daf98f6 Merge pull request #602 from theoludwig/docs/typo-github
docs: typo Github => GitHub
2026-02-25 15:47:56 +05:30
Raj Nandan Sharma 2e91f90057 Merge pull request #603 from theoludwig/docs/link-404-migration-to-v4
docs: fix link to migration guide v3 => v4
2026-02-25 15:47:17 +05:30
Théo LUDWIG 3213ab8efa docs: fix link to migration guide v3 => v4 2026-02-25 10:22:36 +01:00
Théo LUDWIG f2308d9bd1 docs: typo Github => GitHub 2026-02-25 10:21:01 +01:00
github-actions[bot] 623465ff50 chore(release): bump version to 4.0.8 2026-02-24 19:17:23 +00:00
Raj Nandan Sharma 7c224b3886 refactor: restructure monitor_alerts_v2 table creation and enforce foreign key constraints 2026-02-25 00:46:30 +05:30
github-actions[bot] 166073fbd4 chore(release): bump version to 4.0.7 2026-02-24 18:47:54 +00:00
Raj Nandan Sharma 373cbd5917 refactor: update notification and incident handling to include site URL in context 2026-02-25 00:17:12 +05:30
github-actions[bot] 2c27859197 chore(release): bump version to 4.0.6 2026-02-24 18:35:29 +00:00
Raj Nandan Sharma bb5d58b675 Merge pull request #601 from rajnandan1/fix/privilage-1
ensure inactive users cannot log in and restrict actions for non-admi…
2026-02-25 00:03:08 +05:30
Raj Nandan Sharma caca29d354 ensure inactive users cannot log in and restrict actions for non-admin roles. Fixes #600 2026-02-25 00:01:49 +05:30
github-actions[bot] 35f0adb235 chore(release): bump version to 4.0.5 2026-02-24 18:18:40 +00:00
Raj Nandan Sharma 8bcff45d87 fix: #599 migration db 2026-02-24 23:46:15 +05:30
github-actions[bot] 3ad256a626 chore(release): bump version to 4.0.4 2026-02-24 17:27:46 +00:00
Raj Nandan Sharma c4b9181a0a refactor: update migration scripts to rename .js entries to .ts in knex_migrations table 2026-02-24 22:55:19 +05:30
Raj Nandan Sharma 1ba40fadf6 refactor: remove upcoming version details and clean up README content 2026-02-24 21:23:15 +05:30
Raj Nandan Sharma 20f1481a01 refactor: include Mattermost Webhook Trigger guide in documentation 2026-02-24 20:27:13 +05:30
Raj Nandan Sharma 828747876b refactor: update v4.0.0 changelog to include categories and monitor ordering management through pages 2026-02-24 20:22:19 +05:30
Raj Nandan Sharma cbe0ea683f refactor: update SKILL.md for code-architecture and tailwindcss; enhance button component in docs page; implement API spec retrieval in v4 server 2026-02-24 19:54:03 +05:30
Raj Nandan Sharma 561864c625 Fix Railway deployment link in README
Updated the Railway deployment link in the README.
2026-02-24 18:51:35 +05:30
github-actions[bot] 3abdb5c17a chore(release): bump version to 4.0.3 2026-02-24 12:54:46 +00:00
Raj Nandan Sharma f2ef19e3d8 fix release workflow 2026-02-24 18:24:06 +05:30
github-actions[bot] 78b18a59ec chore(release): bump version to 4.0.2 2026-02-24 12:27:09 +00:00
Raj Nandan Sharma c28581b51d removed docs from main build 2026-02-24 17:56:10 +05:30
github-actions[bot] f9486927de chore(release): bump version to 4.0.1 2026-02-24 12:09:00 +00:00
Raj Nandan Sharma 86645d9ea3 Merge pull request #597 from rajnandan1/chore/docs-and-locales
added llms.txt and fixed locales
2026-02-24 17:38:19 +05:30
Raj Nandan Sharma feea1d76cd added llms.txt and fixed locales 2026-02-24 17:37:55 +05:30
Raj Nandan Sharma 2e95f31d93 Merge pull request #596 from rajnandan1/chore/docs-and-locales
added llms.txt and fixed locales
2026-02-24 17:35:49 +05:30
Raj Nandan Sharma 18cf8f51b7 added llms.txt and fixed locales 2026-02-24 17:33:22 +05:30
Raj Nandan Sharma 9af842ebc0 fix docker file 2026-02-24 13:30:30 +05:30
Raj Nandan Sharma 3d157f1c20 Merge pull request #595 from rajnandan1/chore/docs-4
docs: Include changelog for v4.0.0 release with breaking changes and …
2026-02-24 11:55:47 +05:30
Raj Nandan Sharma b970c5bba1 chore: Enhance Docker and documentation for subpath deployment support 2026-02-24 11:20:49 +05:30
Raj Nandan Sharma 7e7c0eb429 chore: Update documentation layout and enhance API reference with new metadata and server URL variable 2026-02-24 10:07:12 +05:30
Raj Nandan Sharma f0ab8f25e7 docs: Include changelog for v4.0.0 release with breaking changes and new features 2026-02-23 23:14:43 +05:30
Raj Nandan Sharma 87896ce2a9 docs: Update links in README for Quick Start and Documentation sections 2026-02-23 12:33:21 +05:30
Raj Nandan Sharma fae9f38d90 style: Adjust margin for notification button in NotificationsPopover component 2026-02-23 12:13:14 +05:30
Raj Nandan Sharma 1df62bc315 Merge pull request #588 from rajnandan1/next/4-pre-alpha
Next/4 pre alpha
2026-02-23 11:25:32 +05:30
Raj Nandan Sharma 9199b807d5 chore: Update environment variables and documentation for ORIGIN requirement; refactor Docker setup for documentation indexing 2026-02-23 11:15:19 +05:30
Raj Nandan Sharma 3363e90007 chore(locales): Update translations for multiple languages and add new status messages
feat(links): Correct URL formatting in invitation and verification email links
refactor(notification): Simplify notification utility imports
chore(docs): Update Discord link and API reference URLs in documentation
style(buttons): Change button variant for better UI consistency
chore(scripts): Implement script to sort translation keys in locale files
2026-02-23 09:57:22 +05:30
Raj Nandan Sharma 78bad84f0e refactor: update workflow to publish nightly Docker images with improved tagging and concurrency 2026-02-22 23:00:00 +05:30
Raj Nandan Sharma 50482aa21d changes 2026-02-22 22:55:54 +05:30
Raj Nandan Sharma 339d653bab delete static files 2026-02-22 20:24:01 +05:30
Raj Nandan Sharma 585db91263 refactor: enhance layout and site data handling with social preview image and custom CSS support 2026-02-22 19:53:19 +05:30
Raj Nandan Sharma cbc48d2ddc docs updates 2026-02-22 15:55:49 +05:30
Raj Nandan Sharma 1c6bed1297 changes 2026-02-21 20:11:44 +05:30
Raj Nandan Sharma bf126e7151 refactor: implement event display settings for incidents and maintenances in dashboard 2026-02-21 19:56:32 +05:30
Raj Nandan Sharma e5e244e2dc refactor: enhance MonitorBar component with layout options and improve dashboard data handling 2026-02-21 15:40:28 +05:30
Raj Nandan Sharma 4b23e729c1 changes 2026-02-20 22:57:34 +05:30
Raj Nandan Sharma c9c675da28 changes 2026-02-20 18:40:28 +05:30
Raj Nandan Sharma 1c485ac376 go live 2026-02-20 11:34:17 +05:30
Raj Nandan Sharma 9a8bf8431b changes 2026-02-17 18:19:20 +05:30
Raj Nandan Sharma 3aedb9be11 refactor: restructure authentication flow and remove obsolete API endpoints 2026-02-17 09:09:48 +05:30
Raj Nandan Sharma 58f0e9f98a refactor: remove copy current page link option and implement sharing options for monitors 2026-02-17 08:21:11 +05:30
Raj Nandan Sharma 88072b7de7 changes 2026-02-16 22:40:46 +05:30
Raj Nandan Sharma 9129e9606c changes 2026-02-16 18:26:42 +05:30
Raj Nandan Sharma b088a79143 refactor: update logging in analyticsEvent function and improve latency display in embed page 2026-02-16 11:51:32 +05:30
Raj Nandan Sharma 14d46cb3de Merge main into next/4-pre-alpha (keep overhaul changes) 2026-02-16 11:16:41 +05:30
Raj Nandan Sharma 5989e5b8cb refactor: improve error handling and logging in GamedigCall, SqlCall, and SSLCall classes; update documentation links and restructure introduction content 2026-02-16 10:22:26 +05:30
Raj Nandan Sharma 7b3afa4467 so many 2026-02-16 08:30:58 +05:30
Raj Nandan Sharma d758de0752 refactor: enhance image upload ID generation with file extension 2026-02-15 13:40:06 +05:30
Raj Nandan Sharma e6424b1d66 refactor: integrate @humanspeak/svelte-purify for enhanced HTML sanitization and update global constants for upload limits 2026-02-15 13:08:26 +05:30
Raj Nandan Sharma 5a95c81574 refactor: restrict role updates in user data modification 2026-02-14 23:11:30 +05:30
Raj Nandan Sharma d170ba69b7 refactor: enhance documentation and improve page titles with site name 2026-02-14 20:13:10 +05:30
Raj Nandan Sharma d04ee0a933 changes 2026-02-14 19:08:24 +05:30
Raj Nandan Sharma 444fe5890a ui revamp 2026-02-13 22:23:07 +05:30
Raj Nandan Sharma d1eb9266ea changes 2026-02-13 17:45:59 +05:30
Raj Nandan Sharma 275c9d7ff4 changes 2026-02-13 16:48:47 +05:30
Raj Nandan Sharma 552bdc09ad refactor: enhance ping and tcp monitoring logic, improve error handling, and introduce shared types 2026-02-13 12:02:13 +05:30
Raj Nandan Sharma 09afbbd17d refactor: optimize npm installation step by removing cache mount 2026-02-12 23:58:48 +05:30
Raj Nandan Sharma 6ea045d152 refactor: implement site announcement feature with management UI and display component 2026-02-12 21:59:41 +05:30
Raj Nandan Sharma 8fd8aa4bcf refactor: implement CloneMonitor functionality and enhance monitor loading with status filtering 2026-02-12 20:11:18 +05:30
Raj Nandan Sharma 366c2c0a22 refactor: update heartbeat handling and monitoring logic, enhance UI components, and improve documentation 2026-02-12 16:19:00 +05:30
Raj Nandan Sharma 21f06a4143 hb 2026-02-11 23:04:28 +05:30
Raj Crustdata d99da146bd sas 2026-02-11 19:33:42 +05:30
Raj Nandan Sharma 290113d553 refactor: update layout and styling for MinuteGrid and MonitorDayDetail components 2026-02-11 13:44:08 +05:30
Raj Nandan Sharma 1f370b3eea changes 2026-02-11 13:07:52 +05:30
Raj Nandan Sharma 17ed56c682 changes 2026-02-10 23:17:16 +05:30
Raj Nandan Sharma 2377a4faa6 changes 2026-02-10 18:17:52 +05:30
Raj Nandan Sharma 32081056c7 track: implement event tracking for user interactions across multiple components 2026-02-10 11:43:24 +05:30
Raj Nandan Sharma 5f86ca27e1 analystic 101 2026-02-10 11:09:29 +05:30
Raj Nandan Sharma c58530cda3 cleanup 1 2026-02-10 08:51:49 +05:30
Raj Nandan Sharma 9b99bec50b Create Dockerfile and Docker Compose configurations for Kener v4 application
- Implement multi-stage Dockerfile for building and running the Kener application with support for Alpine and Debian variants.
- Establish development and production Docker Compose files for local testing and deployment.
- Configure Redis service for caching and job scheduling.
- Set up environment variables for application configuration, including secret keys and database connections.
- Define health checks for Redis service to ensure reliability.
2026-02-10 08:36:28 +05:30
Raj Nandan Sharma 8b189b686e Merge pull request #556 from danynocz/main
Add Slovak language support
2026-02-10 08:17:49 +05:30
Raj Nandan Sharma 430733bd41 Merge pull request #576 from vyPal/feature/umami-custom-src
Umami custom script source
2026-02-10 08:17:25 +05:30
vyPal 6699887a75 Add support for custom umami script source URLs 2026-02-09 21:21:45 +01:00
Raj Nandan Sharma 8a498e902d refactor: update tooltip styles and improve component structure across multiple files 2026-02-09 22:30:11 +05:30
Raj Nandan Sharma eed71d3633 added group 2026-02-09 17:35:18 +05:30
Raj Nandan Sharma 228808a267 stable 2026-02-09 14:43:35 +05:30
Raj Nandan Sharma ee1adf8330 vv 2026-02-09 12:08:12 +05:30
Raj Nandan Sharma 9e8b0962a0 changes 2026-02-09 08:26:16 +05:30
Raj Nandan Sharma 72a28cac61 changes 2026-02-08 19:18:49 +05:30
Raj Nandan Sharma c759ade7b9 file clean up 2026-02-08 19:17:02 +05:30
Raj Nandan Sharma 7392b968ef cvcc 2026-02-08 14:34:53 +05:30
Raj Nandan Sharma 7f328023ea Merge pull request #575 from rajnandan1/claude/create-documentation-skill-file 2026-02-07 22:51:21 +05:30
_DANYNO_ 2749b8017c Add Slovak language support 2026-01-30 17:01:41 +01:00
_DANYNO_ c4aff9f81b Add Slovak language support 2026-01-30 17:00:12 +01:00
_DANYNO_ c4739e0257 Add Slovak language support 2026-01-30 16:59:08 +01:00
Raj Nandan Sharma 9c8d4bcb89 Merge pull request #551 from danynocz/main
Update Czech translation
2026-01-30 10:56:06 +05:30
_DANYNO_ d300f3cdae Update Czech translation 2026-01-29 15:19:48 +01:00
_DANYNO_ b14a5c9f5f Update Czech translation 2026-01-29 15:18:05 +01:00
Raj Nandan Sharma 3936edb017 Merge pull request #550 from DANYNOCZ/main
Add Czech language support
2026-01-29 09:07:03 +05:30
_DANYNO_ 71a9157abf Add Czech language support 2026-01-28 22:43:40 +01:00
_DANYNO_ 3b571f404c Add Czech language support 2026-01-28 22:41:04 +01:00
_DANYNO_ 6ff6e595a6 Add Czech language support 2026-01-28 22:40:04 +01:00
Raj Nandan Sharma bc7c23c1f0 Update issue templates 2026-01-23 14:44:41 +05:30
642 changed files with 42154 additions and 25629 deletions
Vendored
BIN
View File
Binary file not shown.
+123
View File
@@ -0,0 +1,123 @@
---
name: ss-shadcn-svelte
description: >
Use shadcn-svelte components in SvelteKit projects. Detects whether the current project is a SvelteKit
app with shadcn-svelte installed, lists available components, and provides access to full component
documentation via the official llms.txt. Helps choose the right UI components for the job — buttons,
forms, dialogs, tables, charts, and more — following shadcn-svelte best practices.
Use this skill whenever the user is working in a SvelteKit project and wants to: add UI components,
build forms, create dialogs or modals, add a data table, use a date picker, build a sidebar or
navigation, add charts, use a combobox or select, create an alert or toast notification, or generally
build UI with pre-built accessible components. Also trigger when the user mentions "shadcn", "shadcn-svelte",
"bits-ui", or asks about available components in their Svelte project.
---
# shadcn-svelte — Component-Aware Svelte UI Assistant
Use the right shadcn-svelte components when building UI in SvelteKit projects. This skill detects your project setup, shows what's available, and gives you access to full component documentation.
## Prerequisites
The project must be a SvelteKit app with shadcn-svelte initialized:
```bash
# Initialize shadcn-svelte in an existing SvelteKit project
npx shadcn-svelte@latest init
```
## How to use
### Step 1: Detect project setup
Run the detection script to verify this is a SvelteKit project with shadcn-svelte and see which components are already installed:
```bash
bash <skill-path>/scripts/detect.sh .
```
This will:
- Confirm it's a SvelteKit project (checks for `svelte.config.js/ts` and `@sveltejs/kit` in package.json)
- Confirm shadcn-svelte is installed (checks for `components.json`, `bits-ui`, or `shadcn-svelte` in package.json)
- List all currently installed components in the project's UI directory
- Provide the documentation URL
If the script exits with code 1, the project either isn't SvelteKit or doesn't have shadcn-svelte — do not proceed with shadcn-svelte components in that case.
### Step 2: Read the component documentation
The full component documentation for LLMs is available at:
```
https://www.shadcn-svelte.com/llms.txt
```
Fetch this URL to get a structured index of all available components organized by category, with links to individual component documentation pages (in `.md` format).
When you need to use a specific component, read its individual documentation page from the links provided in `llms.txt`. Each component doc includes:
- Import statements and usage examples
- Available props, events, and slots
- Variants and configuration options
- Accessibility information
### Step 3: Use the right component for the job
When building UI, follow this decision process:
1. **Run detection** to confirm shadcn-svelte is available and see installed components
2. **Fetch llms.txt** to see all available components
3. **Read the specific component docs** for the components you plan to use
4. **Check if the component is installed** — if not, add it:
```bash
npx shadcn-svelte@latest add <component-name>
```
5. **Import and use the component** following the documentation patterns
### Component categories
shadcn-svelte components are organized into these categories:
| Category | Components |
|----------|-----------|
| **Layout** | Aspect Ratio, Collapsible, Resizable, Scroll Area, Separator, Sidebar |
| **Form & Input** | Button, Calendar, Checkbox, Combobox, Date Picker, Input, Input OTP, Label, Radio Group, Range Calendar, Select, Slider, Switch, Textarea, Toggle, Toggle Group |
| **Data Display** | Accordion, Avatar, Badge, Card, Carousel, Chart, Table, Data Table |
| **Feedback** | Alert, Alert Dialog, Progress, Skeleton, Sonner (Toast) |
| **Overlay** | Context Menu, Dialog, Drawer, Dropdown Menu, Hover Card, Menubar, Popover, Sheet, Tooltip |
| **Navigation** | Breadcrumb, Command, Pagination, Tabs |
| **Typography** | Typography |
### Adding new components
```bash
# Add a single component
npx shadcn-svelte@latest add button
# Add multiple components
npx shadcn-svelte@latest add button card dialog
# List all available components
npx shadcn-svelte@latest add
```
### Import patterns
Components are typically imported from the project's `$lib/components/ui` directory:
```svelte
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import * as Dialog from "$lib/components/ui/dialog";
</script>
```
Some components use namespace imports (with `* as`) when they have multiple sub-components (Card, Dialog, Sheet, Table, etc.), while simpler components use named imports (Button, Input, Badge, etc.).
## Important guidelines
- **Always run detection first** before suggesting shadcn-svelte components
- **Always read component docs** before using a component — don't guess at props or patterns
- **Check installed components** and add missing ones before importing
- **Use the project's configured path** — the components directory may vary based on `components.json` configuration
- **Follow Svelte 5 patterns** — shadcn-svelte uses runes (`$state`, `$derived`, `$effect`) and snippet-based composition
- **Prefer composition** — shadcn-svelte components are designed to be composed together, not used as monolithic blocks
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
set -euo pipefail
# detect.sh — Check if the current project is a SvelteKit project with shadcn-svelte installed.
# Exits 0 and prints component info if detected, exits 1 otherwise.
PROJECT_DIR="${1:-.}"
# Resolve to absolute path
PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)"
# --- Step 1: Check for SvelteKit ---
SVELTEKIT=false
# Check for svelte.config.js or svelte.config.ts
if [[ -f "$PROJECT_DIR/svelte.config.js" ]] || [[ -f "$PROJECT_DIR/svelte.config.ts" ]]; then
SVELTEKIT=true
fi
# Also verify package.json has @sveltejs/kit
if [[ -f "$PROJECT_DIR/package.json" ]]; then
if grep -q '"@sveltejs/kit"' "$PROJECT_DIR/package.json" 2>/dev/null; then
SVELTEKIT=true
fi
fi
if [[ "$SVELTEKIT" != "true" ]]; then
echo "NOT_SVELTEKIT"
echo "This is not a SvelteKit project. No svelte.config.js/ts found and @sveltejs/kit is not in package.json."
exit 1
fi
# --- Step 2: Check for shadcn-svelte ---
SHADCN=false
# Check for components.json (shadcn-svelte config file)
if [[ -f "$PROJECT_DIR/components.json" ]]; then
# Verify it's actually a shadcn config (has $schema or style field)
if grep -qE '"(\$schema|style)"' "$PROJECT_DIR/components.json" 2>/dev/null; then
SHADCN=true
fi
fi
# Check for bits-ui in package.json (core dependency of shadcn-svelte)
if [[ -f "$PROJECT_DIR/package.json" ]]; then
if grep -q '"bits-ui"' "$PROJECT_DIR/package.json" 2>/dev/null; then
SHADCN=true
fi
fi
# Check for shadcn-svelte in package.json
if [[ -f "$PROJECT_DIR/package.json" ]]; then
if grep -q '"shadcn-svelte"' "$PROJECT_DIR/package.json" 2>/dev/null; then
SHADCN=true
fi
fi
if [[ "$SHADCN" != "true" ]]; then
echo "NO_SHADCN_SVELTE"
echo "SvelteKit project detected, but shadcn-svelte is not installed."
echo "Install it with: npx shadcn-svelte@latest init"
exit 1
fi
# --- Step 3: Gather installed components ---
echo "DETECTED"
echo "SvelteKit project with shadcn-svelte detected."
echo ""
# Check which components are already installed by scanning the components directory
COMPONENTS_DIR=""
# Try to read the components alias from components.json
if [[ -f "$PROJECT_DIR/components.json" ]]; then
# Extract the aliases.components path
ALIAS_PATH=$(grep -o '"components"[[:space:]]*:[[:space:]]*"[^"]*"' "$PROJECT_DIR/components.json" | head -1 | sed 's/.*"components"[[:space:]]*:[[:space:]]*"//' | sed 's/"//')
if [[ -n "$ALIAS_PATH" ]]; then
# Resolve $lib to src/lib
RESOLVED_PATH="${ALIAS_PATH//\$lib/src/lib}"
if [[ -d "$PROJECT_DIR/$RESOLVED_PATH/ui" ]]; then
COMPONENTS_DIR="$PROJECT_DIR/$RESOLVED_PATH/ui"
fi
fi
fi
# Fallback: check common locations
if [[ -z "$COMPONENTS_DIR" ]]; then
for dir in "src/lib/components/ui" "src/lib/ui" "src/components/ui"; do
if [[ -d "$PROJECT_DIR/$dir" ]]; then
COMPONENTS_DIR="$PROJECT_DIR/$dir"
break
fi
done
fi
if [[ -n "$COMPONENTS_DIR" ]] && [[ -d "$COMPONENTS_DIR" ]]; then
echo "Installed components (in $COMPONENTS_DIR):"
for comp_dir in "$COMPONENTS_DIR"/*/; do
if [[ -d "$comp_dir" ]]; then
comp_name=$(basename "$comp_dir")
echo " - $comp_name"
fi
done
echo ""
fi
echo "Documentation: https://www.shadcn-svelte.com/llms.txt"
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}
+54 -354
View File
@@ -5,382 +5,82 @@ description: Specialized skill for creating and editing high-quality Kener docum
# Documentation Writer
## Overview
Use this skill for all docs edits in `src/routes/(docs)/docs/content/` and when updating docs navigation in `src/routes/(docs)/docs.json`.
This skill provides guidelines and best practices for creating high-quality, easy-to-follow documentation for Kener. Use this skill when creating new documentation or editing existing documentation files.
## Non-negotiable rules
## Documentation Structure
1. **Be concise**: remove repetition and background that does not help the user complete a task.
2. **Be actionable**: prioritize “what to do” over theory.
3. **One source of truth**: if another page already has details, link to it instead of duplicating.
4. **Preserve structure**: keep valid frontmatter and heading anchor IDs.
5. **Keep examples copyable**: minimal, tested-looking, and directly relevant.
6. **Search before writing**: always check if the content already exists in some form before adding new sections or pages.
7. **Check Relevant Code**: Search the codebase inside `src/` for any relevant code, comments, or tests that can inform the documentation content and ensure accuracy.
### Content Location
## Docs config model (current)
All documentation files are located in:
```
src/routes/(docs)/docs/content/
```
`docs.json` is versioned. Sidebar lives inside tabs:
### File Organization
- `versions[].content.navigation.tabs[].sidebar`
- Sidebar groups contain `pages`
- Page paths use `content` (legacy `slug` may still appear in older content)
```
content/
├── introduction.md # Top-level pages
├── configuration.md
├── monitors/ # Nested sections
│ ├── overview.md
│ ├── api.md
│ └── ping.md
├── alerting/
│ ├── overview.md
│ └── triggers.md
└── setup/
├── email-setup.md
└── database-setup.md
```
When adding a new doc page, add it to the appropriate tab sidebar path.
### Navigation Configuration
## Versioned link policy (mandatory)
Navigation is controlled by `/src/routes/(docs)/docs.json`:
- For v4 docs content, internal links MUST use explicit v4 paths: `/docs/v4/...`.
- Do not use unversioned shortcuts like `/docs/alerting/...` in v4 pages.
- Before finalizing, verify every internal link in edited files resolves to the intended version.
```json
{
"sidebar": [
{
"group": "Getting Started",
"collapsible": false,
"pages": [
{
"title": "Introduction",
"slug": "introduction"
}
]
}
]
}
```
**Important**: When creating new documentation files, you MUST also update `docs.json` to add navigation entries.
## Required Frontmatter
Every documentation file MUST begin with YAML frontmatter:
## Required page format
```markdown
---
title: Your Page Title
description: A clear, concise description of the page content (used for SEO and previews)
title: Page Title
description: One-line summary of user outcome
---
```
## Custom Heading Anchors
- Use custom anchors for H2/H3 headings: `## Section {#section}`
- Use GitHub admonitions only when needed: `[!NOTE]`, `[!IMPORTANT]`, `[!WARNING]`, `[!CAUTION]`, `[!TIP]`
- Prefer short sections and short lists
Use custom heading IDs for stable deep linking. This is CRITICAL for maintaining links even if heading text changes.
## Preferred structure (default)
### Syntax
1. Short intro (12 sentences)
2. Quick setup / minimum config
3. Required variables/options table
4. Verification step
5. Top troubleshooting items
```markdown
## Section Title {#section-title}
```
Only add extra sections if they materially improve task completion.
### Best Practices
## Keep docs lean
1. **Always include custom IDs** for all H2 and H3 headings
2. Use lowercase, kebab-case format
3. Keep IDs short but descriptive
4. IDs should be unique within the document
Remove or avoid:
### Examples
- Multiple near-identical examples
- Long conceptual explainers
- Platform-by-platform repetition unless behavior differs
- Large checklists that restate earlier content
```markdown
## How API Monitoring Works {#how-api-monitoring-works}
### Configuration Options {#configuration-options}
### Common Issues {#common-issues}
```
## Editing workflow
## Markdown Features
1. Read the whole target document.
2. Compress verbose sections first.
3. Keep critical caveats and breaking notes.
4. Ensure internal links and anchors still work.
5. If adding files, update `docs.json` navigation in the correct version/tab.
### Callout Boxes
## Review checklist
Use GitHub-flavored callout syntax for important information:
```markdown
> [!NOTE]
> Additional context or helpful information
> [!WARNING]
> Caution about potential issues
> [!TIP]
> Helpful tips for users
> [!IMPORTANT]
> Critical information users should know
```
### Code Blocks
Always specify the language for syntax highlighting:
````markdown
```javascript
// Your code here
```
```bash
# Shell commands
```
```json
{
"config": "value"
}
```
````
### Tables
Use tables for structured information:
```markdown
| Column 1 | Column 2 | Column 3 |
| :------- | :------- | :------- |
| Value 1 | Value 2 | Value 3 |
```
### Internal Links
Reference other documentation pages using absolute paths:
```markdown
See the [Email Setup](/docs/setup/email-setup) guide for details.
```
For section anchors:
```markdown
See [Configuration Options](/docs/monitors/api#configuration-options).
```
## Documentation Structure Template
Follow this structure for comprehensive documentation pages:
```markdown
---
title: Feature Name
description: Brief description of the feature
---
Brief introduction paragraph explaining what the feature is and why it matters.
## How It Works {#how-it-works}
Explain the basic workflow or concept.
## Configuration Options {#configuration-options}
Document all available configuration options using tables:
| Field | Type | Description | Default |
| :------- | :------- | :---------- | :------ |
| `option` | `string` | What it does | `value` |
## Examples {#examples}
Provide practical examples with clear explanations.
### 1. Basic Example {#basic-example}
Simple use case with minimal configuration.
### 2. Advanced Example {#advanced-example}
More complex use case demonstrating advanced features.
## Best Practices {#best-practices}
### Topic 1 {#best-practices-topic-1}
Practical advice for optimal usage.
## Troubleshooting {#troubleshooting}
### Common Issues {#common-issues}
| Issue | Possible Cause | Solution |
| :---- | :------------- | :------- |
| Problem | Why it happens | How to fix |
## Next Steps {#next-steps}
- [Related Documentation 1](/docs/path/to/doc1)
- [Related Documentation 2](/docs/path/to/doc2)
```
## Quality Guidelines
### Writing Style
1. **Be Clear and Concise**: Use simple language and short sentences
2. **Be Action-Oriented**: Focus on what users need to do
3. **Be Consistent**: Use the same terms throughout documentation
4. **Be Complete**: Don't assume prior knowledge
### Content Requirements
1. **Start with Context**: Explain why the feature exists
2. **Show Examples**: Always include practical examples
3. **Include Edge Cases**: Document common pitfalls
4. **Link Related Topics**: Help users navigate to related information
### Technical Writing
1. **Use Active Voice**: "Configure the monitor" not "The monitor should be configured"
2. **Use Present Tense**: "Kener sends notifications" not "Kener will send notifications"
3. **Be Specific**: Include exact values, paths, and commands
4. **Test All Code**: Ensure all code examples work
## Avoid Duplication
### Before Creating New Documentation
1. **Search Existing Docs**: Check if the topic is already covered
2. **Use References**: If content exists elsewhere, link to it instead of duplicating
3. **Extend Existing Docs**: Consider adding to an existing page rather than creating a new one
### When Content Overlaps
**Instead of duplicating**:
```markdown
## Email Configuration
To send emails, you need to configure SMTP settings:
- SMTP_HOST=smtp.example.com
- SMTP_PORT=587
...
```
**Use references**:
```markdown
## Email Configuration
Kener supports email notifications through SMTP or Resend. For detailed configuration instructions, see the [Email Setup](/docs/setup/email-setup) guide.
```
### When to Duplicate vs Reference
**Duplicate when**:
- The information is critical to understanding the current topic
- The content is very brief (1-2 sentences)
- The duplicate provides necessary context
**Reference when**:
- Detailed configuration steps exist elsewhere
- The topic is comprehensively covered in another document
- The information would make the current document too long
## Workflow
### Creating New Documentation
1. **Plan the Structure**: Outline sections before writing
2. **Check for Existing Content**: Ensure you're not duplicating
3. **Write the Content**: Follow the template and guidelines
4. **Add Custom Heading IDs**: Include `{#id}` for all major headings
5. **Update docs.json**: Add navigation entry
6. **Add Internal Links**: Link to related documentation
7. **Review and Test**: Verify all links, code examples, and formatting
### Editing Existing Documentation
1. **Read the Full Document**: Understand the existing content
2. **Maintain Consistency**: Match the existing style and tone
3. **Update Related Sections**: Keep all sections consistent
4. **Preserve Custom IDs**: Never change existing `{#custom-ids}`
5. **Update Links**: Ensure all internal references remain valid
### Before Finalizing
**Checklist**:
- [ ] Frontmatter includes title and description
- [ ] All H2 and H3 headings have custom IDs
- [ ] Code blocks specify language
- [ ] Tables are properly formatted
- [ ] Internal links use `/docs/` prefix
- [ ] Examples are practical and tested
- [ ] No duplicate content (or justified duplication)
- [ ] Navigation entry added to docs.json (for new pages)
- [ ] Related documentation is linked
## Common Patterns
### Configuration Documentation
Always document environment variables with tables:
```markdown
### Environment Variables {#environment-variables}
| Variable | Description | Required | Default |
| :------- | :---------- | :------- | :------ |
| `VAR_NAME` | What it does | Yes/No | `value` |
```
### API Documentation
Structure API documentation consistently:
```markdown
## Endpoint Name {#endpoint-name}
Description of what the endpoint does.
**Endpoint**: `POST /api/path`
**Authentication**: Required/Not Required
**Request Body**:
```json
{
"field": "value"
}
```
**Response**:
```json
{
"result": "success"
}
```
```
### Monitor Type Documentation
Follow this structure for monitor types:
1. Brief introduction
2. How it works
3. Configuration options (table)
4. Examples (multiple, increasing complexity)
5. Best practices (with sub-sections)
6. Troubleshooting (table format)
## File Naming Conventions
- Use lowercase
- Use hyphens for spaces: `email-setup.md`
- Be descriptive: `creating-managing.md` not `create.md`
- Match the slug in docs.json
## Examples of High-Quality Documentation
Reference these examples when writing:
- `/docs/content/setup/email-setup.md` - Comprehensive setup guide
- `/docs/content/monitors/api.md` - Feature documentation with examples
- `/docs/content/alerting/overview.md` - Conceptual overview
## Key Principles
1. **User-First**: Write for users of all skill levels
2. **Scannable**: Use headings, lists, and tables for easy scanning
3. **Actionable**: Users should know what to do after reading
4. **Maintainable**: Use references to avoid update cascades
5. **Accessible**: Clear language, good structure, proper formatting
- [ ] Title/description frontmatter exists
- [ ] Key steps are clear and copyable
- [ ] Content is concise and non-duplicative
- [ ] Headings keep stable custom anchors
- [ ] Navigation updated (if new page)
- [ ] Internal links point to correct paths
- [ ] v4 pages use `/docs/v4/...` internal links (no unversioned `/docs/...` shortcuts)
- [ ] No outdated or irrelevant content remains
- [ ] Admonitions used appropriately for important notes
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/ss-shadcn-svelte
+361
View File
@@ -0,0 +1,361 @@
---
name: tailwindcss
description: Tailwind CSS v4 utility-first styling patterns including responsive design, dark mode, and custom configuration. Use when styling with Tailwind, adding utility classes, configuring Tailwind, setting up dark mode, or customizing the theme.
user-invokable: false
metadata:
category: styling
---
# Tailwind CSS v4 Development Guidelines
Best practices for using Tailwind CSS v4 utility classes effectively.
**Note**: Tailwind CSS v4 (released January 2025) uses a CSS-first configuration approach. If you need v3 compatibility, tailwind.config.js is still supported.
## Core Principles
1. **Utility-First**: Use utility classes instead of custom CSS
2. **Mobile-First**: Design for mobile, then scale up with responsive modifiers
3. **Component Extraction**: Extract repeated patterns into components
4. **Consistent Spacing**: Use Tailwind's spacing scale
5. **Custom Configuration**: Extend the default theme for brand consistency
## Basic Utilities
### Layout
```tsx
// Flexbox
<div className="flex items-center justify-between gap-4">
<div className="flex-1">Content</div>
<div className="flex-shrink-0">Sidebar</div>
</div>
// Grid
<div className="grid grid-cols-3 gap-4">
<div>1</div>
<div>2</div>
<div>3</div>
</div>
// Positioning
<div className="relative">
<div className="absolute top-0 right-0">Badge</div>
</div>
```
### Spacing
```tsx
// Padding and Margin
<div className="p-4 m-2"> {/* padding: 1rem, margin: 0.5rem */}
<div className="px-6 py-4"> {/* padding-x: 1.5rem, padding-y: 1rem */}
<div className="mt-8 mb-4"> {/* margin-top: 2rem, margin-bottom: 1rem */}
// Space between children
<div className="space-y-4"> {/* margin-bottom on all but last child */}
<div>Item 1</div>
<div>Item 2</div>
</div>
```
### Typography
```tsx
<h1 className="text-4xl font-bold text-gray-900">Heading</h1>
<p className="text-base font-normal text-gray-600 leading-relaxed">
Paragraph text with comfortable line height.
</p>
<span className="text-sm font-medium text-blue-600">Label</span>
```
### Colors
```tsx
// Text colors
<p className="text-gray-900 dark:text-gray-100">Text</p>
// Background colors
<div className="bg-blue-500 hover:bg-blue-600">Button</div>
// Border colors
<div className="border border-gray-300">Box</div>
```
## Responsive Design
### Breakpoints
```tsx
// Mobile-first responsive classes
<div className="w-full md:w-1/2 lg:w-1/3">
{/* Full width on mobile, half on medium screens, third on large */}
</div>
<h1 className="text-2xl md:text-4xl lg:text-6xl">
{/* Responsive text sizes */}
</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Responsive grid */}
</div>
```
### Container
```tsx
<div className="container mx-auto px-4">
{/* Centered container with horizontal padding */}
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Responsive container padding */}
</div>
```
## Component Patterns
### Button
```tsx
<button className="px-4 py-2 bg-blue-600 text-white font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
Click me
</button>
// Variants
<button className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50">
Secondary
</button>
```
### Card
```tsx
<div className="overflow-hidden rounded-lg bg-white shadow-md">
<img src="/image.jpg" alt="" className="h-48 w-full object-cover" />
<div className="p-6">
<h2 className="mb-2 text-xl font-semibold">Card Title</h2>
<p className="text-gray-600">Card content goes here.</p>
</div>
</div>
```
### Form Input
```tsx
<div className="space-y-2">
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
type="email"
id="email"
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="you@example.com"
/>
<p className="text-sm text-gray-500">We'll never share your email.</p>
</div>
```
## State Variants
### Hover, Focus, Active
```tsx
<button className="bg-blue-500 hover:bg-blue-600 active:bg-blue-700 focus:ring-2 focus:ring-blue-500">
Interactive Button
</button>
<a href="#" className="text-blue-600 hover:text-blue-800 hover:underline">
Link
</a>
```
### Group Hover
```tsx
<div className="group">
<img src="/image.jpg" className="transition-opacity group-hover:opacity-75" />
<p className="group-hover:text-blue-600">Hover the container</p>
</div>
```
### Disabled
```tsx
<button className="disabled:cursor-not-allowed disabled:opacity-50" disabled>
Disabled Button
</button>
```
## Dark Mode
```css
/* Tailwind v4: Configure in app/globals.css */
@import "tailwindcss";
@media (prefers-color-scheme: dark) {
/* Or use class-based: .dark */
}
```
```tsx
// Usage (same as v3)
<div className="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<h1 className="text-gray-900 dark:text-white">Title</h1>
<p className="text-gray-600 dark:text-gray-400">Description</p>
</div>
```
## Custom Styles
### Arbitrary Values
```tsx
<div className="top-[117px]"> {/* Custom top value */}
<div className="bg-[#1da1f2]"> {/* Custom color */}
<div className="grid-cols-[200px_1fr]"> {/* Custom grid template */}
```
### @apply Directive
```css
/* components/button.css */
.btn-primary {
@apply rounded-md bg-blue-600 px-4 py-2 font-medium text-white;
@apply hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:outline-none;
@apply disabled:cursor-not-allowed disabled:opacity-50;
}
```
## Configuration
### Tailwind v4: CSS-First Configuration
```css
/* app/globals.css */
@import "tailwindcss";
@theme {
/* Custom colors */
--color-brand-50: #eff6ff;
--color-brand-100: #dbeafe;
--color-brand-900: #1e3a8a;
/* Custom spacing */
--spacing-128: 32rem;
/* Custom fonts */
--font-family-sans: "Inter", sans-serif;
/* Custom breakpoints */
--breakpoint-3xl: 1920px;
}
```
### Tailwind v3 Config (Still Supported)
```javascript
// tailwind.config.js (optional in v4)
module.exports = {
content: ["./app/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {
colors: {
brand: {
50: "#eff6ff",
100: "#dbeafe",
900: "#1e3a8a"
}
},
spacing: {
128: "32rem"
},
fontFamily: {
sans: ["Inter", "sans-serif"]
}
}
},
plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")]
}
```
## Plugins
### Official Plugins
```bash
npm install @tailwindcss/forms
npm install @tailwindcss/typography
npm install @tailwindcss/aspect-ratio
npm install @tailwindcss/container-queries
```
```tsx
// @tailwindcss/forms
<input type="text" className="form-input rounded-md" />
// @tailwindcss/typography
<article className="prose lg:prose-xl">
<h1>Article Title</h1>
<p>Content...</p>
</article>
```
## Performance
### Automatic Content Detection
Tailwind v4 automatically detects and scans all template files - no `content` configuration needed.
### Build Performance
Tailwind v4 delivers 3.5x faster full builds (~100ms) compared to v3 using modern CSS features like `@property` and `color-mix()`.
**Browser Requirements**: Safari 16.4+, Chrome 111+, Firefox 128+
## Common Patterns
### Centered Content
```tsx
<div className="flex min-h-screen items-center justify-center">
<div>Centered content</div>
</div>
```
### Sticky Header
```tsx
<header className="sticky top-0 z-50 border-b bg-white">
<nav>Navigation</nav>
</header>
```
### Grid Layout
```tsx
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
```
### Truncate Text
```tsx
<p className="truncate">This text will be truncated with ellipsis if too long</p>
<p className="line-clamp-3">This text will show max 3 lines with ellipsis</p>
```
## Best Practices
1. **Use Consistent Spacing**: Stick to Tailwind's spacing scale
2. **Responsive by Default**: Always consider mobile-first design
3. **Extract Components**: Avoid repeating long class lists
4. **Use Theme Colors**: Define custom colors in config, not arbitrary values
5. **Leverage @apply Sparingly**: Prefer utility classes in JSX
6. **Enable Dark Mode**: Plan for dark mode from the start
7. **Use Plugins**: Leverage official plugins for common needs
8. **Optimize Production**: Ensure purge is configured correctly
+44 -2
View File
@@ -1,9 +1,51 @@
# Dependencies
node_modules
# Version control
.git
.github
# IDE and editor
.vscode
dist
.idea
*.swp
*.swo
# Build outputs (rebuilt inside Docker)
build
dist
.svelte-kit
.docs-excluded
# Environment and secrets
.env
.env.*
!.env.example
# OS files
.DS_Store
*.log
Thumbs.db
# Logs
*.log
npm-debug.log*
# Docker files (prevent recursive context)
Dockerfile
docker-compose*.yml
.dockerignore
# Documentation and meta
README.md
README.template.md
AGENTS.md
CHANGELOG.md
LICENSE
check-output.txt
# AI / tooling config
.claude
# Test artifacts
*.test.*
*.spec.*
+3
View File
@@ -0,0 +1,3 @@
KENER_SECRET_KEY=some_secret_key_for_kener
REDIS_URL=redis://localhost:6379
ORIGIN=http://localhost:3000
+10
View File
@@ -0,0 +1,10 @@
---
name: Kener v4 items
about: Kener v4 items
title: ''
labels: kener_v4
assignees: rajnandan1
---
Kener v4 items
+21 -10
View File
@@ -24,18 +24,29 @@ src/routes/(api)/api/
- **Repository**: `src/lib/server/db/repositories/*.ts` - Database operations
- **DbImpl**: `src/lib/server/db/dbimpl.ts` - Bindings for repository methods
### Current Locals (set by middleware in `hooks.server.ts`)
```typescript
interface Locals {
user?: SessionUser; // Auth session
monitor?: MonitorRecordTyped; // /api/monitors/:monitor_tag/*
incident?: IncidentRecord; // /api/incidents/:incident_id/*
maintenance?: MaintenanceRecord; // /api/maintenances/:maintenance_id/*
page?: PageRecord; // /api/pages/:page_path/*
}
```
## Naming Conventions
### Use snake_case for API payloads
```typescript
// Correct
// Correct
interface CreateMonitorRequest {
monitor_tag: string;
start_date_time: number;
duration_seconds: number;
}
// Wrong
// Wrong
interface CreateMonitorRequest {
monitorTag: string;
startDateTime: number;
@@ -251,7 +262,7 @@ export const DELETE: RequestHandler = async ({ locals }) => {
// Delete related records first (cascade)
await db.deleteResourceRelatedRecords(resource.id);
// Delete the resource itself
await db.deleteResource(resource.id);
@@ -275,8 +286,8 @@ const normalizedTs = GetMinuteStartTimestampUTC(body.start_date_time);
const now = GetNowTimestampUTC();
// For optional timestamp with fallback
const timestamp = body.timestamp !== undefined
? GetMinuteStartTimestampUTC(body.timestamp)
const timestamp = body.timestamp !== undefined
? GetMinuteStartTimestampUTC(body.timestamp)
: GetMinuteStartNowTimestampUTC();
```
@@ -300,8 +311,8 @@ if (typeof body.count !== "number" || isNaN(body.count) || body.count <= 0) {
```typescript
const VALID_STATUSES = ["ACTIVE", "INACTIVE"];
if (body.status && !VALID_STATUSES.includes(body.status)) {
return json({
error: { code: "BAD_REQUEST", message: `status must be one of: ${VALID_STATUSES.join(", ")}` }
return json({
error: { code: "BAD_REQUEST", message: `status must be one of: ${VALID_STATUSES.join(", ")}` }
}, { status: 400 });
}
```
@@ -311,8 +322,8 @@ if (body.status && !VALID_STATUSES.includes(body.status)) {
if (body.monitor_tag) {
const monitor = await db.getMonitorByTag(body.monitor_tag);
if (!monitor) {
return json({
error: { code: "BAD_REQUEST", message: `Monitor with tag '${body.monitor_tag}' not found` }
return json({
error: { code: "BAD_REQUEST", message: `Monitor with tag '${body.monitor_tag}' not found` }
}, { status: 400 });
}
}
@@ -324,7 +335,7 @@ if (body.items !== undefined) {
if (!Array.isArray(body.items)) {
return json({ error: { code: "BAD_REQUEST", message: "items must be an array" } }, { status: 400 });
}
for (const item of body.items) {
if (!item.tag || typeof item.tag !== "string") {
return json({ error: { code: "BAD_REQUEST", message: "Each item must have a valid tag" } }, { status: 400 });
+65 -39
View File
@@ -2,25 +2,37 @@
## Project Overview
Kener is an open-source status page application built with **SvelteKit 2.x** (**Svelte 5**) and **Node.js**, and is migrating to a **TypeScript-first** codebase. It provides real-time monitoring, uptime tracking, incident management, and customizable dashboards.
Kener is an open-source status page application built with **SvelteKit 2.x** (**Svelte 5**) and **Node.js/Express**. It is a **TypeScript-first** codebase providing real-time monitoring, uptime tracking, incident management, and customizable dashboards.
## Architecture
### Entry Points
- **`main.js`** - Production server entry: Express + SvelteKit handler + cron scheduler
- **`src/lib/server/startup.js`** - Cron job scheduler for monitors (runs every minute)
### Dual Process Model
In development, `npm run dev` runs two parallel processes:
1. **SvelteKit dev server** (`vite dev`) - serves the frontend with HMR
2. **Cron scheduler** (`vite-node src/lib/server/startup.ts`) - runs monitor checks, maintenance scheduling, daily cleanup
In production, **`scripts/main.ts`** is the single entry point: Express server + SvelteKit handler + migrations + seeds + scheduler startup. Built output runs via `node build/main.js`.
### Route Groups (SvelteKit)
- **`(kener)/`** - Public status page routes
- **`(manage)/`** - Admin dashboard (requires authentication)
- **`(embed)/`** - Embeddable widgets
- **`(docs)/`** - Documentation pages
- **`(api)/`** - SvelteKit API routes
- **`(account)/`** - Account/auth pages
- **`(ext)/`** - External integrations
- **`(assets)/`** - Asset serving
### Core Server Components
- **`src/lib/server/controllers/controller.js`** - Main business logic (~1700 lines), handles monitors, incidents, auth, email
- **`src/lib/server/db/dbimpl.js`** - Database abstraction layer using Knex.js
- **`src/lib/server/services/`** - Monitor type implementations: API, Ping, TCP, DNS, SSL, SQL, Heartbeat, GameDig, Group
- **`src/lib/server/cron-minute.js`** - Per-monitor cron execution logic
- **`src/lib/server/controllers/`** - Domain-split controllers (18 TypeScript files): `apiController.ts`, `incidentController.ts`, `monitorsController.ts`, `maintenanceController.ts`, `pagesController.ts`, `userController.ts`, `dashboardController.ts`, `emailController.ts`, `siteDataController.ts`, `validators.ts`, etc.
- **`src/lib/server/db/dbimpl.ts`** - Database abstraction layer using Knex.js with repository composition pattern
- **`src/lib/server/db/repositories/`** - Domain-driven repositories: `monitors.ts`, `incidents.ts`, `maintenances.ts`, `pages.ts`, `users.ts`, `alerts.ts`, `monitoring.ts`, `images.ts`, `subscriptionSystem.ts`, `emailTemplateConfig.ts`, `monitorAlertConfig.ts`, `site-data.ts`
- **`src/lib/server/services/`** - Monitor type implementations (all TypeScript): `apiCall.ts`, `pingCall.ts`, `tcpCall.ts`, `dnsCall.ts`, `sslCall.ts`, `sqlCall.ts`, `heartbeatCall.ts`, `gamedigCall.ts`, `groupCall.ts`, `grpcCall.ts`, `noneCall.ts`
- **`src/lib/server/schedulers/`** - Scheduling via `croner`: `appScheduler.ts`, `monitorSchedulers.ts`, `maintenanceScheduler.ts`, `dailyCleanup.ts`, `shutdown.ts`
- **`src/lib/server/queues/`** - Job queues via **BullMQ** + **Redis**: `monitorExecuteQueue.ts`, `monitorResponseQueue.ts`, `alertingQueue.ts`, `emailQueue.ts`, `subscriberQueue.ts`
- **`src/lib/server/api-server/`** - Express-side API handlers with file-based routing (directory/method pattern: e.g., `monitor-bar/get.ts`)
- **`src/lib/server/cron-minute.ts`** - Per-monitor cron execution logic
### Database
- Supports SQLite (default), PostgreSQL, MySQL via **Knex.js**
@@ -28,83 +40,97 @@ Kener is an open-source status page application built with **SvelteKit 2.x** (**
- Migrations in `/migrations/`, seeds in `/seeds/`
- Run migrations: `npm run migrate` or auto-runs on `npm start`
### Build System
`npm run build` is a two-step process:
1. `scripts/build-sveltekit.js` - Vite build of SvelteKit app (optionally with `--with-docs`)
2. `scripts/build-server.js` - esbuild bundles `scripts/main.ts` into `build/main.js`
## Development Commands
```bash
npm run dev # Start dev server with hot reload + cron scheduler
npm run build # Production build
npm run preview # Preview production build
npm run check # Typecheck + Svelte checks (uses tsconfig)
npm run dev # Start dev server (SvelteKit + cron scheduler in parallel)
npm run build # Production build (SvelteKit then esbuild server bundle)
npm run start # Run production build (node build/main.js)
npm run check # Svelte + TypeScript type checking
npm run prettify # Format all files with Prettier
npm run migrate # Run database migrations via Knex
npm run seed # Run database seeds
```
## Key Patterns
### Svelte 5 + TypeScript conventions
- Prefer **TypeScript** for new/modified code (`.ts`, and `.svelte` with `lang="ts"`).
- Prefer **Svelte 5 runes** for component state/effects in new code (e.g. `$state`, `$derived`, `$effect`).
- Prefer Svelte 5 props via `$props()` in new components. Keep existing `export let` props where already used to avoid churn.
- For SvelteKit route typing, prefer generated `$types` (e.g. `import type { PageServerLoad } from './$types'`).
- Avoid packages that hard-require Svelte 4 (they can break or force `--legacy-peer-deps`).
- Use **TypeScript** for all code (`.ts`, and `.svelte` with `lang="ts"`).
- Use **Svelte 5 runes** (`$state`, `$derived`, `$effect`, `$props()`) in components.
- For SvelteKit route typing, use generated `$types` (e.g. `import type { PageServerLoad } from './$types'`).
- Avoid packages that hard-require Svelte 4.
### Monitor Types
Defined in `src/lib/server/services/service.js`. Each type has its own implementation file:
```javascript
// Supported: API, PING, TCP, DNS, GROUP, SSL, SQL, HEARTBEAT, GAMEDIG
Defined in `src/lib/server/services/service.ts`. Each type has its own implementation file:
```typescript
// Supported: API, PING, TCP, DNS, GROUP, SSL, SQL, HEARTBEAT, GAMEDIG, GRPC, NONE
```
### Status Constants
Use constants from `src/lib/server/constants`:
```javascript
import { UP, DOWN, DEGRADED, MAINTENANCE, NO_DATA } from "./constants";
Use constants from `src/lib/global-constants.ts`:
```typescript
// In Svelte/client code:
import { UP, DOWN, DEGRADED, MAINTENANCE, NO_DATA } from "$lib/global-constants";
// In server code (use relative path):
import { UP, DOWN, DEGRADED, MAINTENANCE, NO_DATA } from "./global-constants";
```
### API Authentication
APIs use Bearer token auth verified via `VerifyAPIKey()`:
```javascript
import { VerifyAPIKey } from "$lib/server/controllers/controller.js";
```typescript
import { VerifyAPIKey } from "$lib/server/controllers/apiController";
```
### Database Queries
Always use the db singleton, never instantiate Knex directly:
```javascript
```typescript
import db from "$lib/server/db/db";
const monitor = await db.getMonitorByTag(tag);
```
### Timestamps
All timestamps are **UTC seconds** (not milliseconds). Use helpers from `src/lib/server/tool.js`:
```javascript
import { GetMinuteStartNowTimestampUTC, GetNowTimestampUTC } from "./tool";
All timestamps are **UTC seconds** (not milliseconds). Use helpers from `src/lib/server/tool.ts`:
```typescript
import { GetMinuteStartTimestampUTC, GetNowTimestampUTC } from "$lib/server/tool";
```
### i18n
Locales are in `src/lib/locales/`. Add new translations by creating `{code}.json` and updating `locales.json`.
21 locale files in `src/lib/locales/` (en, de, fr, es, hi, ja, ko, zh-CN, zh-TW, pt-BR, ru, etc.). Add new translations by creating `{code}.json` and updating `locales.json`.
## UI Components
Uses **shadcn-svelte** components in `src/lib/components/ui/`. Import pattern:
```javascript
Uses **shadcn-svelte** components in `src/lib/components/ui/` (40+ components). Import pattern:
```typescript
import { Button } from "$lib/components/ui/button";
```
Styling: **TailwindCSS** with HSL CSS variables for theming (see `tailwind.config.js`).
Styling: **Tailwind CSS v4** with CSS-based configuration (no `tailwind.config.js`). Theme uses HSL CSS variables defined in `src/routes/layout.css`.
## Environment Variables
Required in `.env`:
- `KENER_SECRET_KEY` - JWT secret for auth
Required:
- `KENER_SECRET_KEY` - Secret key for auth
- `ORIGIN` - Site URL (e.g., `http://localhost:3000`)
- `DATABASE_URL` - Database connection string
- `REDIS_URL` - Redis connection string (required for BullMQ job queues)
Optional:
- `DATABASE_URL` - Database connection string (defaults to SQLite)
- `KENER_BASE_PATH` - Base path for reverse proxy
- `PORT` - Server port (default 3000)
- `RESEND_API_KEY` / `RESEND_SENDER_EMAIL` - Email notifications
## File Conventions
- Server-only code: `src/lib/server/`
- Shared utilities: `src/lib/` (except `server/`)
- Route data loading: `+page.server.ts` / `+layout.server.ts` (and client-side `+page.ts` / `+layout.ts` when needed)
- Client utilities: `src/lib/client/`
- Route data loading: `+page.server.ts` / `+layout.server.ts`
- API endpoints: `+server.ts` files returning `json()`
## Types & Interfaces
@@ -112,7 +138,7 @@ Optional:
Place types and interfaces in the appropriate folder based on where they are used:
- **`src/lib/types/`** - Shared types (safe to import from both server and client code). Use for domain models, DTOs, API response types, and anything needed on both sides.
- **`src/lib/server/types/`** - Server-only types. Use for DB models, internal service types, auth/session types, and anything that uses `$env/static/private` or Node-only APIs.
- **`src/lib/client/types/`** - Client-only types. Use for UI-specific types, component prop types, and anything that relies on browser/DOM APIs.
- **`src/lib/server/types/`** - Server-only types (`db.ts`, `auth.ts`, `monitor.ts`, `api-server.ts`). Use for DB models, internal service types, auth/session types.
- **`src/lib/client/types/`** - Client-only types (`ui.ts`). Use for UI-specific types, component prop types.
Always use `import type { ... }` when importing types to avoid accidental runtime imports.
+98
View File
@@ -0,0 +1,98 @@
name: Create Release
on:
workflow_dispatch:
inputs:
version:
description: "Release version (for example: 4.0.0)"
required: true
type: string
make_latest:
description: "Mark this release as latest"
required: true
type: boolean
default: true
prerelease:
description: "Mark as pre-release"
required: true
type: boolean
default: false
permissions:
contents: write
jobs:
create-release:
name: Bump version, tag, and create release
runs-on: ubuntu-latest
steps:
- name: Check out default branch
uses: actions/checkout@v4.2.2
with:
ref: ${{ github.event.repository.default_branch }}
fetch-depth: 0
- name: Validate version format
run: |
VERSION="${{ inputs.version }}"
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "Invalid version format: $VERSION"
echo "Use semver like 4.0.0 or 4.0.0-rc.1"
exit 1
fi
- name: Ensure release tag does not already exist
run: |
TAG="v${{ inputs.version }}"
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists"
exit 1
fi
- name: Bump package version
run: |
VERSION="${{ inputs.version }}"
CURRENT_VERSION=$(node -p 'require("./package.json").version')
if [ "$CURRENT_VERSION" != "$VERSION" ]; then
npm version "$VERSION" --no-git-tag-version --allow-same-version
else
echo "package.json already at version $VERSION"
fi
- name: Commit version bump
run: |
VERSION="${{ inputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package.json
if [ -f package-lock.json ]; then
git add package-lock.json
fi
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "chore(release): bump version to $VERSION"
fi
- name: Create and push git tag
run: |
VERSION="${{ inputs.version }}"
TAG="v$VERSION"
git tag -a "$TAG" -m "Release $TAG"
git push origin "HEAD:${{ github.event.repository.default_branch }}"
git push origin "$TAG"
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ inputs.version }}
target_commitish: ${{ github.event.repository.default_branch }}
generate_release_notes: true
make_latest: ${{ inputs.make_latest && 'true' || 'false' }}
prerelease: ${{ inputs.prerelease }}
token: ${{ secrets.RELEASE_TOKEN }}
-83
View File
@@ -1,83 +0,0 @@
name: Generate README
on:
push:
branches:
- main
paths:
- 'README.template.md'
pull_request:
branches:
- main
paths:
- 'README.template.md'
workflow_dispatch: # Allows for manual execution
workflow_run: # Triggers this workflow to run when it recognizes 'publish-images' workflow has ran and successfully completed
workflows: ["Publish Docker Image to Registries"]
types:
- completed
permissions:
contents: write # Explicitly allow pushing changes
jobs:
generate-readme:
name: Generate README from template
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0 # Fetch full history, including tags
persist-credentials: false # We'll manually authenticate
- name: Configure Git
run: |
git config --global user.name "github-actions"
git config --global user.email "github-actions@github.com"
- name: Setup Node.js
uses: actions/setup-node@v4.2.0
with:
node-version: "20"
- name: Install Dependencies
run: npm install mustache dotenv
- name: Extract Major and Major-Minor Versions
run: |
VERSION="${{ vars.BUILD_VERSION }}"
# Check if VERSION is empty and set a fallback value
if [ -z "$VERSION" ]; then
# Fetch the latest release using Git
VERSION=$(git tag -l --sort=-v:refname | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || echo "3.1.0")
fi
MAJOR=$(echo "$VERSION" | cut -d. -f1)
MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2)
echo "Full version: $VERSION"
echo "Major version: $MAJOR"
echo "Major-Minor version: $MAJOR_MINOR"
# Export all as environment variables
echo "LATEST_VERSION=$VERSION" >> $GITHUB_ENV
echo "LATEST_MAJOR_VERSION=$MAJOR" >> $GITHUB_ENV
echo "LATEST_MAJOR_MINOR_VERSION=$MAJOR_MINOR" >> $GITHUB_ENV
- name: Generate README.md
env:
BUILD_FULL_VERSION: ${{ env.LATEST_VERSION }} # e.g., 1.2.3
BUILD_MAJOR_VERSION: ${{ env.LATEST_MAJOR_VERSION}} # e.g., 1
BUILD_MAJOR_MINOR_VERSION: ${{ env.LATEST_MAJOR_MINOR_VERSION }} # e.g., 1.2
run: node scripts/generate-readme.js
- name: Commit and Push Changes
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
git add README.md
git commit -m "Auto-generate README.md with release versions" || echo "No changes to commit"
git push https://x-access-token:${{ secrets.GH_PAT }}@github.com/${{ github.repository }}.git HEAD:main
-21
View File
@@ -1,21 +0,0 @@
name: Prevent Direct README Changes
on:
pull_request:
paths:
- "README.md"
jobs:
check-readme:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4.2.2
- name: Detect direct README changes
run: |
if git diff --name-only origin/main | grep -q "README.md"; then
echo "❌ Direct modifications to README.md are not allowed!"
echo "Please update README.template.md instead."
exit 1
fi
+19 -84
View File
@@ -1,93 +1,58 @@
name: Publish Docker Image to Registries
name: Publish Nightly Docker Image
on:
release:
types:
- published # Runs only when a GitHub Release is published
workflow_dispatch: # Allows for manual execution
push:
branches:
- next/**
workflow_dispatch:
env:
ALPINE_VERSION: "23-alpine"
DEBIAN_VERSION: "23-slim"
# Registry URLs
DOCKERHUB_REGISTRY: docker.io
GITHUB_REGISTRY: ghcr.io
# Docker Hub image name (using Docker Hub username)
DOCKERHUB_IMAGE_NAME: ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }}
# GitHub image name (formatted as `account/repo`)
GITHUB_IMAGE_NAME: ${{ github.repository }}
concurrency:
group: nightly-docker-${{ github.ref }}
cancel-in-progress: true
jobs:
check-lockfile:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '23' # Adjust as needed
- name: Install dependencies
run: npm ci
- name: Check for lock-file changes
run: |
git diff --exit-code package-lock.json || (
echo "🫥 package-lock.json is outdated or missing. Please run 'npm install' and commit the updated lockfile."
exit 1
)
build-and-push-to-registries:
needs: check-lockfile # Runs only after `check-lockfile` completes successfully
name: Push Docker images to Docker Hub and GitHub Container Registry
build-and-push-nightly:
name: Build and push nightly Docker images
strategy:
matrix:
variant: [alpine, debian]
variant: [debian, alpine]
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
contents: read
packages: write
# This is used to complete the identity challenge with sigstore/fulcio when running outside of PRs.
id-token: write
steps:
- name: Check out the repo
uses: actions/checkout@v4.2.2
# Install the cosign tool (except on PR)
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@v3.8.0
with:
cosign-release: 'v2.2.4'
cosign-release: v2.2.4
# Set up BuildKit Docker container builder to be able to build multi-platform images and export cache
# https://github.com/docker/setup-buildx-action
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.8.0
# Log in to Docker Hub (except on PR)
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3.3.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
# Log in to GitHub Container Registry (except on PR)
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3.3.0
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# Combined metadata extraction for both registries
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5.6.1
@@ -96,61 +61,31 @@ jobs:
${{ env.DOCKERHUB_IMAGE_NAME }}
${{ env.GITHUB_REGISTRY }}/${{ env.GITHUB_IMAGE_NAME }}
tags: |
# Debian Variant Tags
type=semver,pattern={{version}},enable=${{ matrix.variant == 'debian' }}
type=semver,pattern={{major}}.{{minor}},enable=${{ matrix.variant == 'debian' }}
type=semver,pattern={{major}},enable=${{ matrix.variant == 'debian' }}
type=raw,value=latest,enable=${{ matrix.variant == 'debian' }}
# Alpine Variant Tags
type=semver,pattern={{version}},suffix=-alpine,enable=${{ matrix.variant == 'alpine' }}
type=semver,pattern={{major}}.{{minor}},suffix=-alpine,enable=${{ matrix.variant == 'alpine' }}
type=semver,pattern={{major}},suffix=-alpine,enable=${{ matrix.variant == 'alpine' }}
type=raw,value=alpine,enable=${{ matrix.variant == 'alpine' }}
type=raw,value=nightly,enable=${{ matrix.variant == 'debian' }}
type=raw,value=nightly-alpine,enable=${{ matrix.variant == 'alpine' }}
type=sha,format=short,prefix=nightly-,enable=${{ matrix.variant == 'debian' }}
type=sha,format=short,prefix=nightly-alpine-,enable=${{ matrix.variant == 'alpine' }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.3.0
# Build and push Docker image with Buildx to both registries (don't push on PR)
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6.13.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VARIANT=${{ matrix.variant }}
ALPINE_VERSION=${{ env.ALPINE_VERSION }}
DEBIAN_VERSION=${{ env.DEBIAN_VERSION }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
# Sign the resulting Docker image digests
- name: Sign the published Docker images
if: ${{ github.event_name != 'pull_request' }}
env:
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
run: |
echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
# For use in other workflows (e.g. 'generate-readme', etc.)
- name: Save Build Version to Repository Variable
if: matrix.variant == 'debian' && github.run_attempt == 1
run: |
# VERSION="${{ steps.meta.outputs.version }}"
VERSION=$(gh release view --json tagName -q .tagName 2>/dev/null || echo "")
# Check if VERSION is empty and set a fallback value
if [ -z "$VERSION" ]; then
VERSION=$(git tag -l --sort=-version:refname | grep -E '^(v)?[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || echo "3.1.0")
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "Setting BUILD_VERSION to $VERSION"
gh variable set BUILD_VERSION --body "$VERSION"
env:
GH_TOKEN: ${{ secrets.GH_PAT }} # Needs to be PAT w/ Read access to metadata and secrets & Read and Write access to actions, actions variables, and code
+92
View File
@@ -0,0 +1,92 @@
name: Publish Main Docker Image (with Docs)
on:
push:
branches:
- main
workflow_dispatch:
env:
DOCKERHUB_REGISTRY: docker.io
GITHUB_REGISTRY: ghcr.io
DOCKERHUB_IMAGE_NAME: ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }}
GITHUB_IMAGE_NAME: ${{ github.repository }}
concurrency:
group: main-docker-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-push-main:
name: Build and push main Docker images (with docs)
strategy:
matrix:
variant: [debian, alpine]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Check out the repo
uses: actions/checkout@v4.2.2
- name: Install cosign
uses: sigstore/cosign-installer@v3.8.0
with:
cosign-release: v2.2.4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.8.0
- name: Log in to Docker Hub
uses: docker/login-action@v3.3.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3.3.0
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5.6.1
with:
images: |
${{ env.DOCKERHUB_IMAGE_NAME }}
${{ env.GITHUB_REGISTRY }}/${{ env.GITHUB_IMAGE_NAME }}
tags: |
type=raw,value=main-with-docs,enable=${{ matrix.variant == 'debian' }}
type=raw,value=main-with-docs-alpine,enable=${{ matrix.variant == 'alpine' }}
type=sha,format=short,prefix=main-with-docs-,enable=${{ matrix.variant == 'debian' }}
type=sha,format=short,prefix=main-with-docs-alpine-,enable=${{ matrix.variant == 'alpine' }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.3.0
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6.13.0
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VARIANT=${{ matrix.variant }}
WITH_DOCS=true
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Sign the published Docker images
env:
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
run: |
echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
+146
View File
@@ -0,0 +1,146 @@
name: Publish Release Docker Images
on:
release:
types:
- published
workflow_dispatch:
env:
DOCKERHUB_REGISTRY: docker.io
GITHUB_REGISTRY: ghcr.io
DOCKERHUB_IMAGE_NAME: ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }}
GITHUB_IMAGE_NAME: ${{ github.repository }}
concurrency:
group: release-docker-${{ github.event.release.tag_name || github.ref }}
cancel-in-progress: true
jobs:
build-and-push-release:
name: Build and push release Docker images
strategy:
matrix:
variant: [debian, alpine]
base_path: ["", "/status"]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Check out release tag
uses: actions/checkout@v4.2.2
with:
ref: refs/tags/${{ github.event.release.tag_name || github.ref_name }}
fetch-depth: 0
- name: Validate package version matches release tag
run: |
TAG="${{ github.event.release.tag_name || github.ref_name }}"
EXPECTED_VERSION="${TAG#v}"
PACKAGE_VERSION=$(node -p 'require("./package.json").version')
if [ "$PACKAGE_VERSION" != "$EXPECTED_VERSION" ]; then
echo "package.json version mismatch"
echo "release tag: $TAG"
echo "expected package.json version: $EXPECTED_VERSION"
echo "actual package.json version: $PACKAGE_VERSION"
exit 1
fi
- name: Install cosign
uses: sigstore/cosign-installer@v3.8.0
with:
cosign-release: v2.2.4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.8.0
- name: Log in to Docker Hub
uses: docker/login-action@v3.3.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3.3.0
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute release tags
id: vars
run: |
TAG="${{ github.event.release.tag_name || github.ref_name }}"
NORM_TAG="${TAG#v}"
if [ "${{ matrix.base_path }}" = "/status" ]; then
BASE_SUFFIX="-status"
else
BASE_SUFFIX=""
fi
WITH_DOCS="false"
if [ "${{ matrix.variant }}" = "alpine" ]; then
VARIANT_SUFFIX="-alpine"
else
VARIANT_SUFFIX=""
fi
FULL_SUFFIX="${BASE_SUFFIX}${VARIANT_SUFFIX}"
echo "release_tag=${TAG}${FULL_SUFFIX}" >> "$GITHUB_OUTPUT"
echo "release_norm_tag=${NORM_TAG}${FULL_SUFFIX}" >> "$GITHUB_OUTPUT"
echo "latest_tag=latest${FULL_SUFFIX}" >> "$GITHUB_OUTPUT"
echo "with_docs=${WITH_DOCS}" >> "$GITHUB_OUTPUT"
if [ "${{ matrix.variant }}" = "debian" ]; then
echo "release_tag_debian_alias=${TAG}${BASE_SUFFIX}-debian" >> "$GITHUB_OUTPUT"
echo "release_norm_tag_debian_alias=${NORM_TAG}${BASE_SUFFIX}-debian" >> "$GITHUB_OUTPUT"
echo "latest_tag_debian_alias=latest${BASE_SUFFIX}-debian" >> "$GITHUB_OUTPUT"
fi
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5.6.1
with:
images: |
${{ env.DOCKERHUB_IMAGE_NAME }}
${{ env.GITHUB_REGISTRY }}/${{ env.GITHUB_IMAGE_NAME }}
tags: |
type=raw,value=${{ steps.vars.outputs.latest_tag }}
type=raw,value=${{ steps.vars.outputs.release_tag }}
type=raw,value=${{ steps.vars.outputs.release_norm_tag }},enable=${{ steps.vars.outputs.release_norm_tag != steps.vars.outputs.release_tag }}
type=raw,value=${{ steps.vars.outputs.latest_tag_debian_alias }},enable=${{ matrix.variant == 'debian' }}
type=raw,value=${{ steps.vars.outputs.release_tag_debian_alias }},enable=${{ matrix.variant == 'debian' }}
type=raw,value=${{ steps.vars.outputs.release_norm_tag_debian_alias }},enable=${{ matrix.variant == 'debian' && steps.vars.outputs.release_norm_tag_debian_alias != steps.vars.outputs.release_tag_debian_alias }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.3.0
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6.13.0
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VARIANT=${{ matrix.variant }}
WITH_DOCS=${{ steps.vars.outputs.with_docs }}
KENER_BASE_PATH=${{ matrix.base_path }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Sign the published Docker images
env:
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
run: |
echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
+5
View File
@@ -1,4 +1,6 @@
.DS_Store
.DS_STORE
**/.DS_Store
node_modules
static/kener
build
@@ -30,3 +32,6 @@ static/uploads/*
temp.txt
temp.js
.DS_Store
knip-output.txt
check-output.txt
translation-report.json
+2 -1
View File
@@ -19,4 +19,5 @@ config/static/*
!config/static/.kener
**/*.yaml
**/*.yml
.github/
.github/
src/lib/components/ui
+17
View File
@@ -21,3 +21,20 @@ You MUST use this tool whenever writing Svelte code before sending it to the use
Generates a Svelte Playground link with the provided code.
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
## Database compatibility rule
All database operations — migrations, queries, and repository functions — **MUST** work across all three supported databases: **SQLite**, **PostgreSQL**, and **MySQL**. Use Knex.js schema builder and query builder abstractions; avoid raw SQL unless wrapped in dialect-safe helpers or guarded with `try/catch`. When writing migrations:
- Use `knex.schema.hasColumn` / `knex.schema.hasTable` guards for idempotency.
- Use Knex column types (`.string()`, `.integer()`, `.text()`, etc.) — never raw `ALTER TABLE` unless necessary.
- For data-seeding inside migrations, use standard Knex query builder (`.insert()`, `.update()`, `.orderBy()`, `.first()`).
- Test that `defaultTo()` values and `notNullable()` constraints work on all three engines.
## Documentation writing skill
When the user asks to write or edit documentation, follow the skill file:
- `.claude/skills/documentation-writer/SKILL.md`
This is mandatory for docs-related tasks. Prioritize short, clear, action-oriented docs and avoid bloat.
+123
View File
@@ -0,0 +1,123 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What is Kener?
Kener is an open-source status page application built with **SvelteKit 2.x (Svelte 5)** and **Node.js/Express**. It is a **TypeScript-first** codebase providing real-time monitoring, uptime tracking, incident management, and customizable dashboards.
## Development Commands
```bash
npm run dev # Start dev server (SvelteKit + cron scheduler in parallel)
npm run build # Production build (SvelteKit then esbuild server bundle)
npm run start # Run production build (node build/main.js)
npm run check # Svelte + TypeScript type checking
npm run prettify # Format all files with Prettier
npm run migrate # Run database migrations via Knex
npm run seed # Run database seeds (migrations run automatically first)
```
## Architecture
### Dual Process Model
In development, `npm run dev` runs two parallel processes:
1. **SvelteKit dev server** (`vite dev`) - serves the frontend
2. **Cron scheduler** (`vite-node src/lib/server/startup.ts`) - runs monitor checks, maintenance scheduler, daily cleanup
In production, `scripts/main.ts` is the single entry point: Express server + SvelteKit handler + migrations + seeds + scheduler startup.
### SvelteKit Route Groups
- **`(kener)/`** - Public status page
- **`(manage)/`** - Admin dashboard (authenticated)
- **`(embed)/`** - Embeddable widgets
- **`(docs)/`** - Documentation pages
- **`(api)/`** - SvelteKit API routes; also `src/lib/server/api-server/` for Express-side API handlers (file-based routing: `./action/method.ts`)
- **`(account)/`** - Account/auth pages
- **`(ext)/`** - External integrations
- **`(assets)/`** - Asset serving
### Database
- **Knex.js** for query building and migrations. Supports SQLite (default), PostgreSQL, MySQL
- Connection configured via `DATABASE_URL` env var: `sqlite://./path`, `postgresql://...`, `mysql://...`
- Migrations in `/migrations/`, seeds in `/seeds/`
- Always use the db singleton: `import db from "$lib/server/db/db"`
### Monitor Services
Each monitor type has a dedicated implementation in `src/lib/server/services/`:
- Types: API, Ping, TCP, DNS, SSL, SQL, Heartbeat, GameDig, Group, gRPC, None
- Scheduled via `src/lib/server/schedulers/` using `croner`
- Job queues managed with **BullMQ** + **Redis** (`src/lib/server/queues/`)
### Build System
`npm run build` is a two-step process:
1. `scripts/build-sveltekit.js` - Vite build of SvelteKit app (optionally with `--with-docs`)
2. `scripts/build-server.js` - esbuild bundles `scripts/main.ts` into `build/main.js`
## Key Conventions
### Svelte 5 + TypeScript
- Use **TypeScript** for new/modified code
- Use **Svelte 5 runes** (`$state`, `$derived`, `$effect`, `$props()`) in new components
- Use generated `$types` for SvelteKit route typing (`import type { PageServerLoad } from './$types'`)
- Use `import type { ... }` for type imports
### UI Components
- **shadcn-svelte** components in `src/lib/components/ui/`
- Import: `import { Button } from "$lib/components/ui/button"`
- Styling: **Tailwind CSS v4** with HSL CSS variables for theming
### Timestamps
All timestamps are **UTC seconds** (not milliseconds). Use helpers from `src/lib/server/tool.ts`.
### Status Constants
Constants are exported as a **default export** from `src/lib/global-constants.ts`:
```typescript
// In Svelte/client code or SvelteKit routes:
import GC from "$lib/global-constants"
// Usage: GC.UP, GC.DOWN, GC.DEGRADED, GC.MAINTENANCE, GC.NO_DATA
// In server code (use relative path):
import GC from "../../global-constants.js"
// Usage: GC.UP, GC.DOWN, etc.
```
### API Authentication
APIs use Bearer token auth: `import { VerifyAPIKey } from "$lib/server/controllers/apiController"`
### Types Location
- `src/lib/types/` - Shared types (client + server)
- `src/lib/server/types/` - Server-only types
- `src/lib/client/types/` - Client-only types
### i18n
Locale files in `src/lib/locales/`. Add translations by creating `{code}.json` and updating `locales.json`.
## Environment Variables
Required: `KENER_SECRET_KEY`, `ORIGIN`, `REDIS_URL`
Optional: `DATABASE_URL` (defaults to SQLite), `KENER_BASE_PATH`, `PORT` (default 3000), `RESEND_API_KEY`, `RESEND_SENDER_EMAIL`
## Skills
Read `.claude/skills/` for specialized instructions on:
- **svelte-code-writer** - Svelte component creation/editing
- **documentation-writer** - Editing docs in `src/routes/(docs)/docs/content/`
- **tailwindcss** - Tailwind CSS v4 patterns
+193
View File
@@ -0,0 +1,193 @@
# syntax=docker/dockerfile:1
# =============================================================================
# Kener v4 — Status Page Application
# Multi-stage, multi-variant (Alpine / Debian) Dockerfile
#
# Build:
# docker build -t kener . # Alpine (default)
# docker build -t kener --build-arg VARIANT=debian . # Debian Slim
# docker build -t kener --build-arg WITH_DOCS=true . # Include docs
#
# Run:
# docker run -d -p 3000:3000 \
# -e KENER_SECRET_KEY=<secret> \
# -e ORIGIN=http://localhost:3000 \
# -e REDIS_URL=redis://<host>:6379 \
# -v kener_db:/app/database \
# kener
# =============================================================================
ARG NODE_VERSION=24
ARG VARIANT=alpine
ARG WITH_DOCS=false
ARG KENER_BASE_PATH=
# =============================================================================
# STAGE 1 — BUILDER (installs deps, compiles native modules, builds app)
# =============================================================================
# ---------- Alpine builder ----------
FROM node:${NODE_VERSION}-alpine AS builder-alpine
RUN apk add --no-cache \
build-base \
python3 \
sqlite \
sqlite-dev \
tzdata
# ---------- Debian builder ----------
FROM node:${NODE_VERSION}-slim AS builder-debian
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
python3 \
sqlite3 \
libsqlite3-dev \
tzdata && \
rm -rf /var/lib/apt/lists/*
# ---------- Selected variant ----------
FROM builder-${VARIANT} AS builder
ENV NPM_CONFIG_LOGLEVEL=error
WORKDIR /app
ARG KENER_BASE_PATH
ENV KENER_BASE_PATH=${KENER_BASE_PATH}
# 1. Copy package manifests first (maximises layer cache hits)
COPY package*.json ./
# 2. Install ALL dependencies (devDependencies needed for the build step)
RUN npm ci --no-fund && \
npm cache clean --force
# 3. Copy the rest of the source tree
COPY . .
# 4. Create directories that the app expects
RUN mkdir -p database
# 5. Conditionally remove docs routes before build
# (avoids EXDEV rename error in overlayfs; clean .svelte-kit so stale
# route types don't persist)
ARG WITH_DOCS
RUN if [ "$WITH_DOCS" != "true" ]; then \
rm -rf src/routes/\(docs\) .svelte-kit; \
fi
# 6. Build: SvelteKit (vite) + server bundle (esbuild)
# Use build-with-docs when docs are enabled
RUN if [ "$WITH_DOCS" = "true" ]; then \
npm run build-with-docs; \
else \
npm run build; \
fi
# 7. Stage docs runtime files for index-docs (empty dir when docs disabled)
RUN mkdir -p /docs-runtime && \
if [ "$WITH_DOCS" = "true" ]; then \
mkdir -p /docs-runtime/scripts && \
mkdir -p /docs-runtime/src/lib && \
mkdir -p "/docs-runtime/src/routes/(docs)/docs" && \
cp scripts/index-docs.ts /docs-runtime/scripts/ && \
cp src/lib/marked.ts /docs-runtime/src/lib/ && \
cp "src/routes/(docs)/docs.json" "/docs-runtime/src/routes/(docs)/" && \
cp -r "src/routes/(docs)/docs/content" "/docs-runtime/src/routes/(docs)/docs/"; \
fi
# 8. Remove devDependencies from node_modules
RUN npm prune --omit=dev
# =============================================================================
# STAGE 2 — PRODUCTION (minimal runtime image)
# =============================================================================
# ---------- Alpine runtime ----------
FROM node:${NODE_VERSION}-alpine AS final-alpine
RUN apk add --no-cache \
sqlite \
tzdata \
iputils \
curl \
libcap && \
# Grant ping the NET_RAW capability so non-root users can send ICMP packets
setcap cap_net_raw+ep /bin/ping || true
# ---------- Debian runtime ----------
FROM node:${NODE_VERSION}-slim AS final-debian
RUN apt-get update && apt-get install -y --no-install-recommends \
sqlite3 \
tzdata \
iputils-ping \
curl \
libcap2-bin && \
setcap cap_net_raw+ep /usr/bin/ping || true && \
rm -rf /var/lib/apt/lists/*
# ---------- Selected variant ----------
FROM final-${VARIANT} AS final
ARG PORT=3000
ARG KENER_BASE_PATH=
ENV NODE_ENV=production \
PORT=${PORT} \
KENER_BASE_PATH=${KENER_BASE_PATH} \
BODY_SIZE_LIMIT=3M \
TZ=UTC \
# Required so Node can import .ts migration/seed files at runtime
NODE_OPTIONS="--experimental-strip-types"
WORKDIR /app
# Create writable directories owned by the non-root "node" user
# (node:node is provided by the official Node.js images)
RUN mkdir -p database && \
chown -R node:node /app
# ---- Copy artifacts from builder (order: least → most likely to change) ----
# Production node_modules (largest layer, changes least often)
COPY --chown=node:node --from=builder /app/node_modules ./node_modules
# Package manifest (needed for ESM "type":"module" resolution)
COPY --chown=node:node --from=builder /app/package.json ./package.json
# Knex migrations & seeds (run at startup by build/main.js)
COPY --chown=node:node --from=builder /app/migrations ./migrations
COPY --chown=node:node --from=builder /app/seeds ./seeds
# Seed data files imported by seeds at runtime (all are leaf modules)
COPY --chown=node:node --from=builder /app/src/lib/server/db/seedSiteData.ts ./src/lib/server/db/seedSiteData.ts
COPY --chown=node:node --from=builder /app/src/lib/server/db/seedMonitorData.ts ./src/lib/server/db/seedMonitorData.ts
COPY --chown=node:node --from=builder /app/src/lib/server/db/seedPagesData.ts ./src/lib/server/db/seedPagesData.ts
COPY --chown=node:node --from=builder /app/src/lib/server/templates/general ./src/lib/server/templates/general
# Locale JSON files (read at runtime by server-side i18n)
COPY --chown=node:node --from=builder /app/src/lib/locales ./src/lib/locales
# Build output (SvelteKit client/server + esbuild main.js) — changes most often
COPY --chown=node:node --from=builder /app/build ./build
# Docs runtime files (index-docs script + markdown sources; empty when WITH_DOCS=false)
COPY --chown=node:node --from=builder /docs-runtime/ ./
# Entrypoint script (runs index-docs on startup when docs are bundled)
COPY --chown=node:node docker-entrypoint.sh ./docker-entrypoint.sh
RUN chmod +x docker-entrypoint.sh
# ---- Runtime configuration ----
# Switch to non-root user
USER node
EXPOSE ${PORT}
# Healthcheck: hit the /healthcheck endpoint exposed by Express in main.ts
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD sh -c 'curl -sf http://localhost:${PORT}${KENER_BASE_PATH}/healthcheck || exit 1'
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "build/main.js"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Raj Nandan Sharma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+211 -20
View File
@@ -1,38 +1,229 @@
# sv
# Kener - Stunning Status Pages
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
<p align="center">
<img src="https://kener.ing/og.jpg?v=1" width="100%" height="auto" class="rounded-lg shadow-lg" alt="kener example illustration">
</p>
## Creating a project
<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://awesome-selfhosted.net/tags/status--uptime-pages.html#kener"><img src="https://awesome.re/mentioned-badge.svg" alt="Awesome self hosted" /></a>
</p>
If you're seeing this, you've probably already done this step. Congrats!
<p align="center">
<a href="https://hub.docker.com/r/rajnandan1/kener"><img src="https://img.shields.io/docker/pulls/rajnandan1/kener" alt="Docker Kener" /></a>
<a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=latest"><img alt="Docker Image Size" src="https://img.shields.io/docker/image-size/rajnandan1/kener/latest?logo=docker&logoColor=white&label=debian" /></a>
<a href="https://hub.docker.com/r/rajnandan1/kener/tags?page=1&ordering=last_updated&name=alpine"><img alt="Docker Image Size" src="https://img.shields.io/docker/image-size/rajnandan1/kener/alpine?logo=docker&logoColor=white&label=alpine" /></a>
</p>
```sh
# create a new project in the current directory
npx sv create
<p align="center">
<a href="https://github.com/rajnandan1/kener/actions/workflows/publish-images.yml"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/rajnandan1/kener/publish-images.yml" /></a>
<a href="https://github.com/rajnandan1/kener/commit/HEAD"><img src="https://img.shields.io/github/last-commit/rajnandan1/kener/main" alt="" /></a>
<a href="https://github.com/rajnandan1/kener/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/rajnandan1/kener.svg" /></a>
<a href="https://deepwiki.com/rajnandan1/kener"><img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
</p>
# create a new project in my-app
npx sv create my-app
<p align="center">
<a href="https://www.producthunt.com/posts/kener-2" target="_blank">
<img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=kener-2&theme=light" alt="Kener on Product Hunt">
</a>
</p>
<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>
| [🌍 Live Server](https://kener.ing) | [🎉 Quick Start](https://kener.ing/docs/v4/getting-started/quick-start) | [🗄 Documentation](https://kener.ing/docs/v4/getting-started/introduction) |
| ----------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------- |
<p align="center">
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/spSvic?referralCode=1Pn7vs&utm_medium=integration&utm_source=template&utm_campaign=generic)
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/1YRTMI?referralCode=rajnandan1)
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https%3A%2F%2Fgithub.com%2Frajnandan1%2Fkener)
</p>
## What is Kener?
**Kener** is a sleek and lightweight status page system built with **SvelteKit** and **NodeJS**. Its not here to replace heavyweights like Datadog or Atlassian but rather to offer a simple, modern, and hassle-free way to set up a great-looking status page with minimal effort.
Designed with **ease of use** and **customization in mind**, Kener provides all the essential features youd expect from a status page—without unnecessary complexity.
### Why Kener?
✅ &nbsp;Minimal overhead &ndash; Set up quickly with a clean, modern UI<br>
✅ &nbsp;Customizable &ndash; Easily tailor it to match your brand<br>
✅ &nbsp;Open-source & free &ndash; Because great tools should be accessible to everyone
### What's in a Name?
“Kener” is inspired by the Assamese word _“Kene”_, meaning _“hows it going?”_. The _.ing_ was added because, well… that domain was available. 😄
## Quick Start
Get Kener running in minutes.
### Docker (recommended)
```bash
git clone https://github.com/rajnandan1/kener.git
cd kener
# Uses docker-compose.yml (includes Redis + Kener)
# Set a strong KENER_SECRET_KEY and ORIGIN in docker-compose.yml before first run
docker compose up -d
```
## Developing
Open `http://localhost:3000`.
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
> [!IMPORTANT]
> Set a strong `KENER_SECRET_KEY` and set `ORIGIN` to your public URL before starting for the first time.
```sh
npm run dev
Use `docker-compose.dev.yml` when you want to build from local source instead of pulling the published image:
# or start the server and open the app in a new browser tab
npm run dev -- --open
```bash
docker compose -f docker-compose.dev.yml up -d --build
```
## Building
Or combine both files to keep base production config while overriding Kener with a local build:
To create a production version of your app:
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
```
### Run pre-built image
You can use either image:
- `docker.io/rajnandan1/kener:latest`
- `ghcr.io/rajnandan1/kener:latest`
For subpath deployments (`/status`), use:
- `docker.io/rajnandan1/kener:latest-status`
- `docker.io/rajnandan1/kener:latest-status-alpine`
- `ghcr.io/rajnandan1/kener:latest-status`
- `ghcr.io/rajnandan1/kener:latest-status-alpine`
```bash
mkdir -p database
docker run -d \
--name kener \
-p 3000:3000 \
-v "$(pwd)/database:/app/database" \
-e "KENER_SECRET_KEY=replace_with_a_random_string" \
-e "ORIGIN=http://localhost:3000" \
-e "REDIS_URL=redis://host.docker.internal:6379" \
docker.io/rajnandan1/kener:latest
```
### Run pre-built subpath image (`/status`)
```bash
mkdir -p database
docker run -d \
--name kener-status \
-p 3000:3000 \
-v "$(pwd)/database:/app/database" \
-e "KENER_SECRET_KEY=replace_with_a_random_string" \
-e "ORIGIN=http://localhost:3000" \
-e "KENER_BASE_PATH=/status" \
-e "REDIS_URL=redis://host.docker.internal:6379" \
docker.io/rajnandan1/kener:latest-status
```
> [!NOTE]
> For subpath mode, keep `ORIGIN` as the site origin (`http://localhost:3000`), not `http://localhost:3000/status`.
### Run without Docker
Requirements:
- Node.js `>= 20`
- Redis
```bash
git clone https://github.com/rajnandan1/kener.git
cd kener
npm install
# Start Redis (example)
docker run -d --name kener-redis -p 6379:6379 redis:7-alpine
```sh
npm run build
npm run start
```
You can preview the production build with `npm run preview`.
Create a `.env` with at least:
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
```dotenv
KENER_SECRET_KEY=replace_with_a_random_string
ORIGIN=http://localhost:3000
REDIS_URL=redis://localhost:6379
PORT=3000
```
For the full quick start (including local Docker builds and dev mode), see the docs:
- https://kener.ing/docs/v4/getting-started/quick-start
## Features
Kener combines public status page essentials with advanced admin workflows.
### 📊 &nbsp;Monitoring, Reliability, and Communication
- Monitor **API, Ping, TCP, DNS, SSL, SQL, Heartbeat, and GameDig** checks
- Manage incidents with clear timelines, updates, and acknowledgements
- Schedule maintenance windows and keep users informed throughout
- Send notifications via **Email, Webhook, Slack, and Discord**
- Explore historical monitoring data and uptime trends
### 🎨 &nbsp;Status Page Experience and Branding
- Build branded, customizable status pages (logo, colors, CSS, themes)
- Support **light/dark mode**, localization, and timezone-aware display
- Embed status widgets and badges into external sites and portals
- Provide SEO-friendly public pages for global audiences
### 🛠️ &nbsp;Operations, Collaboration, and Automation
- Invite teams with role-based collaboration across workflows
- Manage multiple status pages from one Kener instance
- Use trigger-based workflows and template-driven messaging
- Manage API keys for secure integrations and automations
- Integrate analytics providers like GA, Plausible, Mixpanel, Umami, and Clarity
- Access the full REST API for incidents, monitors, and reporting
## Technologies Used
- [SvelteKit](https://kit.svelte.dev/)
- [shadcn-svelte](https://www.shadcn-svelte.com/)
## Support Me
If youre enjoying Kener and want to support its development, consider sponsoring me on GitHub or treating me to a coffee. Your support helps keep the project growing! 🚀
- [Sponsor Me Using GitHub](https://github.com/sponsors/rajnandan1)
- [Buy Me a Coffee](https://www.buymeacoffee.com/rajnandan1)
## Contributing
If you want to contribute to Kener, please read the [Contribution 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)
-32
View File
@@ -1,32 +0,0 @@
> kener@4.0.0 check
> svelte-kit sync && svelte-check --tsconfig ./tsconfig.json
[dotenv@17.2.3] injecting env (19) from .env -- tip: 🔐 prevent committing .env to code: https://dotenvx.com/precommit
Loading svelte-check in workspace: /Users/rajnandan1/Code/kener
Getting Svelte diagnostics...
[dotenv@17.2.3] injecting env (19) from .env -- tip: ⚙️ load multiple .env files with { path: ['.env.local', '.env'] }
[dotenv@17.2.3] injecting env (0) from .env -- tip: ⚙️ override existing env vars with { override: true }
[dotenv@17.2.3] injecting env (0) from .env -- tip: 🔑 add access controls to secrets: https://dotenvx.com/ops
/Users/rajnandan1/Code/kener/src/lib/components/ui/chart/chart-container.svelte:20:28
Warn: This reference only captures the initial value of `id`. Did you mean to reference it inside a closure instead?
https://svelte.dev/e/state_referenced_locally (svelte)
const chartId = `chart-${id || uid.replace(/:/g, "")}`;
/Users/rajnandan1/Code/kener/src/routes/(docs)/docs/DocsSidebar.svelte:45:25
Warn: This reference only captures the initial value of `currentSlug`. Did you mean to reference it inside a closure instead?
https://svelte.dev/e/state_referenced_locally (svelte)
let expandedPages = $state<string[]>([]);
let prevSlug = $state(currentSlug);
/Users/rajnandan1/Code/kener/src/routes/(kener)/events/[MMMM]-[YYYY]/+page.svelte:34:28
Warn: This reference only captures the initial value of `data`. Did you mean to reference it inside a closure instead?
https://svelte.dev/e/state_referenced_locally (svelte)
// Parse the current month from params
const parsedDate = parse(data.monthParam, "MMMM-yyyy", new Date());
const currentMonth = format(parsedDate, "MMMM yyyy");
====================================
svelte-check found 0 errors and 3 warnings in 3 files
+56
View File
@@ -0,0 +1,56 @@
# =============================================================================
# Kener v4 — Development Docker Compose (local build testing)
#
# Builds the image from the local Dockerfile instead of pulling from a registry.
#
# Usage:
# docker compose -f docker-compose.dev.yml up -d --build
#
# Build a specific variant:
# docker compose -f docker-compose.dev.yml build --build-arg VARIANT=debian
# docker compose -f docker-compose.dev.yml up -d
# =============================================================================
services:
# ---------------------------------------------------------------------------
# Redis
# ---------------------------------------------------------------------------
redis:
image: redis:7-alpine
container_name: kener-redis-dev
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# ---------------------------------------------------------------------------
# Kener — built from local Dockerfile
# ---------------------------------------------------------------------------
kener:
build:
context: .
dockerfile: Dockerfile
args:
VARIANT: alpine # or "debian"
NODE_VERSION: 24
WITH_DOCS: "true"
KENER_BASE_PATH: ""
container_name: kener-dev
environment:
KENER_SECRET_KEY: dev-secret-key-for-local-testing-only
ORIGIN: http://localhost:3000
REDIS_URL: redis://redis:6379
# DATABASE_URL: sqlite://./database/kener.sqlite.db
ports:
- "3000:3000"
volumes:
- dev_data:/app/database
depends_on:
redis:
condition: service_healthy
volumes:
dev_data:
+47
View File
@@ -0,0 +1,47 @@
# =============================================================================
# Kener v4 — Production Docker Compose for /status base path
#
# Usage:
# docker compose -f docker-compose.status.yml up -d
# =============================================================================
services:
redis:
image: redis:7-alpine
container_name: kener-redis-status
restart: unless-stopped
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
kener:
image: rajnandan1/kener:latest-status
# For Alpine variant use: rajnandan1/kener:latest-status-alpine
container_name: kener-status
environment:
KENER_SECRET_KEY: replace_me_with_a_random_string # generate: openssl rand -base64 32
ORIGIN: http://localhost:3000/status
REDIS_URL: redis://redis:6379
KENER_BASE_PATH: /status
# DATABASE_URL: sqlite://./database/kener.sqlite.db
# DATABASE_URL: postgresql://user:password@postgres:5432/kener
# DATABASE_URL: mysql://user:password@mysql:3306/kener
ports:
- "3000:3000"
volumes:
- data:/app/database
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
volumes:
data:
name: kener_db_status
redis_data:
name: kener_redis_status
+115
View File
@@ -0,0 +1,115 @@
# =============================================================================
# Kener v4 — Production Docker Compose
#
# Usage:
# docker compose up -d
#
# Defaults to the published image. To use a local build instead:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
# =============================================================================
services:
# ---------------------------------------------------------------------------
# Redis — required for BullMQ queues, caching, and scheduler
# ---------------------------------------------------------------------------
redis:
image: redis:7-alpine
container_name: kener-redis
restart: unless-stopped
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# ---------------------------------------------------------------------------
# Kener — Status Page Application
# ---------------------------------------------------------------------------
kener:
image: rajnandan1/kener:latest
# For Alpine variant use: rajnandan1/kener:alpine
container_name: kener
environment:
# ── Required ──
KENER_SECRET_KEY: replace_me_with_a_random_string # generate: openssl rand -base64 32
ORIGIN: http://localhost:3000 # public URL of your Kener instance (required for CSRF protection)
REDIS_URL: redis://redis:6379
# ── Database (default: SQLite) ──
# DATABASE_URL: sqlite://./database/kener.sqlite.db
# DATABASE_URL: postgresql://user:password@postgres:5432/kener
# DATABASE_URL: mysql://user:password@mysql:3306/kener
# ── Email (optional) ──
# RESEND_API_KEY:
# RESEND_SENDER_EMAIL:
# SMTP_HOST:
# SMTP_PORT:
# SMTP_USER:
# SMTP_PASSWORD:
# SMTP_SENDER:
# SMTP_SECURE: 0
# ── Advanced (you likely don't need to change these) ──
# PORT: 3000
# KENER_BASE_PATH:
# NODE_ENV: production # already set in the image
ports:
- "3000:3000"
volumes:
- data:/app/database
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
# ---------------------------------------------------------------------------
# Optional: PostgreSQL (uncomment and set DATABASE_URL above)
# ---------------------------------------------------------------------------
# postgres:
# image: postgres:16-alpine
# container_name: kener-postgres
# environment:
# POSTGRES_USER: kener
# POSTGRES_PASSWORD: change_me # use a strong password
# POSTGRES_DB: kener
# volumes:
# - postgres_data:/var/lib/postgresql/data
# restart: unless-stopped
# healthcheck:
# test: ["CMD-SHELL", "pg_isready -U kener"]
# interval: 10s
# timeout: 5s
# retries: 5
# ---------------------------------------------------------------------------
# Optional: MySQL / MariaDB (uncomment and set DATABASE_URL above)
# ---------------------------------------------------------------------------
# mysql:
# image: mariadb:11
# container_name: kener-mysql
# environment:
# MYSQL_USER: kener
# MYSQL_PASSWORD: change_me # use a strong password
# MYSQL_DATABASE: kener
# MYSQL_RANDOM_ROOT_PASSWORD: "true"
# volumes:
# - mysql_data:/var/lib/mysql
# restart: unless-stopped
# healthcheck:
# test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
# interval: 10s
# timeout: 5s
# retries: 5
volumes:
data:
name: kener_db
redis_data:
name: kener_redis
# postgres_data:
# name: kener_postgres
# mysql_data:
# name: kener_mysql
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -e
# Default body size limit for SvelteKit adapter-node (512K default is too small for image uploads)
export BODY_SIZE_LIMIT="${BODY_SIZE_LIMIT:-3M}"
# Index documentation into Redis when docs are bundled in the image
if [ -f /app/scripts/index-docs.ts ]; then
echo "[kener] Indexing documentation into Redis..."
node --experimental-strip-types /app/scripts/index-docs.ts || \
echo "[kener] Warning: docs indexing failed (is REDIS_URL set?). Continuing..."
fi
exec "$@"
-135
View File
@@ -1,135 +0,0 @@
// 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.ts";
import shutdownSchedulers from "./src/lib/server/schedulers/shutdown.ts";
import shutdownQueues from "./src/lib/server/queues/shutdown.ts";
import dbInstance from "./src/lib/server/db/db.ts";
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 || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const app: any = express();
const db = knex(knexOb);
if (process.env.ORIGIN) {
process.env.SVELTEKIT_ORIGIN = process.env.ORIGIN;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app.use((req: any, res: any, next: any) => {
if (req.path.startsWith("/embed")) {
res.setHeader("Content-Security-Policy", "frame-ancestors *");
}
res.setHeader("X-Powered-By", "Kener");
next();
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app.get(base + "/healthcheck", (req: any, res: any) => {
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",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any),
);
} 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 + "!");
});
// Graceful shutdown handler
async function gracefulShutdown(signal: string) {
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
try {
console.log("Shutting down schedulers...");
await shutdownSchedulers();
console.log("Schedulers shut down successfully.");
console.log("Shutting down queues...");
await shutdownQueues();
console.log("Queues shut down successfully.");
console.log("Closing database connection...");
await dbInstance.close();
console.log("Database connection closed successfully.");
console.log("Graceful shutdown completed.");
process.exit(0);
} catch (err) {
console.error("Error during graceful shutdown:", err);
process.exit(1);
}
}
// Handle termination signals
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
-133
View File
@@ -1,133 +0,0 @@
// 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")
);
}
+159
View File
@@ -0,0 +1,159 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable("monitoring_data"))) {
await knex.schema.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"]);
});
}
if (!(await knex.schema.hasTable("monitor_alerts"))) {
await knex.schema.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 (IF NOT EXISTS not supported by all DBs, so use try/catch)
try {
await knex.schema.raw("CREATE INDEX idx_monitor_tag_created_at ON monitor_alerts (monitor_tag, created_at)");
} catch (_e) {
// Index already exists
}
if (!(await knex.schema.hasTable("site_data"))) {
await knex.schema.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());
});
}
if (!(await knex.schema.hasTable("monitors"))) {
await knex.schema.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());
});
}
if (!(await knex.schema.hasTable("triggers"))) {
await knex.schema.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());
});
}
if (!(await knex.schema.hasTable("users"))) {
await knex.schema.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());
});
}
if (!(await knex.schema.hasTable("api_keys"))) {
await knex.schema.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());
});
}
if (!(await knex.schema.hasTable("incidents"))) {
await knex.schema.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");
});
}
if (!(await knex.schema.hasTable("incident_monitors"))) {
await knex.schema.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"]);
});
}
if (!(await knex.schema.hasTable("incident_comments"))) {
await knex.schema.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 async function down(knex: Knex): Promise<void> {
await knex.schema
.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");
}
@@ -1,11 +0,0 @@
export function up(knex) {
return knex.schema.alterTable("incidents", function (table) {
table.text("incident_type").defaultTo("INCIDENT");
});
}
export function down(knex) {
return knex.schema.alterTable("incidents", function (table) {
table.dropColumn("incident_type");
});
}
@@ -0,0 +1,16 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
const hasCol = await knex.schema.hasColumn("incidents", "incident_type");
if (!hasCol) {
await knex.schema.alterTable("incidents", function (table) {
table.text("incident_type").defaultTo("INCIDENT");
});
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable("incidents", function (table) {
table.dropColumn("incident_type");
});
}
@@ -1,11 +0,0 @@
export function up(knex) {
return knex.schema.alterTable("incidents", function (table) {
table.text("incident_source").defaultTo("DASHBOARD");
});
}
export function down(knex) {
return knex.schema.alterTable("incidents", function (table) {
table.dropColumn("incident_source");
});
}
@@ -0,0 +1,16 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
const hasCol = await knex.schema.hasColumn("incidents", "incident_source");
if (!hasCol) {
await knex.schema.alterTable("incidents", function (table) {
table.text("incident_source").defaultTo("DASHBOARD");
});
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable("incidents", function (table) {
table.dropColumn("incident_source");
});
}
@@ -1,9 +1,9 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export function up(knex) {
return knex.schema.createTable("invitations", (table) => {
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable("invitations")) return;
await knex.schema.createTable("invitations", (table) => {
// Primary key
table.increments("id").primary();
@@ -29,10 +29,6 @@ export function up(knex) {
});
}
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export function down(knex) {
return knex.schema.dropTableIfExists("invitations");
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("invitations");
}
@@ -1,6 +1,8 @@
export function up(knex) {
return knex.schema
.createTable("subscribers", (table) => {
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable("subscribers"))) {
await knex.schema.createTable("subscribers", (table) => {
table.increments("id").primary();
table.string("subscriber_send").notNullable();
table.text("subscriber_meta").nullable();
@@ -14,8 +16,11 @@ export function up(knex) {
// Add index on subscriber_send for better query performance
table.index(["subscriber_send"]);
})
.createTable("subscriptions", (table) => {
});
}
if (!(await knex.schema.hasTable("subscriptions"))) {
await knex.schema.createTable("subscriptions", (table) => {
table.increments("id").primary();
table.integer("subscriber_id").unsigned().notNullable();
table.string("subscriptions_status").notNullable();
@@ -30,8 +35,11 @@ export function up(knex) {
// Add index to optimize queries filtering by status and monitors
table.index(["subscriptions_status", "subscriptions_monitors"]);
})
.createTable("subscription_triggers", (table) => {
});
}
if (!(await knex.schema.hasTable("subscription_triggers"))) {
await knex.schema.createTable("subscription_triggers", (table) => {
table.increments("id").primary();
table.string("subscription_trigger_type").notNullable().unique();
table.string("subscription_trigger_status").notNullable();
@@ -39,10 +47,10 @@ export function up(knex) {
table.datetime("created_at").defaultTo(knex.fn.now());
table.datetime("updated_at").defaultTo(knex.fn.now());
});
}
}
export function down(knex) {
return knex.schema
export async function down(knex: Knex): Promise<void> {
await knex.schema
.dropTableIfExists("subscription_triggers")
.dropTableIfExists("subscriptions")
.dropTableIfExists("subscribers");
@@ -1,186 +0,0 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// monitoring_data indexes
// For queries: WHERE monitor_tag = ? AND type = ? ORDER BY timestamp DESC (getLastHeartbeat)
await knex.schema.alterTable("monitoring_data", (table) => {
table.index(["monitor_tag", "type", "timestamp"], "idx_monitoring_data_tag_type_ts");
});
// For background cleanup: WHERE timestamp < ?
await knex.schema.alterTable("monitoring_data", (table) => {
table.index(["timestamp"], "idx_monitoring_data_timestamp");
});
// monitor_alerts indexes
// For queries: WHERE incident_number = ?
await knex.schema.alterTable("monitor_alerts", (table) => {
table.index(["incident_number"], "idx_monitor_alerts_incident_number");
});
// For queries: WHERE monitor_tag = ? AND monitor_status = ? AND alert_status = ?
await knex.schema.alterTable("monitor_alerts", (table) => {
table.index(["monitor_tag", "monitor_status", "alert_status"], "idx_monitor_alerts_tag_status_alert");
});
// monitors indexes
// For queries: WHERE status = ?
await knex.schema.alterTable("monitors", (table) => {
table.index(["status"], "idx_monitors_status");
});
// For queries: WHERE category_name = ?
await knex.schema.alterTable("monitors", (table) => {
table.index(["category_name"], "idx_monitors_category");
});
// incidents indexes
// For queries: WHERE status = ? ORDER BY start_date_time
await knex.schema.alterTable("incidents", (table) => {
table.index(["status", "start_date_time"], "idx_incidents_status_start");
});
// For queries: WHERE start_date_time >= ? AND start_date_time <= ?
await knex.schema.alterTable("incidents", (table) => {
table.index(["start_date_time", "end_date_time"], "idx_incidents_start_end");
});
// For queries: ORDER BY updated_at DESC (getRecentUpdatedIncidents)
await knex.schema.alterTable("incidents", (table) => {
table.index(["updated_at"], "idx_incidents_updated_at");
});
// For queries: WHERE state = ?
await knex.schema.alterTable("incidents", (table) => {
table.index(["state"], "idx_incidents_state");
});
// For queries: WHERE incident_type = ?
await knex.schema.alterTable("incidents", (table) => {
table.index(["incident_type"], "idx_incidents_type");
});
// incident_monitors indexes
// For queries: WHERE incident_id = ? (joining with incidents)
await knex.schema.alterTable("incident_monitors", (table) => {
table.index(["incident_id"], "idx_incident_monitors_incident_id");
});
// For queries: WHERE monitor_tag = ? (getIncidentsByMonitorTag)
await knex.schema.alterTable("incident_monitors", (table) => {
table.index(["monitor_tag"], "idx_incident_monitors_monitor_tag");
});
// incident_comments indexes
// For queries: WHERE incident_id = ?
await knex.schema.alterTable("incident_comments", (table) => {
table.index(["incident_id"], "idx_incident_comments_incident_id");
});
// For queries: WHERE incident_id = ? AND status = ?
await knex.schema.alterTable("incident_comments", (table) => {
table.index(["incident_id", "status"], "idx_incident_comments_incident_status");
});
// triggers indexes
// For queries: WHERE trigger_status = ?
await knex.schema.alterTable("triggers", (table) => {
table.index(["trigger_status"], "idx_triggers_status");
});
// subscribers indexes
// For queries: WHERE subscriber_status = ?
await knex.schema.alterTable("subscribers", (table) => {
table.index(["subscriber_status"], "idx_subscribers_status");
});
// For queries: WHERE subscriber_type = ?
await knex.schema.alterTable("subscribers", (table) => {
table.index(["subscriber_type"], "idx_subscribers_type");
});
// invitations indexes
// For queries: WHERE invited_user_id = ? AND invitation_type = ? AND invitation_status = ?
await knex.schema.alterTable("invitations", (table) => {
table.index(["invited_user_id", "invitation_type", "invitation_status"], "idx_invitations_user_type_status");
});
}
export async function down(knex: Knex): Promise<void> {
// Drop indexes in reverse order
await knex.schema.alterTable("invitations", (table) => {
table.dropIndex([], "idx_invitations_user_type_status");
});
await knex.schema.alterTable("subscribers", (table) => {
table.dropIndex([], "idx_subscribers_type");
});
await knex.schema.alterTable("subscribers", (table) => {
table.dropIndex([], "idx_subscribers_status");
});
await knex.schema.alterTable("triggers", (table) => {
table.dropIndex([], "idx_triggers_status");
});
await knex.schema.alterTable("incident_comments", (table) => {
table.dropIndex([], "idx_incident_comments_incident_status");
});
await knex.schema.alterTable("incident_comments", (table) => {
table.dropIndex([], "idx_incident_comments_incident_id");
});
await knex.schema.alterTable("incident_monitors", (table) => {
table.dropIndex([], "idx_incident_monitors_monitor_tag");
});
await knex.schema.alterTable("incident_monitors", (table) => {
table.dropIndex([], "idx_incident_monitors_incident_id");
});
await knex.schema.alterTable("incidents", (table) => {
table.dropIndex([], "idx_incidents_type");
});
await knex.schema.alterTable("incidents", (table) => {
table.dropIndex([], "idx_incidents_state");
});
await knex.schema.alterTable("incidents", (table) => {
table.dropIndex([], "idx_incidents_updated_at");
});
await knex.schema.alterTable("incidents", (table) => {
table.dropIndex([], "idx_incidents_start_end");
});
await knex.schema.alterTable("incidents", (table) => {
table.dropIndex([], "idx_incidents_status_start");
});
await knex.schema.alterTable("monitors", (table) => {
table.dropIndex([], "idx_monitors_category");
});
await knex.schema.alterTable("monitors", (table) => {
table.dropIndex([], "idx_monitors_status");
});
await knex.schema.alterTable("monitor_alerts", (table) => {
table.dropIndex([], "idx_monitor_alerts_tag_status_alert");
});
await knex.schema.alterTable("monitor_alerts", (table) => {
table.dropIndex([], "idx_monitor_alerts_incident_number");
});
await knex.schema.alterTable("monitoring_data", (table) => {
table.dropIndex([], "idx_monitoring_data_timestamp");
});
await knex.schema.alterTable("monitoring_data", (table) => {
table.dropIndex([], "idx_monitoring_data_tag_type_ts");
});
}
@@ -1,6 +1,8 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable("images")) return;
await knex.schema.createTable("images", (table) => {
table.string("id", 32).primary(); // nanoid generated ID with prefix
table.text("data").notNullable(); // base64 encoded image data
+38 -26
View File
@@ -2,37 +2,49 @@ import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Create pages table
await knex.schema.createTable("pages", (table) => {
table.increments("id").primary();
table.string("page_path", 255).notNullable().unique(); // e.g., "/", "/api", "/infrastructure"
table.string("page_title", 255).notNullable();
table.string("page_header", 255);
table.string("page_subheader", 255);
table.string("page_logo", 255);
table.text("page_settings_json"); // JSON settings for the page
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
});
if (!(await knex.schema.hasTable("pages"))) {
await knex.schema.createTable("pages", (table) => {
table.increments("id").primary();
table.string("page_path", 255).notNullable().unique(); // e.g., "/", "/api", "/infrastructure"
table.string("page_title", 255).notNullable();
table.string("page_header", 255);
table.string("page_subheader", 255);
table.string("page_logo", 255);
table.text("page_settings_json"); // JSON settings for the page
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
});
}
// Create pages_monitors junction table
await knex.schema.createTable("pages_monitors", (table) => {
table.integer("page_id").unsigned().notNullable();
table.string("monitor_tag", 255).notNullable();
table.text("monitor_settings_json"); // JSON settings for monitor on this page (e.g., order, visibility)
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
if (!(await knex.schema.hasTable("pages_monitors"))) {
await knex.schema.createTable("pages_monitors", (table) => {
table.integer("page_id").unsigned().notNullable();
table.string("monitor_tag", 255).notNullable();
table.text("monitor_settings_json"); // JSON settings for monitor on this page (e.g., order, visibility)
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Composite primary key
table.primary(["page_id", "monitor_tag"]);
// Composite primary key
table.primary(["page_id", "monitor_tag"]);
// Foreign key constraints
table.foreign("page_id").references("id").inTable("pages").onDelete("CASCADE");
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
});
// Foreign key constraints
table.foreign("page_id").references("id").inTable("pages").onDelete("CASCADE");
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
});
}
// Add index for faster lookups
await knex.schema.raw("CREATE INDEX idx_pages_monitors_page_id ON pages_monitors (page_id)");
await knex.schema.raw("CREATE INDEX idx_pages_monitors_monitor_tag ON pages_monitors (monitor_tag)");
// Add indexes (safe to fail if they already exist)
try {
await knex.schema.raw("CREATE INDEX idx_pages_monitors_page_id ON pages_monitors (page_id)");
} catch (_e) {
/* index already exists */
}
try {
await knex.schema.raw("CREATE INDEX idx_pages_monitors_monitor_tag ON pages_monitors (monitor_tag)");
} catch (_e) {
/* index already exists */
}
}
export async function down(knex: Knex): Promise<void> {
@@ -1,64 +1,69 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Create maintenances table - defines maintenance schedules using iCalendar RRULE
// RRULE examples:
// - ONE_TIME: FREQ=MINUTELY;COUNT=1 (single occurrence)
// - RECURRING: FREQ=WEEKLY;BYDAY=SU;BYHOUR=2;BYMINUTE=0 (every Sunday at 2 AM)
// Reference: http://www.kanzaki.com/docs/ical/rrule.html
await knex.schema.createTable("maintenances", (table) => {
table.increments("id").primary();
table.string("title", 255).notNullable();
table.text("description").nullable(); // Maintenance details/description
table.integer("start_date_time").notNullable(); // Unix timestamp - when the first occurrence starts
table.string("rrule", 500).notNullable(); // iCalendar RRULE string (e.g., FREQ=WEEKLY;BYDAY=SU)
table.integer("duration_seconds").notNullable(); // Duration of each maintenance window in seconds
table.string("status", 50).notNullable().defaultTo("ACTIVE"); // ACTIVE or INACTIVE
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
});
if (!(await knex.schema.hasTable("maintenances"))) {
await knex.schema.createTable("maintenances", (table) => {
table.increments("id").primary();
table.string("title", 255).notNullable();
table.text("description").nullable();
table.integer("start_date_time").notNullable();
table.string("rrule", 500).notNullable();
table.integer("duration_seconds").notNullable();
table.string("status", 50).notNullable().defaultTo("ACTIVE");
table.string("is_global", 15).notNullable().defaultTo("YES");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
});
}
// Create maintenance_monitors junction table - links monitors to maintenance schedules
await knex.schema.createTable("maintenance_monitors", (table) => {
table.increments("id").primary();
table.integer("maintenance_id").unsigned().notNullable();
table.string("monitor_tag", 255).notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
if (!(await knex.schema.hasTable("maintenance_monitors"))) {
await knex.schema.createTable("maintenance_monitors", (table) => {
table.increments("id").primary();
table.integer("maintenance_id").unsigned().notNullable();
table.string("monitor_tag", 255).notNullable();
table.string("monitor_impact").defaultTo("MAINTENANCE").notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Foreign key constraints
table.foreign("maintenance_id").references("id").inTable("maintenances").onDelete("CASCADE");
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
table.foreign("maintenance_id").references("id").inTable("maintenances").onDelete("CASCADE");
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
// Unique constraint to prevent duplicate monitor assignments
table.unique(["maintenance_id", "monitor_tag"]);
});
table.unique(["maintenance_id", "monitor_tag"]);
});
}
// Create maintenances_events table - actual maintenance occurrences (generated by job)
await knex.schema.createTable("maintenances_events", (table) => {
table.increments("id").primary();
table.integer("maintenance_id").unsigned().notNullable();
table.integer("start_date_time").notNullable(); // Unix timestamp
table.integer("end_date_time").notNullable(); // Unix timestamp
table.string("status", 50).notNullable().defaultTo("SCHEDULED"); // SCHEDULED, IN_PROGRESS, COMPLETED, CANCELLED
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
if (!(await knex.schema.hasTable("maintenances_events"))) {
await knex.schema.createTable("maintenances_events", (table) => {
table.increments("id").primary();
table.integer("maintenance_id").unsigned().notNullable();
table.integer("start_date_time").notNullable();
table.integer("end_date_time").notNullable();
table.string("status", 50).notNullable().defaultTo("SCHEDULED");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Foreign key constraint
table.foreign("maintenance_id").references("id").inTable("maintenances").onDelete("CASCADE");
});
table.foreign("maintenance_id").references("id").inTable("maintenances").onDelete("CASCADE");
});
}
// Add indexes for faster lookups
await knex.schema.raw("CREATE INDEX idx_maintenances_status ON maintenances (status)");
await knex.schema.raw("CREATE INDEX idx_maintenances_start_time ON maintenances (start_date_time)");
await knex.schema.raw(
// Add indexes (safe to fail if they already exist)
const indexes = [
"CREATE INDEX idx_maintenances_status ON maintenances (status)",
"CREATE INDEX idx_maintenances_start_time ON maintenances (start_date_time)",
"CREATE INDEX idx_maintenance_monitors_maintenance_id ON maintenance_monitors (maintenance_id)",
);
await knex.schema.raw("CREATE INDEX idx_maintenance_monitors_monitor_tag ON maintenance_monitors (monitor_tag)");
await knex.schema.raw("CREATE INDEX idx_maintenances_events_maintenance_id ON maintenances_events (maintenance_id)");
await knex.schema.raw("CREATE INDEX idx_maintenances_events_status ON maintenances_events (status)");
await knex.schema.raw("CREATE INDEX idx_maintenances_events_start_time ON maintenances_events (start_date_time)");
await knex.schema.raw("CREATE INDEX idx_maintenances_events_end_time ON maintenances_events (end_date_time)");
"CREATE INDEX idx_maintenance_monitors_monitor_tag ON maintenance_monitors (monitor_tag)",
"CREATE INDEX idx_maintenances_events_maintenance_id ON maintenances_events (maintenance_id)",
"CREATE INDEX idx_maintenances_events_status ON maintenances_events (status)",
"CREATE INDEX idx_maintenances_events_start_time ON maintenances_events (start_date_time)",
"CREATE INDEX idx_maintenances_events_end_time ON maintenances_events (end_date_time)",
];
for (const sql of indexes) {
try {
await knex.schema.raw(sql);
} catch (_e) {
/* index already exists */
}
}
}
export async function down(knex: Knex): Promise<void> {
@@ -1,10 +1,14 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Remove unique constraint from monitors.name
await knex.schema.alterTable("monitors", (table) => {
table.dropUnique(["name"]);
});
// Remove unique constraint from monitors.name (safe to fail if already dropped)
try {
await knex.schema.alterTable("monitors", (table) => {
table.dropUnique(["name"]);
});
} catch (_e) {
// Constraint already removed
}
}
export async function down(knex: Knex): Promise<void> {
@@ -1,9 +1,12 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("monitors", (table) => {
table.string("is_hidden").defaultTo("NO").notNullable();
});
const hasCol = await knex.schema.hasColumn("monitors", "is_hidden");
if (!hasCol) {
await knex.schema.alterTable("monitors", (table) => {
table.string("is_hidden").defaultTo("NO").notNullable();
});
}
}
export async function down(knex: Knex): Promise<void> {
@@ -1,9 +1,12 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("monitors", (table) => {
table.text("monitor_settings_json").nullable();
});
const hasCol = await knex.schema.hasColumn("monitors", "monitor_settings_json");
if (!hasCol) {
await knex.schema.alterTable("monitors", (table) => {
table.text("monitor_settings_json").nullable();
});
}
}
export async function down(knex: Knex): Promise<void> {
@@ -1,12 +1,17 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("maintenance_monitors", (table) => {
table.string("monitor_impact").defaultTo("MAINTENANCE").notNullable();
});
if (!(await knex.schema.hasTable("maintenance_monitors"))) return;
const hasCol = await knex.schema.hasColumn("maintenance_monitors", "monitor_impact");
if (!hasCol) {
await knex.schema.alterTable("maintenance_monitors", (table) => {
table.string("monitor_impact").defaultTo("MAINTENANCE").notNullable();
});
}
}
export async function down(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable("maintenance_monitors"))) return;
await knex.schema.alterTable("maintenance_monitors", (table) => {
table.dropColumn("monitor_impact");
});
@@ -2,42 +2,54 @@ import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Create monitor_alerts_config table
await knex.schema.createTable("monitor_alerts_config", (table) => {
table.increments("id").primary();
table.string("monitor_tag", 255).notNullable();
table.string("alert_for", 50).notNullable(); // STATUS, LATENCY, UPTIME
table.string("alert_value", 255).notNullable(); // DOWN, DEGRADED, or numeric value like "1000" or "99"
table.integer("failure_threshold").notNullable().defaultTo(1);
table.integer("success_threshold").notNullable().defaultTo(1);
table.text("alert_description");
table.string("create_incident", 10).notNullable().defaultTo("NO"); // YES or NO
table.string("is_active", 10).notNullable().defaultTo("YES"); // YES or NO
table.string("severity", 50).notNullable().defaultTo("WARNING"); // CRITICAL or WARNING
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
if (!(await knex.schema.hasTable("monitor_alerts_config"))) {
await knex.schema.createTable("monitor_alerts_config", (table) => {
table.increments("id").primary();
table.string("monitor_tag", 255).notNullable();
table.string("alert_for", 50).notNullable(); // STATUS, LATENCY, UPTIME
table.string("alert_value", 255).notNullable(); // DOWN, DEGRADED, or numeric value like "1000" or "99"
table.integer("failure_threshold").notNullable().defaultTo(1);
table.integer("success_threshold").notNullable().defaultTo(1);
table.text("alert_description");
table.string("create_incident", 10).notNullable().defaultTo("NO"); // YES or NO
table.string("is_active", 10).notNullable().defaultTo("YES"); // YES or NO
table.string("severity", 50).notNullable().defaultTo("WARNING"); // CRITICAL or WARNING
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Foreign key to monitors table
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
});
// Foreign key to monitors table
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
});
}
// Create index for faster lookups
await knex.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
await knex.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
// Create indexes (safe to fail if they already exist)
try {
await knex.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
} catch (_e) {
/* index already exists */
}
try {
await knex.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
} catch (_e) {
/* index already exists */
}
// Create monitor_alerts_config_triggers junction table
await knex.schema.createTable("monitor_alerts_config_triggers", (table) => {
table.integer("monitor_alerts_id").unsigned().notNullable();
table.integer("trigger_id").unsigned().notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
if (!(await knex.schema.hasTable("monitor_alerts_config_triggers"))) {
await knex.schema.createTable("monitor_alerts_config_triggers", (table) => {
table.integer("monitor_alerts_id").unsigned().notNullable();
table.integer("trigger_id").unsigned().notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Composite primary key
table.primary(["monitor_alerts_id", "trigger_id"]);
// Composite primary key
table.primary(["monitor_alerts_id", "trigger_id"]);
// Foreign keys
table.foreign("monitor_alerts_id").references("id").inTable("monitor_alerts_config").onDelete("CASCADE");
table.foreign("trigger_id").references("id").inTable("triggers").onDelete("CASCADE");
});
// Foreign keys
table.foreign("monitor_alerts_id").references("id").inTable("monitor_alerts_config").onDelete("CASCADE");
table.foreign("trigger_id").references("id").inTable("triggers").onDelete("CASCADE");
});
}
}
export async function down(knex: Knex): Promise<void> {
@@ -1,19 +1,56 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable("monitor_alerts_v2", (table) => {
table.increments("id").primary();
table.integer("config_id").references("id").inTable("monitor_alerts_config").notNullable().onDelete("CASCADE");
table.integer("incident_id").references("id").inTable("incidents").nullable().onDelete("SET NULL");
table.string("alert_status", 255).notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
if (!(await knex.schema.hasTable("monitor_alerts_v2"))) {
await knex.schema.createTable("monitor_alerts_v2", (table) => {
table.increments("id").primary();
table.integer("config_id").unsigned().notNullable();
table.integer("incident_id").unsigned().nullable();
table.string("alert_status", 255).notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Add index for faster queries on config_id and alert_status
table.index(["config_id", "alert_status"]);
});
// Add index for faster queries on config_id and alert_status
table.index(["config_id", "alert_status"]);
});
}
// Ensure config_id is unsigned (fix for MySQL users who had signed int from a prior failed run)
try {
await knex.schema.alterTable("monitor_alerts_v2", (table) => {
table.integer("config_id").unsigned().notNullable().alter();
});
} catch (_e) {
/* column may already be correct */
}
// Ensure incident_id is unsigned
try {
await knex.schema.alterTable("monitor_alerts_v2", (table) => {
table.integer("incident_id").unsigned().nullable().alter();
});
} catch (_e) {
/* column may already be correct */
}
// Add foreign key constraints (skip if they already exist)
try {
await knex.schema.alterTable("monitor_alerts_v2", (table) => {
table.foreign("config_id").references("id").inTable("monitor_alerts_config").onDelete("CASCADE");
});
} catch (_e) {
/* foreign key may already exist */
}
try {
await knex.schema.alterTable("monitor_alerts_v2", (table) => {
table.foreign("incident_id").references("id").inTable("incidents").onDelete("SET NULL");
});
} catch (_e) {
/* foreign key may already exist */
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTable("monitor_alerts_v2");
await knex.schema.dropTableIfExists("monitor_alerts_v2");
}
@@ -2,93 +2,138 @@ import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// 1. Create subscriber_users table - the actual user identity
await knex.schema.createTable("subscriber_users", (table) => {
table.increments("id").primary();
if (!(await knex.schema.hasTable("subscriber_users"))) {
await knex.schema.createTable("subscriber_users", (table) => {
table.increments("id").primary();
table.string("email", 255).notNullable().unique();
table.string("status", 20).notNullable().defaultTo("PENDING");
table.string("verification_code", 10).nullable();
table.timestamp("verification_expires_at").nullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
table.index(["status"]);
table.index(["email"]);
});
}
// Email is the primary identifier for users
table.string("email", 255).notNullable().unique();
// 2. Create subscriber_methods table
if (!(await knex.schema.hasTable("subscriber_methods"))) {
await knex.schema.createTable("subscriber_methods", (table) => {
table.increments("id").primary();
table.integer("subscriber_user_id").unsigned().notNullable();
table.string("method_type", 50).notNullable();
table.string("method_value", 500).notNullable();
table.string("status", 20).notNullable().defaultTo("ACTIVE");
table.text("meta").nullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
});
}
// User status: PENDING (awaiting verification), ACTIVE, INACTIVE
table.string("status", 20).notNullable().defaultTo("PENDING");
// Add indexes, unique constraints, and foreign keys for subscriber_methods (idempotent)
try {
await knex.schema.alterTable("subscriber_methods", (table) => {
table.index(["subscriber_user_id"]);
});
} catch (_e) {
/* index already exists */
}
try {
await knex.schema.alterTable("subscriber_methods", (table) => {
table.index(["method_type"]);
});
} catch (_e) {
/* index already exists */
}
try {
await knex.schema.alterTable("subscriber_methods", (table) => {
table.index(["status"]);
});
} catch (_e) {
/* index already exists */
}
try {
await knex.schema.alterTable("subscriber_methods", (table) => {
table.unique(["subscriber_user_id", "method_type", "method_value"], {
indexName: "sub_methods_user_type_value_unique",
});
});
} catch (_e) {
/* unique constraint already exists */
}
try {
await knex.schema.alterTable("subscriber_methods", (table) => {
table.foreign("subscriber_user_id").references("id").inTable("subscriber_users").onDelete("CASCADE");
});
} catch (_e) {
/* foreign key already exists */
}
// Verification code for email verification (6 digit)
table.string("verification_code", 10).nullable();
table.timestamp("verification_expires_at").nullable();
// 3. Create user_subscriptions_v2 table
if (!(await knex.schema.hasTable("user_subscriptions_v2"))) {
await knex.schema.createTable("user_subscriptions_v2", (table) => {
table.increments("id").primary();
table.integer("subscriber_user_id").unsigned().notNullable();
table.integer("subscriber_method_id").unsigned().notNullable();
table.string("event_type", 50).notNullable();
table.string("status", 20).notNullable().defaultTo("ACTIVE");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
});
}
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Indexes
table.index(["status"]);
table.index(["email"]);
});
// 2. Create subscriber_methods table - methods a user has configured
await knex.schema.createTable("subscriber_methods", (table) => {
table.increments("id").primary();
// Link to subscriber_user
table.integer("subscriber_user_id").unsigned().notNullable();
// Method type: email, webhook, slack, discord
table.string("method_type", 50).notNullable();
// Method value: email address, webhook URL, slack webhook, discord webhook
table.string("method_value", 500).notNullable();
// Status: ACTIVE, INACTIVE
table.string("status", 20).notNullable().defaultTo("ACTIVE");
// For webhook methods, we might want to store additional config
table.text("meta").nullable(); // JSON for extra config like headers
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Indexes
table.index(["subscriber_user_id"]);
table.index(["method_type"]);
table.index(["status"]);
// Unique: one method type per value per user (can't have same webhook twice)
table.unique(["subscriber_user_id", "method_type", "method_value"]);
// Foreign key
table.foreign("subscriber_user_id").references("id").inTable("subscriber_users").onDelete("CASCADE");
});
// 3. Create user_subscriptions_v2 table - what a user subscribes to
await knex.schema.createTable("user_subscriptions_v2", (table) => {
table.increments("id").primary();
// Link to subscriber_user
table.integer("subscriber_user_id").unsigned().notNullable();
// Link to subscriber_method (which method to use for this subscription)
table.integer("subscriber_method_id").unsigned().notNullable();
// What event type: incidents, maintenance
table.string("event_type", 50).notNullable();
// Status: ACTIVE, INACTIVE
table.string("status", 20).notNullable().defaultTo("ACTIVE");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Indexes
table.index(["subscriber_user_id"]);
table.index(["subscriber_method_id"]);
table.index(["event_type"]);
table.index(["status"]);
// Unique: one subscription per user-method-event-entity
table.unique(["subscriber_user_id", "subscriber_method_id", "event_type"]);
// Foreign keys
table.foreign("subscriber_user_id").references("id").inTable("subscriber_users").onDelete("CASCADE");
table.foreign("subscriber_method_id").references("id").inTable("subscriber_methods").onDelete("CASCADE");
});
// Add indexes, unique constraints, and foreign keys for user_subscriptions_v2 (idempotent)
try {
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
table.index(["subscriber_user_id"]);
});
} catch (_e) {
/* index already exists */
}
try {
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
table.index(["subscriber_method_id"]);
});
} catch (_e) {
/* index already exists */
}
try {
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
table.index(["event_type"]);
});
} catch (_e) {
/* index already exists */
}
try {
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
table.index(["status"]);
});
} catch (_e) {
/* index already exists */
}
try {
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
table.unique(["subscriber_user_id", "subscriber_method_id", "event_type"], {
indexName: "sub_v2_user_method_event_unique",
});
});
} catch (_e) {
/* unique constraint already exists */
}
try {
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
table.foreign("subscriber_user_id").references("id").inTable("subscriber_users").onDelete("CASCADE");
});
} catch (_e) {
/* foreign key already exists */
}
try {
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
table.foreign("subscriber_method_id").references("id").inTable("subscriber_methods").onDelete("CASCADE");
});
} catch (_e) {
/* foreign key already exists */
}
}
export async function down(knex: Knex): Promise<void> {
@@ -1,18 +0,0 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Create email_templates_config table
await knex.schema.createTable("email_templates_config", (table) => {
table.string("email_type", 50).notNullable().primary(); // email_code, forgot_password, verify_email
//template_subject
table.text("template_subject").notNullable();
//template_html_body
table.text("template_html_body").notNullable();
//template_text_body
table.text("template_text_body").notNullable();
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("email_templates_config");
}
@@ -1,6 +1,8 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable("general_email_templates")) return;
await knex.schema.createTable("general_email_templates", (table) => {
table.string("template_id").primary();
table.string("template_subject");
@@ -1,49 +0,0 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("subscription_triggers");
await knex.schema.dropTableIfExists("subscriptions");
await knex.schema.dropTableIfExists("subscribers");
}
export async function down(knex: Knex): Promise<void> {
await knex.schema
.createTable("subscribers", (table) => {
table.increments("id").primary();
table.string("subscriber_send").notNullable();
table.text("subscriber_meta").nullable();
table.string("subscriber_type").notNullable();
table.string("subscriber_status").notNullable();
table.dateTime("created_at").defaultTo(knex.fn.now());
table.dateTime("updated_at").defaultTo(knex.fn.now());
// Add unique constraint on subscriber_send and subscriber_type
table.unique(["subscriber_send", "subscriber_type"]);
// Add index on subscriber_send for better query performance
table.index(["subscriber_send"]);
})
.createTable("subscriptions", (table) => {
table.increments("id").primary();
table.integer("subscriber_id").unsigned().notNullable();
table.string("subscriptions_status").notNullable();
table.string("subscriptions_monitors").notNullable();
table.text("subscriptions_meta").nullable();
table.dateTime("created_at").defaultTo(knex.fn.now());
table.dateTime("updated_at").defaultTo(knex.fn.now());
// Add unique constraint on subscriber_id and subscriptions_monitors
table.unique(["subscriber_id", "subscriptions_monitors"]);
// Add index to optimize queries filtering by status and monitors
table.index(["subscriptions_status", "subscriptions_monitors"]);
})
.createTable("subscription_triggers", (table) => {
table.increments("id").primary();
table.string("subscription_trigger_type").notNullable().unique();
table.string("subscription_trigger_status").notNullable();
table.text("config").nullable();
table.dateTime("created_at").defaultTo(knex.fn.now());
table.dateTime("updated_at").defaultTo(knex.fn.now());
});
}
@@ -1,12 +1,16 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("monitors", (table) => {
table.text("external_url").nullable();
});
await knex.schema.alterTable("monitoring_data", (table) => {
table.text("error_message").nullable();
});
if (!(await knex.schema.hasColumn("monitors", "external_url"))) {
await knex.schema.alterTable("monitors", (table) => {
table.text("external_url").nullable();
});
}
if (!(await knex.schema.hasColumn("monitoring_data", "error_message"))) {
await knex.schema.alterTable("monitoring_data", (table) => {
table.text("error_message").nullable();
});
}
}
export async function down(knex: Knex): Promise<void> {
@@ -0,0 +1,25 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
try {
await knex.schema.alterTable("monitoring_data", (table) => {
table.index(["timestamp"], "idx_monitoring_data_timestamp");
});
} catch (_e) {
/* index already exists */
}
try {
await knex.schema.alterTable("monitoring_data", (table) => {
table.index(["monitor_tag", "type", "timestamp"], "idx_monitoring_data_monitor_tag_type_timestamp");
});
} catch (_e) {
/* index already exists */
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable("monitoring_data", (table) => {
table.dropIndex(["timestamp"], "idx_monitoring_data_timestamp");
table.dropIndex(["monitor_tag", "type", "timestamp"], "idx_monitoring_data_monitor_tag_type_timestamp");
});
}
@@ -0,0 +1,27 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn("incidents", "is_global"))) {
await knex.schema.table("incidents", (table) => {
table.string("is_global", 15).notNullable().defaultTo("YES");
});
}
if ((await knex.schema.hasTable("maintenances")) && !(await knex.schema.hasColumn("maintenances", "is_global"))) {
await knex.schema.table("maintenances", (table) => {
table.string("is_global", 15).notNullable().defaultTo("YES");
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn("incidents", "is_global")) {
await knex.schema.table("incidents", (table) => {
table.dropColumn("is_global");
});
}
if ((await knex.schema.hasTable("maintenances")) && (await knex.schema.hasColumn("maintenances", "is_global"))) {
await knex.schema.table("maintenances", (table) => {
table.dropColumn("is_global");
});
}
}
@@ -0,0 +1,27 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
const hasCol = await knex.schema.hasColumn("users", "is_owner");
if (!hasCol) {
await knex.schema.alterTable("users", (table) => {
table.string("is_owner").defaultTo("NO").notNullable();
});
// Set the first user (by id) as owner, if any users exist.
// This only runs when the column has just been added to avoid
// overwriting an existing owner on migration re-run.
const firstUser = await knex("users").orderBy("id", "asc").first();
if (firstUser) {
await knex("users").where("id", firstUser.id).update({ is_owner: "YES" });
}
}
}
export async function down(knex: Knex): Promise<void> {
const hasCol = await knex.schema.hasColumn("users", "is_owner");
if (hasCol) {
await knex.schema.alterTable("users", (table) => {
table.dropColumn("is_owner");
});
}
}
@@ -0,0 +1,13 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("pages", (table) => {
table.text("page_subheader").alter();
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable("pages", (table) => {
table.string("page_subheader", 255).alter();
});
}
@@ -0,0 +1,19 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
const hasColumn = await knex.schema.hasColumn("pages_monitors", "position");
if (!hasColumn) {
await knex.schema.alterTable("pages_monitors", (table) => {
table.integer("position").unsigned().notNullable().defaultTo(0);
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasColumn = await knex.schema.hasColumn("pages_monitors", "position");
if (hasColumn) {
await knex.schema.alterTable("pages_monitors", (table) => {
table.dropColumn("position");
});
}
}
@@ -0,0 +1,215 @@
/**
* Migration: Multi-Monitor Alerts
*
* This migration refactors the monitor alerting system to support many-to-many
* relationships between alert configurations and monitors. Previously, each
* `monitor_alerts_config` row was tied to exactly one monitor via a `monitor_tag`
* foreign key column. This migration:
*
* 1. Creates a new `monitor_alerts_config_monitors` junction table that links
* `monitor_alerts_config` rows to one or more `monitors` rows, enabling a
* single alert configuration to fire across multiple monitors.
*
* 2. Adds a `monitor_tag` column to `monitor_alerts_v2` so that each firing
* alert record knows which specific monitor triggered it (important when one
* config covers many monitors).
*
* 3. Migrates existing data: copies every `monitor_alerts_config.monitor_tag`
* value into the new junction table and backfills `monitor_alerts_v2.monitor_tag`
* from the same source, preserving all historical alert records.
*
* 4. Removes the one-to-one constraint on `monitor_alerts_config.monitor_tag` by
* dropping its foreign key and setting the column nullable (SQLite workaround:
* nulls the column directly since SQLite cannot drop foreign key constraints
* inline).
*
* 5. Adds a composite index on `monitor_alerts_v2 (config_id, monitor_tag,
* alert_status)` for fast per-monitor alert status lookups.
*
* The `down` migration reverses these steps: restores the first junction-table
* entry back onto `monitor_alerts_config.monitor_tag`, re-adds the foreign key
* (non-SQLite), removes the `monitor_tag` column from `monitor_alerts_v2`
* (non-SQLite), and drops the junction table.
*/
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Step 1: Create monitor_alerts_config_monitors junction table
if (!(await knex.schema.hasTable("monitor_alerts_config_monitors"))) {
await knex.schema.createTable("monitor_alerts_config_monitors", (table) => {
table.integer("monitor_alerts_id").unsigned().notNullable();
table.string("monitor_tag", 255).notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Composite primary key
table.primary(["monitor_alerts_id", "monitor_tag"]);
// Foreign keys
table.foreign("monitor_alerts_id").references("id").inTable("monitor_alerts_config").onDelete("CASCADE");
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
});
}
// Step 2: Add monitor_tag column to monitor_alerts_v2 for per-monitor alert tracking
const hasV2Column = await knex.schema.hasColumn("monitor_alerts_v2", "monitor_tag");
if (!hasV2Column) {
await knex.schema.alterTable("monitor_alerts_v2", (table) => {
table.string("monitor_tag", 255).nullable();
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
});
}
// Step 3: Migrate existing data from monitor_alerts_config.monitor_tag to junction table
// and backfill monitor_alerts_v2.monitor_tag from the same source
const existingConfigs = await knex("monitor_alerts_config").whereNotNull("monitor_tag").select("id", "monitor_tag");
if (existingConfigs.length > 0) {
// Build a config_id -> monitor_tag map for backfilling alerts
const configTagMap = new Map<number, string>();
const inserts = existingConfigs.map((config: { id: number; monitor_tag: string }) => {
configTagMap.set(config.id, config.monitor_tag);
return {
monitor_alerts_id: config.id,
monitor_tag: config.monitor_tag,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
};
});
// Batch insert into junction table
const chunkSize = 100;
for (let i = 0; i < inserts.length; i += chunkSize) {
await knex("monitor_alerts_config_monitors").insert(inserts.slice(i, i + chunkSize));
}
// Backfill monitor_tag on existing monitor_alerts_v2 rows
const existingAlerts = await knex("monitor_alerts_v2").whereNull("monitor_tag").select("id", "config_id");
for (const alert of existingAlerts) {
const tag = configTagMap.get(alert.config_id);
if (tag) {
await knex("monitor_alerts_v2").where({ id: alert.id }).update({ monitor_tag: tag });
}
}
}
// Step 4: Drop foreign key and make monitor_tag nullable on monitor_alerts_config
const dbClient = knex.client.config.client;
if (dbClient === "sqlite3" || dbClient === "better-sqlite3") {
// SQLite cannot ALTER COLUMN, so we rebuild the table with monitor_tag nullable
await knex.transaction(async (trx) => {
await trx.raw("PRAGMA foreign_keys = OFF");
await trx.raw("DROP TABLE IF EXISTS monitor_alerts_config_new");
await trx.raw(`
CREATE TABLE monitor_alerts_config_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_tag VARCHAR(255),
alert_for VARCHAR(50) NOT NULL,
alert_value VARCHAR(255) NOT NULL,
failure_threshold INTEGER NOT NULL DEFAULT 1,
success_threshold INTEGER NOT NULL DEFAULT 1,
alert_description TEXT,
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await trx.raw(`
INSERT INTO monitor_alerts_config_new
(id, monitor_tag, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at)
SELECT
id, NULL, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at
FROM monitor_alerts_config
`);
await trx.raw("DROP TABLE monitor_alerts_config");
await trx.raw("ALTER TABLE monitor_alerts_config_new RENAME TO monitor_alerts_config");
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
} catch (_e) {
/* index may already exist */
}
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
} catch (_e) {
/* index may already exist */
}
await trx.raw("PRAGMA foreign_keys = ON");
});
} else {
try {
await knex.schema.alterTable("monitor_alerts_config", (table) => {
table.dropForeign(["monitor_tag"]);
});
} catch (_e) {
// Foreign key may not exist or already dropped
}
await knex.schema.alterTable("monitor_alerts_config", (table) => {
table.string("monitor_tag", 255).nullable().alter();
});
await knex("monitor_alerts_config").update({ monitor_tag: null });
}
// Step 5: Add composite index on monitor_alerts_v2 for fast lookups
try {
await knex.raw(
"CREATE INDEX idx_monitor_alerts_v2_config_tag_status ON monitor_alerts_v2 (config_id, monitor_tag, alert_status)",
);
} catch (_e) {
/* index already exists */
}
}
export async function down(knex: Knex): Promise<void> {
// Step 1: Drop the composite index on monitor_alerts_v2
try {
await knex.raw("DROP INDEX IF EXISTS idx_monitor_alerts_v2_config_tag_status");
} catch (_e) {
/* index may not exist */
}
// Step 2: Copy first monitor_tag from junction table back to monitor_alerts_config
const configs = await knex("monitor_alerts_config").select("id");
for (const config of configs) {
const firstMonitor = await knex("monitor_alerts_config_monitors").where({ monitor_alerts_id: config.id }).first();
if (firstMonitor) {
await knex("monitor_alerts_config").where({ id: config.id }).update({ monitor_tag: firstMonitor.monitor_tag });
}
}
// Step 3: Delete configs that have no monitors (can't satisfy NOT NULL)
await knex("monitor_alerts_config").whereNull("monitor_tag").del();
// Step 4: Re-add foreign key constraint on monitor_alerts_config (non-SQLite only)
const dbClient = knex.client.config.client;
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
await knex.schema.alterTable("monitor_alerts_config", (table) => {
table.string("monitor_tag", 255).notNullable().alter();
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
});
}
// Step 5: Drop monitor_tag column from monitor_alerts_v2 (non-SQLite only)
const hasV2Column = await knex.schema.hasColumn("monitor_alerts_v2", "monitor_tag");
if (hasV2Column) {
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
await knex.schema.alterTable("monitor_alerts_v2", (table) => {
table.dropForeign(["monitor_tag"]);
table.dropColumn("monitor_tag");
});
}
}
// Step 6: Drop junction table
await knex.schema.dropTableIfExists("monitor_alerts_config_monitors");
}
@@ -0,0 +1,144 @@
/**
* Migration: Fix SQLite monitor_alerts_config.monitor_tag NOT NULL constraint
*
* The earlier migration 20260325120000_multi_monitor_alerts nulled out data in
* monitor_alerts_config.monitor_tag for SQLite but could not alter the column
* constraint (SQLite doesn't support ALTER COLUMN). This migration recreates
* the table with monitor_tag as nullable, preserving all data and indexes.
*
* Only runs on SQLite/better-sqlite3; other databases already had the column
* altered in the previous migration.
*/
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
const dbClient = knex.client.config.client;
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
return; // Already handled by 20260325120000_multi_monitor_alerts
}
// Check if monitor_tag is already nullable (migration 1 already rebuilt the table)
const tableInfo: Array<{ name: string; notnull: number }> = await knex.raw(
"PRAGMA table_info(monitor_alerts_config)",
);
const monitorTagCol = tableInfo.find((col) => col.name === "monitor_tag");
if (monitorTagCol && monitorTagCol.notnull === 0) {
return; // Column is already nullable, nothing to do
}
// Column is still NOT NULL — rebuild the table to make it nullable
try {
await knex.transaction(async (trx) => {
await trx.raw("PRAGMA foreign_keys = OFF");
await trx.raw("DROP TABLE IF EXISTS monitor_alerts_config_new");
await trx.raw(`
CREATE TABLE monitor_alerts_config_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_tag VARCHAR(255),
alert_for VARCHAR(50) NOT NULL,
alert_value VARCHAR(255) NOT NULL,
failure_threshold INTEGER NOT NULL DEFAULT 1,
success_threshold INTEGER NOT NULL DEFAULT 1,
alert_description TEXT,
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await trx.raw(`
INSERT INTO monitor_alerts_config_new
(id, monitor_tag, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at)
SELECT
id, NULL, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at
FROM monitor_alerts_config
`);
await trx.raw("DROP TABLE monitor_alerts_config");
await trx.raw("ALTER TABLE monitor_alerts_config_new RENAME TO monitor_alerts_config");
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
} catch (_e) {
/* index may already exist */
}
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
} catch (_e) {
/* index may already exist */
}
await trx.raw("PRAGMA foreign_keys = ON");
});
} catch (e) {
await knex.raw("PRAGMA foreign_keys = ON");
throw e;
}
}
export async function down(knex: Knex): Promise<void> {
const dbClient = knex.client.config.client;
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
return;
}
// Revert: make monitor_tag NOT NULL again via table rebuild
try {
await knex.transaction(async (trx) => {
await trx.raw("PRAGMA foreign_keys = OFF");
await trx.raw(`
CREATE TABLE monitor_alerts_config_old (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_tag VARCHAR(255) NOT NULL,
alert_for VARCHAR(50) NOT NULL,
alert_value VARCHAR(255) NOT NULL,
failure_threshold INTEGER NOT NULL DEFAULT 1,
success_threshold INTEGER NOT NULL DEFAULT 1,
alert_description TEXT,
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Only copy rows that have a non-null monitor_tag
await trx.raw(`
INSERT INTO monitor_alerts_config_old
(id, monitor_tag, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at)
SELECT
id, monitor_tag, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at
FROM monitor_alerts_config
WHERE monitor_tag IS NOT NULL
`);
await trx.raw("DROP TABLE monitor_alerts_config");
await trx.raw("ALTER TABLE monitor_alerts_config_old RENAME TO monitor_alerts_config");
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
} catch (_e) {
/* index may already exist */
}
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
} catch (_e) {
/* index may already exist */
}
await trx.raw("PRAGMA foreign_keys = ON");
});
} catch (e) {
await knex.raw("PRAGMA foreign_keys = ON");
throw e;
}
}
+3805 -6249
View File
File diff suppressed because it is too large Load Diff
+30 -20
View File
@@ -1,6 +1,6 @@
{
"name": "kener",
"version": "4.0.0",
"version": "4.0.21",
"type": "module",
"private": false,
"license": "MIT",
@@ -28,7 +28,10 @@
"url": "https://github.com/rajnandan1/kener.git"
},
"scripts": {
"build": "vite build",
"build": "node scripts/build-sveltekit.js && node scripts/build-server.js",
"build-with-docs": "node scripts/build-sveltekit.js --with-docs && node scripts/build-server.js",
"build:sveltekit": "vite build",
"build:server": "node scripts/build-server.js",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"configure": "node build.js",
@@ -36,22 +39,23 @@
"development": "vite dev",
"devschedule": "vite-node src/lib/server/startup.ts",
"generate-readme": "node scripts/generate-readme.js",
"migrate": "npx knex migrate:latest",
"index-docs": "vite-node scripts/index-docs.ts",
"migrate": "vite-node scripts/fix-migration-ext.ts && npx knex migrate:latest",
"predev": "npm run seed",
"prepare": "svelte-kit sync || echo ''",
"preseed": "npx knex migrate:latest",
"preseed": "vite-node scripts/fix-migration-ext.ts && npx knex migrate:latest",
"prettify": "prettier --write .",
"preview": "vite preview",
"schedule": "vite-node src/lib/server/startup.ts",
"seed": "npx knex seed:run",
"start": "npx tsx main.ts"
"start": "node build/main.js"
},
"devDependencies": {
"@internationalized/date": "^3.10.0",
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/kit": "^2.53.3",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
@@ -60,12 +64,11 @@
"@types/d3-shape": "^3.1.8",
"@types/dns2": "^2.0.10",
"@types/express": "^5.0.6",
"@types/fs-extra": "^11.0.4",
"@types/heic-convert": "^2.1.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/mustache": "^4.2.6",
"@types/node": "^25.0.3",
"@types/nodemailer": "^7.0.4",
"@zerodevx/svelte-toast": "^0.9.6",
"autoprefixer": "^10.4.22",
"clsx": "^2.1.1",
"concurrently": "^9.2.1",
@@ -77,16 +80,16 @@
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.43.8",
"svelte": "^5.53.5",
"svelte-awesome-color-picker": "^4.1.0",
"svelte-check": "^4.3.4",
"svelte-dnd-action": "^0.9.68",
"svelte-sonner": "^1.0.7",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^7.2.2",
"vite-node": "^5.3.0",
"vite-plugin-devtools-json": "^1.0.0"
@@ -110,14 +113,14 @@
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.39.11",
"@formkit/auto-animate": "^0.9.0",
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"@humanspeak/svelte-purify": "^0.0.6",
"@number-flow/svelte": "^0.3.9",
"@openai/agents": "^0.3.7",
"@rajnandan1/aven": "file:../aven",
"@rajnandan1/svelte-legos": "^0.0.1",
"@scalar/express-api-reference": "^0.8.28",
"@scalar/sveltekit": "^0.1.43",
"@scaleway/random-name": "^5.1.4",
"@uiw/codemirror-theme-github": "^4.25.3",
"analytics": "^0.8.19",
"axios": "^1.13.2",
"badge-maker": "^5.0.2",
"bcrypt": "^6.0.0",
@@ -135,8 +138,9 @@
"figlet": "^1.9.4",
"flexsearch": "^0.8.212",
"front-matter": "^4.0.2",
"fs-extra": "^11.3.2",
"gamedig": "^5.3.2",
"glob": "^13.0.6",
"heic-convert": "^2.1.0",
"highlight.js": "^11.11.1",
"ioredis": "^5.8.2",
"js-yaml": "^4.1.1",
@@ -151,8 +155,6 @@
"marked-plaintify": "^1.1.1",
"mobile-detect": "^1.4.5",
"mode-watcher": "^1.1.0",
"moment": "^2.30.1",
"moment-timezone": "^0.6.0",
"mustache": "^4.2.0",
"mysql2": "^3.15.3",
"nanoid": "^5.1.6",
@@ -162,7 +164,6 @@
"pg": "^8.16.3",
"pg-pool": "^3.10.1",
"ping": "^1.0.0",
"queue": "^7.0.0",
"randomstring": "^1.3.1",
"resend": "^6.6.0",
"rrule": "^2.8.1",
@@ -170,7 +171,16 @@
"striptags": "^3.2.0",
"style-to-object": "^1.0.14",
"svelte-codemirror-editor": "^2.1.0",
"vite-plugin-package-version": "^1.1.0",
"zod": "^4.3.2"
"vite-plugin-package-version": "^1.1.0"
},
"overrides": {
"fast-xml-parser": "^5.5.6",
"rollup": "^4.59.0",
"undici": "^7.24.0",
"minimatch": "^10.2.3",
"devalue": "^5.6.4",
"dompurify": "^3.3.2",
"cookie": "^0.7.0",
"mailparser": "^3.9.3"
}
}
+35
View File
@@ -0,0 +1,35 @@
services:
- type: web
name: kener
runtime: image
image:
url: docker.io/rajnandan1/kener:latest
envVars:
- key: DATABASE_URL
fromDatabase:
name: kener-db
property: connectionString
- key: KENER_SECRET_KEY
generateValue: true
- key: ORIGIN
fromService:
type: web
name: kener
envVarKey: RENDER_EXTERNAL_URL
- key: REDIS_URL
fromService:
name: kener-redis
type: keyvalue
property: connectionString
- type: keyvalue
name: kener-redis
plan: starter
ipAllowList:
- source: 0.0.0.0/0
description: everywhere
maxmemoryPolicy: allkeys-lru
databases:
- name: kener-db
plan: basic-256mb
databaseName: kener
+52
View File
@@ -0,0 +1,52 @@
import * as esbuild from "esbuild";
import { readFileSync } from "fs";
const pkg = JSON.parse(readFileSync("./package.json", "utf8"));
// Collect all dependency names to externalize, except CJS packages
// that need to be bundled for ESM compatibility
const CJS_PACKAGES_TO_BUNDLE = ["rrule"];
const externalDeps = [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {})].filter(
(dep) => !CJS_PACKAGES_TO_BUNDLE.includes(dep),
);
await esbuild.build({
entryPoints: ["scripts/main.ts"],
bundle: true,
platform: "node",
target: "node20",
format: "esm",
outfile: "build/main.js",
// Externalize all node_modules except CJS packages that break ESM named imports
external: externalDeps,
alias: {
// Map SvelteKit's $lib alias so server code resolves correctly
$lib: "./src/lib",
},
define: {
// Inject version at build time so src/lib/version.ts resolves it
// without relying on vite-plugin-package-version at runtime
"import.meta.env.PACKAGE_VERSION": JSON.stringify(pkg.version),
},
plugins: [
{
name: "rewrite-build-imports",
setup(build) {
// Since the output lives in build/, rewrite ../build/X → ./X
// so that handler.js (SvelteKit output) resolves correctly
build.onResolve({ filter: /^\.\.\/build\// }, (args) => {
return {
path: args.path.replace(/^\.\.\/build\//, "./"),
external: true,
};
});
},
},
],
banner: {
js: "// Kener production server built with esbuild",
},
});
console.log(`Server build completed: build/main.js (v${pkg.version})`);
+51
View File
@@ -0,0 +1,51 @@
/**
* SvelteKit build script that optionally excludes docs routes.
*
* Usage:
* node scripts/build-sveltekit.js # build WITHOUT docs
* node scripts/build-sveltekit.js --with-docs # build WITH docs
*/
import { execSync } from "child_process";
import { renameSync, existsSync, rmSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = resolve(__dirname, "..");
const withDocs = process.argv.includes("--with-docs");
const docsDir = resolve(rootDir, "src/routes/(docs)");
const docsHiddenDir = resolve(rootDir, ".docs-excluded");
function moveDocs(from, to) {
if (existsSync(from)) {
renameSync(from, to);
}
}
function build() {
if (!withDocs) {
console.log("[build] Excluding docs routes from build...");
moveDocs(docsDir, docsHiddenDir);
// Clean generated route types so stale docs routes don't persist
const svelteKitDir = resolve(rootDir, ".svelte-kit");
if (existsSync(svelteKitDir)) {
rmSync(svelteKitDir, { recursive: true, force: true });
}
} else {
console.log("[build] Including docs routes in build...");
}
try {
execSync("npx vite build", { cwd: rootDir, stdio: "inherit" });
} finally {
// Always restore docs folder, even if build fails
if (!withDocs) {
moveDocs(docsHiddenDir, docsDir);
console.log("[build] Restored docs routes.");
}
}
}
build();
+210
View File
@@ -0,0 +1,210 @@
//node scripts/check-translations.js
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { globSync } from "glob";
import yaml from "js-yaml";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, "..");
const srcDir = path.join(projectRoot, "src");
const localesDir = path.join(srcDir, "lib", "locales");
const SUPPORTED_FORMATS = new Set(["json", "yaml"]);
const SOURCE_GLOBS = ["src/**/*.{svelte,ts,js,mts,cts}", "!src/lib/locales/**/*.json", "!src/**/*.d.ts"];
const TRANSLATION_CALL_REGEX = /\$t\s*\(\s*(["'`])([\s\S]*?)\1\s*(?:,|\))/g;
const COMMENT_PATTERNS = [/\/\*[\s\S]*?\*\//g, /\/\/[^\n\r]*/g, /<!--[\s\S]*?-->/g];
const WHITELISTED_DYNAMIC_KEYS = new Set([
"All Systems Operational",
"Degraded Performance",
"Partial Degraded Performance",
"Partial System Outage",
"Major System Outage",
"No Status Available",
]);
function decodeQuotedContent(value) {
return value
.replace(/\\\\/g, "\\")
.replace(/\\n/g, "\n")
.replace(/\\r/g, "\r")
.replace(/\\t/g, "\t")
.replace(/\\"/g, '"')
.replace(/\\'/g, "'")
.replace(/\\`/g, "`");
}
function getLineNumber(text, index) {
let line = 1;
for (let i = 0; i < index; i += 1) {
if (text[i] === "\n") {
line += 1;
}
}
return line;
}
function maskComments(content) {
let result = content;
for (const pattern of COMMENT_PATTERNS) {
result = result.replace(pattern, (match) => match.replace(/[^\n]/g, " "));
}
return result;
}
function getUsedTranslationKeys() {
const files = globSync(SOURCE_GLOBS, {
cwd: projectRoot,
nodir: true,
ignore: ["**/node_modules/**", "**/.svelte-kit/**", "**/build/**", "**/dist/**"],
});
const usedKeys = new Set();
const skippedDynamicCalls = [];
for (const relativePath of files) {
const absolutePath = path.join(projectRoot, relativePath);
const content = fs.readFileSync(absolutePath, "utf8");
const searchable = maskComments(content);
TRANSLATION_CALL_REGEX.lastIndex = 0;
for (const match of searchable.matchAll(TRANSLATION_CALL_REGEX)) {
const quote = match[1];
const rawKey = match[2].trim();
if (!rawKey) {
continue;
}
if (quote === "`" && rawKey.includes("${")) {
skippedDynamicCalls.push({
file: relativePath,
line: getLineNumber(searchable, match.index ?? 0),
reason: "template literal contains interpolation",
});
continue;
}
usedKeys.add(decodeQuotedContent(rawKey));
}
}
for (const key of WHITELISTED_DYNAMIC_KEYS) {
usedKeys.add(key);
}
return {
usedKeys,
skippedDynamicCalls,
};
}
function readLocaleMappings() {
const localeFiles = globSync("*.json", {
cwd: localesDir,
nodir: true,
});
const localeMap = new Map();
for (const fileName of localeFiles) {
const fullPath = path.join(localesDir, fileName);
const raw = fs.readFileSync(fullPath, "utf8");
const json = JSON.parse(raw);
if (!json || typeof json !== "object" || typeof json.mappings !== "object") {
throw new Error(`Invalid locale file format in ${fileName}: expected { mappings: {...} }`);
}
localeMap.set(fileName, new Set(Object.keys(json.mappings)));
}
return localeMap;
}
function parseArgs(argv) {
let format = "json";
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--format") {
format = (argv[i + 1] || "").toLowerCase();
i += 1;
continue;
}
if (arg.startsWith("--format=")) {
format = arg.split("=")[1].toLowerCase();
}
}
if (!SUPPORTED_FORMATS.has(format)) {
throw new Error(`Unsupported format: ${format}. Use --format json or --format yaml.`);
}
return {
format,
outputFile: path.join(projectRoot, `translation-report.${format === "yaml" ? "yaml" : "json"}`),
};
}
function main() {
const { format, outputFile } = parseArgs(process.argv.slice(2));
const { usedKeys, skippedDynamicCalls } = getUsedTranslationKeys();
const locales = readLocaleMappings();
const sortedUsedKeys = [...usedKeys].sort((a, b) => a.localeCompare(b));
const localeFiles = [...locales.keys()].sort((a, b) => a.localeCompare(b));
const localesReport = {};
for (const localeFile of localeFiles) {
const localeKeys = locales.get(localeFile) ?? new Set();
const missing = sortedUsedKeys.filter((key) => !localeKeys.has(key));
const unused = [...localeKeys].filter((key) => !usedKeys.has(key)).sort((a, b) => a.localeCompare(b));
localesReport[localeFile] = {
missing,
unused,
missingCount: missing.length,
unusedCount: unused.length,
};
}
const report = {
generatedAt: new Date().toISOString(),
format,
scannedSourceDir: "src",
usedLiteralKeysCount: sortedUsedKeys.length,
usedLiteralKeys: sortedUsedKeys,
whitelistedDynamicKeysCount: WHITELISTED_DYNAMIC_KEYS.size,
whitelistedDynamicKeys: [...WHITELISTED_DYNAMIC_KEYS].sort((a, b) => a.localeCompare(b)),
localeFilesCount: localeFiles.length,
locales: localesReport,
skippedDynamicCallsCount: skippedDynamicCalls.length,
skippedDynamicCalls,
};
if (format === "yaml") {
fs.writeFileSync(outputFile, yaml.dump(report, { noRefs: true }) + "\n", "utf8");
} else {
fs.writeFileSync(outputFile, JSON.stringify(report, null, 2) + "\n", "utf8");
}
console.log(`Translation report written to: ${outputFile}`);
}
try {
main();
} catch (error) {
console.error("Failed to check translations.");
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Renames .js migration entries to .ts in the knex_migrations table.
* This is needed because migration files were renamed from .js to .ts,
* but existing databases still reference the old .js filenames.
*
* Idempotent — safe to run multiple times.
*/
import knex from "knex";
import knexOb from "../knexfile.js";
const db = knex(knexOb);
async function fixMigrationExtensions() {
try {
const hasTable = await db.schema.hasTable("knex_migrations");
if (!hasTable) {
console.log("No knex_migrations table found, skipping.");
return;
}
const oldJsMigrations = await db("knex_migrations").where("name", "like", "%.js");
if (oldJsMigrations.length === 0) {
console.log("No .js migration entries found, nothing to rename.");
return;
}
for (const row of oldJsMigrations) {
const newName = row.name.replace(/\.js$/, ".ts");
await db("knex_migrations").where("id", row.id).update({ name: newName });
console.log(`Renamed: ${row.name} -> ${newName}`);
}
console.log(`Fixed ${oldJsMigrations.length} migration record(s).`);
} catch (err) {
console.error("Error fixing migration extensions:", err);
process.exit(1);
} finally {
await db.destroy();
}
}
fixMigrationExtensions();
+220
View File
@@ -0,0 +1,220 @@
/**
* Standalone script to index documentation content into Redis for full-text search.
*
* This script reads docs.json and all markdown files from disk, converts them
* to plain text, and stores the search documents in Redis. It should be run
* manually whenever documentation content changes.
*
* Usage: npm run index-docs
*/
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import fm from "front-matter";
import IORedis from "ioredis";
import dotenv from "dotenv";
import { marked } from "marked";
import plaintify from "marked-plaintify";
import { mdToText } from "../src/lib/marked.ts";
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DOCS_JSON_PATH = path.join(__dirname, "../src/routes/(docs)/docs.json");
const CONTENT_DIR = path.join(__dirname, "../src/routes/(docs)/docs/content");
const REDIS_DOCS_KEY = "kener-docs:search:documents";
interface DocsPage {
title: string;
content?: string;
slug?: string;
pages?: DocsPageSource[];
}
interface DocsPageSource {
title: string;
slug: string;
pages?: DocsPageSource[];
}
interface DocsSidebarGroup {
group: string;
pages: DocsPage[];
}
interface DocsNavTab {
name: string;
url?: string;
sidebar?: DocsSidebarGroup[];
}
interface DocsVersion {
name: string;
slug: string;
latest?: boolean;
content: {
navigation?: {
tabs?: DocsNavTab[];
};
};
}
interface DocsRootConfig {
versions: DocsVersion[];
}
interface DocsSearchDocument {
id: string;
title: string;
slug: string;
group: string;
content: string;
rawContent: string;
}
interface DocsSearchIndexData {
documents: DocsSearchDocument[];
lastUpdated: number;
}
/**
* Read markdown content for a slug (body only, without frontmatter)
*/
function getMarkdownContent(slug: string): string | null {
const directPath = path.join(CONTENT_DIR, `${slug}.md`);
try {
let rawContent: string | null = null;
if (fs.existsSync(directPath)) {
rawContent = fs.readFileSync(directPath, "utf-8");
} else {
const indexPath = path.join(CONTENT_DIR, slug, "index.md");
if (fs.existsSync(indexPath)) {
rawContent = fs.readFileSync(indexPath, "utf-8");
}
}
if (!rawContent) return null;
const parsed = fm(rawContent);
return parsed.body;
} catch {
return null;
}
}
/**
* Recursively collect all pages from sidebar groups (including nested pages)
*/
function collectPages(
pages: DocsPageSource[],
group: string,
result: Array<{ page: DocsPageSource; group: string }>,
): void {
for (const page of pages) {
result.push({ page, group });
if (page.pages && page.pages.length > 0) {
collectPages(page.pages, group, result);
}
}
}
function normalizePage(page: DocsPage): DocsPageSource {
const resolvedPath = page.content ?? page.slug;
if (!resolvedPath) {
throw new Error(`[index-docs] Page \"${page.title}\" must define content or slug`);
}
return {
title: page.title,
slug: resolvedPath,
pages: page.pages?.map(normalizePage),
};
}
function normalizeSidebar(sidebar: DocsSidebarGroup[]): Array<{ group: string; pages: DocsPageSource[] }> {
return sidebar.map((group) => ({
group: group.group,
pages: group.pages.map(normalizePage),
}));
}
async function main(): Promise<void> {
// Validate Redis URL
if (!process.env.REDIS_URL) {
console.error("Error: REDIS_URL environment variable is not set.");
process.exit(1);
}
// Read docs.json
if (!fs.existsSync(DOCS_JSON_PATH)) {
console.error(`Error: docs.json not found at ${DOCS_JSON_PATH}`);
process.exit(1);
}
const config: DocsRootConfig = JSON.parse(fs.readFileSync(DOCS_JSON_PATH, "utf-8"));
const latestVersion = config.versions.find((version) => version.latest) ?? config.versions[0];
if (!latestVersion) {
console.error("[index-docs] No versions found in docs.json");
process.exit(1);
}
const tabs = latestVersion.content.navigation?.tabs ?? [];
const documents: DocsSearchDocument[] = [];
// Collect all pages from all tabs' sidebars
const allPages: Array<{ page: DocsPageSource; group: string }> = [];
for (const tab of tabs) {
const sidebar = normalizeSidebar(tab.sidebar ?? []);
for (const sidebarGroup of sidebar) {
collectPages(sidebarGroup.pages, sidebarGroup.group, allPages);
}
}
console.log(`[index-docs] Indexing version ${latestVersion.slug}`);
console.log(`[index-docs] Found ${allPages.length} pages to index`);
for (const { page, group } of allPages) {
const markdownContent = getMarkdownContent(page.slug);
if (markdownContent) {
const plainContent = mdToText(markdownContent);
documents.push({
id: page.slug,
title: page.title,
slug: page.slug,
group,
content: plainContent,
rawContent: markdownContent,
});
} else {
console.warn(`[index-docs] No content found for slug: ${page.slug}`);
}
}
console.log(`[index-docs] Indexed ${documents.length} documents`);
// Store in Redis
const redis = new IORedis(process.env.REDIS_URL, { maxRetriesPerRequest: null });
const indexData: DocsSearchIndexData = {
documents,
lastUpdated: Date.now(),
};
await redis.set(REDIS_DOCS_KEY, JSON.stringify(indexData));
console.log(`[index-docs] Stored ${documents.length} documents in Redis (key: ${REDIS_DOCS_KEY})`);
await redis.quit();
console.log("[index-docs] Done.");
}
main().catch((err) => {
console.error("[index-docs] Fatal error:", err);
process.exit(1);
});
+101
View File
@@ -0,0 +1,101 @@
import dotenv from "dotenv";
dotenv.config();
import express from "express";
import Startup from "../src/lib/server/startup.ts";
import shutdownSchedulers from "../src/lib/server/schedulers/shutdown.ts";
import shutdownQueues from "../src/lib/server/queues/shutdown.ts";
import dbInstance from "../src/lib/server/db/db.ts";
import knex from "knex";
import knexOb from "../knexfile.js";
const PORT = process.env.PORT || 3000;
const base = process.env.KENER_BASE_PATH || "";
async function start() {
// Dynamic import so BODY_SIZE_LIMIT from .env is available
// before the handler reads it at module top-level
const { handler } = await import("../build/handler.js");
const app: any = express();
const db = knex(knexOb);
app.get(base + "/healthcheck", (req: any, res: any) => {
res.end("ok");
});
app.use(handler);
//migrations
async function runMigrations() {
try {
// Rename old .js migration entries to .ts in the knex_migrations table
// so Knex can find the renamed files on disk
const hasTable = await db.schema.hasTable("knex_migrations");
if (hasTable) {
const oldJsMigrations = await db("knex_migrations").where("name", "like", "%.js");
for (const row of oldJsMigrations) {
const newName = row.name.replace(/\.js$/, ".ts");
await db("knex_migrations").where("id", row.id).update({ name: newName });
console.log(`Renamed migration record: ${row.name} -> ${newName}`);
}
}
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 + "!");
});
// Graceful shutdown handler
async function gracefulShutdown(signal: string) {
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
try {
console.log("Shutting down schedulers...");
await shutdownSchedulers();
console.log("Schedulers shut down successfully.");
console.log("Shutting down queues...");
await shutdownQueues();
console.log("Queues shut down successfully.");
console.log("Closing database connection...");
await dbInstance.close();
console.log("Database connection closed successfully.");
console.log("Graceful shutdown completed.");
process.exit(0);
} catch (err) {
console.error("Error during graceful shutdown:", err);
process.exit(1);
}
}
// Handle termination signals
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
}
start();
+113
View File
@@ -0,0 +1,113 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { globSync } from "glob";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, "..");
const localesDir = path.join(projectRoot, "src", "lib", "locales");
function fail(message) {
throw new Error(message);
}
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function sortObjectKeysAscending(input) {
const keys = Object.keys(input).sort((a, b) => a.localeCompare(b));
const result = {};
for (const key of keys) {
result[key] = input[key];
}
return result;
}
function replaceMappingsPreserveTopLevelOrder(localeData, sortedMappings) {
const next = {};
let sawMappings = false;
for (const key of Object.keys(localeData)) {
if (key === "mappings") {
next[key] = sortedMappings;
sawMappings = true;
} else {
next[key] = localeData[key];
}
}
if (!sawMappings) {
next.mappings = sortedMappings;
}
return next;
}
function loadLocaleJson(localePath, localeFileName) {
const raw = fs.readFileSync(localePath, "utf8");
let parsed;
try {
parsed = JSON.parse(raw);
} catch (error) {
fail(`Invalid JSON in ${localeFileName}: ${error instanceof Error ? error.message : String(error)}`);
}
if (!isPlainObject(parsed)) {
fail(`Invalid locale file ${localeFileName}: expected a top-level object.`);
}
if (!isPlainObject(parsed.mappings)) {
fail(`Invalid locale file ${localeFileName}: expected "mappings" to be an object.`);
}
return parsed;
}
function sortTranslations() {
if (!fs.existsSync(localesDir)) {
fail(`Locales directory not found: ${localesDir}`);
}
const localeFiles = globSync("*.json", {
cwd: localesDir,
nodir: true,
}).sort((a, b) => a.localeCompare(b));
if (localeFiles.length === 0) {
fail(`No locale files found in ${localesDir}`);
}
let changedFilesCount = 0;
for (const localeFileName of localeFiles) {
const localePath = path.join(localesDir, localeFileName);
const localeData = loadLocaleJson(localePath, localeFileName);
const sortedMappings = sortObjectKeysAscending(localeData.mappings);
const nextLocaleData = replaceMappingsPreserveTopLevelOrder(localeData, sortedMappings);
const nextContent = `${JSON.stringify(nextLocaleData, null, 2)}\n`;
const currentContent = fs.readFileSync(localePath, "utf8");
if (nextContent !== currentContent) {
fs.writeFileSync(localePath, nextContent, "utf8");
changedFilesCount += 1;
console.log(`${localeFileName}: sorted mappings in ascending key order`);
} else {
console.log(`${localeFileName}: already sorted`);
}
}
console.log(`Done. Updated ${changedFilesCount} file${changedFilesCount === 1 ? "" : "s"}.`);
}
try {
sortTranslations();
} catch (error) {
console.error("Failed to sort translations.");
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
+15
View File
@@ -2,6 +2,7 @@ import subscriptionAccountCodeTemplate from "../src/lib/server/templates/general
import subscriptionUpdateTemplate from "../src/lib/server/templates/general/subscription_update_template.ts";
import forgotPasswordTemplate from "../src/lib/server/templates/general/forgot_password_template.ts";
import inviteUserTemplate from "../src/lib/server/templates/general/invite_user_template.ts";
import verifyEmailTemplate from "../src/lib/server/templates/general/verify_email_template.ts";
import type { Knex } from "knex";
export async function seed(knex: Knex): Promise<void> {
@@ -58,4 +59,18 @@ export async function seed(knex: Knex): Promise<void> {
template_text_body: inviteUserTemplate.template_text_body,
});
}
count = await knex("general_email_templates")
.where({ template_id: verifyEmailTemplate.template_id })
.count("template_id as CNT")
.first();
if (count && count.CNT == 0) {
await knex("general_email_templates").insert({
template_id: verifyEmailTemplate.template_id,
template_subject: verifyEmailTemplate.template_subject,
template_html_body: verifyEmailTemplate.template_html_body,
template_text_body: verifyEmailTemplate.template_text_body,
});
}
}
+3 -1
View File
@@ -22,7 +22,7 @@ export async function seed(knex: Knex): Promise<void> {
.returning("id");
// For the home page, add the default monitor (earth) if it exists
if (page.page_path === "/") {
if (page.page_path === "") {
const earthMonitor = await knex("monitors").where({ tag: "earth" }).first();
if (earthMonitor) {
const pageId = typeof insertedPage === "object" ? insertedPage.id : insertedPage;
@@ -30,6 +30,7 @@ export async function seed(knex: Knex): Promise<void> {
page_id: pageId,
monitor_tag: "earth",
monitor_settings_json: "",
position: 0,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
@@ -42,6 +43,7 @@ export async function seed(knex: Knex): Promise<void> {
page_id: pageId,
monitor_tag: "kener",
monitor_settings_json: "",
position: 1,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
+10
View File
@@ -0,0 +1,10 @@
{
"version": 1,
"skills": {
"ss-shadcn-svelte": {
"source": "rajnandan1/such-skills",
"sourceType": "github",
"computedHash": "0678d0cad0bce1d56731c9613e75f2d274810ad2a17ecea2d21434b6416c90b7"
}
}
}
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta property="og:locale" content="en_US" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+44 -14
View File
@@ -1,29 +1,26 @@
import { json, type Handle } from "@sveltejs/kit";
import { sequence } from "@sveltejs/kit/hooks";
import { VerifyAPIKey } from "$lib/server/controllers/apiController";
import db from "$lib/server/db/db";
import type { UnauthorizedResponse, NotFoundResponse } from "$lib/types/api";
import { initializeSearchIndex } from "$lib/server/docs-search";
import { GetMonitorsParsed } from "$lib/server/controllers/monitorsController";
// Initialize documentation search index at server startup
initializeSearchIndex();
const API_PATH_PREFIX = "/api/";
// Paths that don't require authentication
const PUBLIC_API_PATHS = ["/api/status", "/api/spec"];
const PUBLIC_API_PATHS = ["/api/status"];
// Regex to match routes with monitor_tag parameter
const MONITOR_TAG_ROUTE_REGEX = /^\/api\/monitors\/([^/]+)/;
const MONITOR_TAG_ROUTE_REGEX = /^\/api\/(?:v\d+\/)?monitors\/([^/]+)/;
// Regex to match routes with incident_id parameter
const INCIDENT_ID_ROUTE_REGEX = /^\/api\/incidents\/(\d+)/;
const INCIDENT_ID_ROUTE_REGEX = /^\/api\/(?:v\d+\/)?incidents\/(\d+)/;
// Regex to match routes with maintenance_id parameter
const MAINTENANCE_ID_ROUTE_REGEX = /^\/api\/maintenances\/(\d+)/;
const MAINTENANCE_ID_ROUTE_REGEX = /^\/api\/(?:v\d+\/)?maintenances\/(\d+)/;
// Regex to match routes with page_path parameter
const PAGE_PATH_ROUTE_REGEX = /^\/api\/pages\/([^/]+)/;
const PAGE_PATH_ROUTE_REGEX = /^\/api\/(?:v\d+\/)?pages\/([^/]+)/;
function isApiRoute(pathname: string): boolean {
return pathname.startsWith(API_PATH_PREFIX);
@@ -62,7 +59,38 @@ function extractPagePath(pathname: string): string | null {
return match ? decodeURIComponent(match[1]) : null;
}
export const handle: Handle = async ({ event, resolve }) => {
// Content types that indicate a form submission (mirrors SvelteKit's internal CSRF check scope)
const FORM_CONTENT_TYPES = ["application/x-www-form-urlencoded", "multipart/form-data", "text/plain"];
function isFormContentType(request: Request): boolean {
const type = request.headers.get("content-type")?.split(";", 1)[0].trim()?.toLowerCase() ?? "";
return FORM_CONTENT_TYPES.includes(type);
}
// Custom CSRF handler: validates Origin when present, allows requests when absent.
// When Origin is absent (e.g. Referrer-Policy: no-referrer), security relies on
// SameSite=Lax cookies which prevent cross-site POST from carrying auth cookies.
const csrfHandle: Handle = async ({ event, resolve }) => {
const { request } = event;
if (
isFormContentType(request) &&
(request.method === "POST" || request.method === "PUT" || request.method === "PATCH" || request.method === "DELETE")
) {
const requestOrigin = request.headers.get("origin");
if (requestOrigin && requestOrigin !== "null") {
const requestHost = new URL(requestOrigin).host;
const expectedHost = event.url.host;
if (requestHost !== expectedHost) {
return new Response(`Cross-site ${request.method} form submissions are forbidden`, { status: 403 });
}
}
}
return resolve(event);
};
const apiAuthHandle: Handle = async ({ event, resolve }) => {
const { pathname } = event.url;
// Check if this is an API route that requires authentication
@@ -91,7 +119,7 @@ export const handle: Handle = async ({ event, resolve }) => {
return json(errorResponse, { status: 401 });
}
// Validate monitor tag exists for /api/monitors/:monitor_tag/* routes
// Validate monitor tag exists for /api/(vX/)?monitors/:monitor_tag/* routes
const monitorTag = extractMonitorTag(pathname);
if (monitorTag) {
const monitor = await GetMonitorsParsed({ tag: monitorTag }).then((monitors) => monitors[0]);
@@ -108,7 +136,7 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.monitor = monitor;
}
// Validate incident_id exists for /api/incidents/:incident_id/* routes
// Validate incident_id exists for /api/(vX/)?incidents/:incident_id/* routes
const incidentId = extractIncidentId(pathname);
if (incidentId) {
const incident = await db.getIncidentById(incidentId);
@@ -125,7 +153,7 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.incident = incident;
}
// Validate maintenance_id exists for /api/maintenances/:maintenance_id/* routes
// Validate maintenance_id exists for /api/(vX/)?maintenances/:maintenance_id/* routes
const maintenanceId = extractMaintenanceId(pathname);
if (maintenanceId) {
const maintenance = await db.getMaintenanceById(maintenanceId);
@@ -142,7 +170,7 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.maintenance = maintenance;
}
// Validate page_path exists for /api/pages/:page_path/* routes
// Validate page_path exists for /api/(vX/)?pages/:page_path/* routes
const pagePath = extractPagePath(pathname);
if (pagePath) {
const page = await db.getPageByPath(pagePath);
@@ -164,3 +192,5 @@ export const handle: Handle = async ({ event, resolve }) => {
response.headers.delete("Link");
return response;
};
export const handle = sequence(csrfHandle, apiAuthHandle);
+63
View File
@@ -0,0 +1,63 @@
import GC from "$lib/global-constants";
type AlertTextKind = "label" | "help" | "description";
interface AlertTextInput {
kind: AlertTextKind;
alert_for: string;
alert_value?: string | number;
failure_threshold?: number;
success_threshold?: number;
}
export function getAlertText({
kind,
alert_for,
alert_value,
failure_threshold,
success_threshold,
}: AlertTextInput): string {
if (kind === "label") {
switch (alert_for) {
case GC.STATUS:
return "Status Value";
case GC.LATENCY:
return "Latency Threshold (ms)";
case GC.UPTIME:
return "Uptime Threshold (%)";
default:
return "Value";
}
}
if (kind === "help") {
switch (alert_for) {
case GC.STATUS:
return "Alert when monitor status equals this value";
case GC.LATENCY:
return "Alert when latency exceeds this value (in milliseconds)";
case GC.UPTIME:
return "Alert when uptime falls below this percentage";
default:
return "";
}
}
const thresholdValue = failure_threshold ?? 0;
const resolveThreshold = success_threshold ?? 0;
const value = alert_value ?? "";
if (alert_for === GC.STATUS) {
return `Alert when ${thresholdValue} consecutive checks result in ${value}. Resolve after ${resolveThreshold} successful check(s).`;
}
if (alert_for === GC.LATENCY) {
return `Alert when latency exceeds ${value}ms for ${thresholdValue} consecutive checks. Resolve after ${resolveThreshold} check(s) below threshold.`;
}
if (alert_for === GC.UPTIME) {
return `Alert when uptime falls below ${value}% for ${thresholdValue} checks. Resolve after ${resolveThreshold} check(s) above threshold.`;
}
return "";
}
+8 -8
View File
@@ -1,4 +1,4 @@
export const DefaultAPIEval = `(async function (statusCode, responseTime, responseRaw, modules) {
export const DefaultAPIEval = `async function (statusCode, responseTime, responseRaw, modules) {
let statusCodeShort = Math.floor(statusCode/100);
if(statusCode == 429 || (statusCodeShort >=2 && statusCodeShort <= 3)) {
return {
@@ -10,9 +10,9 @@ export const DefaultAPIEval = `(async function (statusCode, responseTime, respon
status: 'DOWN',
latency: responseTime,
}
})`;
}`;
export const DefaultPingEval = `(async function (arrayOfPings) {
export const DefaultPingEval = `async function (arrayOfPings) {
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
return acc + ping.latency;
}, 0);
@@ -25,9 +25,9 @@ export const DefaultPingEval = `(async function (arrayOfPings) {
status: alive ? 'UP' : 'DOWN',
latency: latencyTotal / arrayOfPings.length,
}
})`;
}`;
export const DefaultTCPEval = `(async function (arrayOfPings) {
export const DefaultTCPEval = `async function (arrayOfPings) {
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
return acc + ping.latency;
}, 0);
@@ -40,14 +40,14 @@ export const DefaultTCPEval = `(async function (arrayOfPings) {
status: alive ? 'UP' : 'DOWN',
latency: latencyTotal / arrayOfPings.length,
}
})`;
}`;
export const DefaultGamedigEval = `(async function (responseTime, responseRaw) {
export const DefaultGamedigEval = `async function (responseTime, responseRaw) {
return {
status: 'UP',
latency: responseTime,
}
})`;
}`;
export const GAMEDIG_TIMEOUT = 10 * 1000; // 10 seconds
export const GAMEDIG_SOCKET_TIMEOUT = 2 * 1000; // 2 seconds
+4 -2
View File
@@ -3,8 +3,9 @@ export interface AnalyticsEventDetail {
data: unknown;
}
export const analyticsEvent = (event: string, data: unknown): void => {
// Do something with the event and data
const analyticsEvent = (event: string, data: unknown): void => {
//log event data
// console.log("Analytics Event:", event, data);
if (typeof document !== "undefined") {
event = "kener_" + event;
@@ -19,3 +20,4 @@ export const analyticsEvent = (event: string, data: unknown): void => {
);
}
};
export default analyticsEvent;
+1 -19
View File
@@ -1,20 +1,12 @@
import { getUnixTime, startOfDay } from "date-fns";
import { toZonedTime, fromZonedTime } from "date-fns-tz";
/**
* Get current timestamp in UTC seconds
* @returns Current UTC timestamp in seconds (Unix timestamp)
*/
export function getNowTimestampUTC(): number {
return getUnixTime(new Date());
}
/**
* Get start of day timestamp for a given timezone
* @param timezone - IANA timezone string (e.g., "America/New_York")
* @returns Start of day in the given timezone as Unix timestamp (seconds)
*/
export function getStartOfDayAtTz(timezone: string): number {
function getStartOfDayAtTz(timezone: string): number {
const now = new Date();
const zonedTime = toZonedTime(now, timezone);
const startOfZonedDay = startOfDay(zonedTime);
@@ -30,13 +22,3 @@ export function getStartOfDayAtTz(timezone: string): number {
export function getEndOfDayAtTz(timezone: string): number {
return getStartOfDayAtTz(timezone) + 86400;
}
/**
* Get current timestamp in a given timezone
* @param timezone - IANA timezone string (e.g., "America/New_York")
* @returns Current time in the given timezone as Unix timestamp (seconds)
*/
export function getNowAtTz(timezone: string): number {
const now = new Date();
return getUnixTime(fromZonedTime(now, timezone));
}
+111
View File
@@ -0,0 +1,111 @@
import { browser } from "$app/environment";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
import type { MonitorBarResponse } from "$lib/server/api-server/monitor-bar/get";
interface MonitorBarsResponse {
data: Record<string, MonitorBarResponse>;
missingTags: string[];
}
interface Waiter {
resolve: (value: MonitorBarResponse) => void;
reject: (reason?: unknown) => void;
}
interface BatchState {
tags: Set<string>;
waitersByTag: Map<string, Waiter[]>;
timer: ReturnType<typeof setTimeout>;
}
const pendingBatches = new Map<string, BatchState>();
/** In-memory cache: survives across component mounts, cleared on page refresh */
const cache = new Map<string, MonitorBarResponse>();
const makeBatchKey = (days: number, endOfDayTodayAtTz: number): string => `${days}:${endOfDayTodayAtTz}`;
const makeCacheKey = (tag: string, days: number, endOfDayTodayAtTz: number): string =>
`${tag}:${days}:${endOfDayTodayAtTz}`;
const flushBatch = async (key: string): Promise<void> => {
const batch = pendingBatches.get(key);
if (!batch) return;
pendingBatches.delete(key);
const tags = Array.from(batch.tags);
const [daysStr, endTsStr] = key.split(":");
const days = Number(daysStr);
const endOfDayTodayAtTz = Number(endTsStr);
try {
const query = `?tags=${encodeURIComponent(tags.join(","))}&endOfDayTodayAtTz=${endOfDayTodayAtTz}&days=${days}`;
const response = await fetch(clientResolver(resolve, "/dashboard-apis/monitor-bars") + query);
if (!response.ok) {
throw new Error("Failed to fetch monitor bars");
}
const payload = (await response.json()) as MonitorBarsResponse;
for (const tag of tags) {
const waiters = batch.waitersByTag.get(tag) || [];
const item = payload.data[tag];
if (item) {
cache.set(makeCacheKey(tag, days, endOfDayTodayAtTz), item);
waiters.forEach((w) => w.resolve(item));
} else {
const missingError = new Error(`Monitor data not found for tag: ${tag}`);
waiters.forEach((w) => w.reject(missingError));
}
}
} catch (err) {
for (const waiters of batch.waitersByTag.values()) {
waiters.forEach((w) => w.reject(err));
}
}
};
export const requestMonitorBar = (
tag: string,
days: number,
endOfDayTodayAtTz: number,
): Promise<MonitorBarResponse> => {
if (!browser) {
return Promise.reject(new Error("requestMonitorBar can only be called in the browser"));
}
// Return cached data immediately if available
const cacheKey = makeCacheKey(tag, days, endOfDayTodayAtTz);
const cached = cache.get(cacheKey);
if (cached) {
return Promise.resolve(cached);
}
const key = makeBatchKey(days, endOfDayTodayAtTz);
return new Promise<MonitorBarResponse>((resolvePromise, rejectPromise) => {
let batch = pendingBatches.get(key);
if (!batch) {
batch = {
tags: new Set<string>(),
waitersByTag: new Map<string, Waiter[]>(),
timer: setTimeout(() => {
void flushBatch(key);
}, 0),
};
pendingBatches.set(key, batch);
}
batch.tags.add(tag);
const waiters = batch.waitersByTag.get(tag) || [];
waiters.push({ resolve: resolvePromise, reject: rejectPromise });
batch.waitersByTag.set(tag, waiters);
});
};
/** Evict all cached entries (useful if caller needs a forced refresh) */
export const clearMonitorBarCache = (): void => {
cache.clear();
};
-2
View File
@@ -1,3 +1 @@
// Client-only types (safe for +page.ts / components).
export type ThemeMode = "light" | "dark" | "system";
+10 -6
View File
@@ -362,21 +362,25 @@ function GetStatusBgColor(item: TimestampStatusCount): string {
let textColor = GetStatusColor(item);
return textColor.replace("text-", "bg-");
}
// Get initials from monitor name for avatar fallback
function GetInitials(name: string): string {
return name
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
export {
siteDataExtractFromDb,
AllRecordTypes,
ValidateIpAddress,
IsValidHost,
IsValidNameServer,
IsValidURL,
IsValidPort,
ValidateCronExpression,
SortMonitor,
RandomString,
GetGameFromId,
GetStatusSummary,
GetStatusColor,
GetStatusBgColor,
ParseLatency,
GetInitials,
};
@@ -1,188 +0,0 @@
<script lang="ts">
import { Badge } from "$lib/components/ui/badge/index.js";
import Clock from "lucide-svelte/icons/clock";
import CalendarClock from "lucide-svelte/icons/calendar-clock";
import Timer from "lucide-svelte/icons/timer";
import { t } from "$lib/stores/i18n";
import { formatDate, formatDuration } from "$lib/stores/datetime";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
interface Maintenance {
id: number;
title: string;
description?: string | null;
start_date_time: number;
end_date_time: number;
monitor_tag?: string;
}
interface Props {
ongoingMaintenances?: Maintenance[];
upcomingMaintenances?: Maintenance[];
pastMaintenances?: Maintenance[];
class?: string;
}
let {
ongoingMaintenances = [],
upcomingMaintenances = [],
pastMaintenances = [],
class: className = ""
}: Props = $props();
// Deduplicate maintenances by id (can have duplicates due to multiple monitors)
function deduplicateMaintenances(maintenances: Maintenance[]): Maintenance[] {
const seen = new Set<number>();
return maintenances.filter((m) => {
if (seen.has(m.id)) return false;
seen.add(m.id);
return true;
});
}
// Deduplicated arrays
let uniqueOngoing = $derived(deduplicateMaintenances(ongoingMaintenances));
let uniqueUpcoming = $derived(deduplicateMaintenances(upcomingMaintenances));
let uniquePast = $derived(deduplicateMaintenances(pastMaintenances));
// Check if there's any maintenance data
let hasAnyData = $derived(uniqueOngoing.length > 0 || uniqueUpcoming.length > 0 || uniquePast.length > 0);
</script>
{#if hasAnyData}
<div class="bg-background rounded-3xl border {className}">
<div class=" flex items-center justify-between p-4">
<Badge variant="secondary" class="gap-1">{$t("Maintenances")}</Badge>
</div>
<div class="grid grid-cols-3 gap-0">
<!-- Ongoing Maintenances -->
<div class="flex flex-col border-t border-r">
<div class="text-muted-foreground flex items-center justify-between gap-2 border-b p-4 text-sm font-medium">
<div class="flex items-center gap-2">
<div class="bg-maintenance h-2 w-2 rounded-full"></div>
{$t("Ongoing")}
</div>
<div class="text-maintenance">
<span>{uniqueOngoing.length}</span>
</div>
</div>
<div class="scrollbar-hidden max-h-64 overflow-y-auto">
{#if uniqueOngoing.length === 0}
<p class="text-muted-foreground py-4 text-center text-xs">{$t("No ongoing maintenances")}</p>
{:else}
{#each uniqueOngoing as maintenance (maintenance.id)}
<a
href={clientResolver(resolve, `/maintenances/${maintenance.id}`)}
class="hover:bg-muted/50 block border-b p-3 transition-colors last:border-0"
>
<h4 class="line-clamp-2 text-sm leading-tight font-medium">{maintenance.title}</h4>
{#if maintenance.description}
<p class="text-muted-foreground mt-1 line-clamp-3 text-xs leading-relaxed">
{maintenance.description}
</p>
{/if}
<div class="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span class="flex items-center gap-1">
<Clock class="h-3 w-3" />
{$formatDate(maintenance.start_date_time, "MMM d, HH:mm")}
</span>
<span class="flex items-center gap-1">
<Timer class="h-3 w-3" />
{$formatDuration(maintenance.start_date_time, maintenance.end_date_time)}
</span>
</div>
</a>
{/each}
{/if}
</div>
</div>
<!-- Upcoming Maintenances -->
<div class="flex flex-col border-t border-r">
<div class="text-muted-foreground flex items-center justify-between gap-2 border-b p-4 text-sm font-medium">
<div class="flex items-center gap-2">
<div class="bg-primary h-2 w-2 rounded-full"></div>
{$t("Upcoming")}
</div>
<div>
{uniqueUpcoming.length}
</div>
</div>
<div class="scrollbar-hidden max-h-64 overflow-y-auto">
{#if uniqueUpcoming.length === 0}
<p class="text-muted-foreground py-4 text-center text-xs">{$t("No upcoming maintenances")}</p>
{:else}
{#each uniqueUpcoming as maintenance (maintenance.id)}
<a
href={clientResolver(resolve, `/maintenances/${maintenance.id}`)}
class="hover:bg-muted/50 block border-b p-3 transition-colors last:border-0"
>
<h4 class="line-clamp-2 text-sm leading-tight font-medium">{maintenance.title}</h4>
{#if maintenance.description}
<p class="text-muted-foreground mt-1 line-clamp-3 text-xs leading-relaxed">
{maintenance.description}
</p>
{/if}
<div class="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span class="flex items-center gap-1">
<CalendarClock class="h-3 w-3" />
{$formatDate(maintenance.start_date_time, "MMM d, HH:mm")}
</span>
<span class="flex items-center gap-1">
<Timer class="h-3 w-3" />
{$formatDuration(maintenance.start_date_time, maintenance.end_date_time)}
</span>
</div>
</a>
{/each}
{/if}
</div>
</div>
<!-- Past Maintenances -->
<div class="flex flex-col border-t">
<div class="text-muted-foreground flex items-center justify-between gap-2 border-b p-4 text-sm font-medium">
<div class="flex items-center gap-2">
<div class="bg-muted-foreground h-2 w-2 rounded-full"></div>
{$t("Past")}
</div>
<div>
{uniquePast.length}
</div>
</div>
<div class="scrollbar-hidden max-h-64 overflow-y-auto">
{#if uniquePast.length === 0}
<p class="text-muted-foreground py-4 text-center text-xs">{$t("No past maintenances")}</p>
{:else}
{#each uniquePast as maintenance (maintenance.id)}
<a
href={clientResolver(resolve, `/maintenances/${maintenance.id}`)}
class="hover:bg-muted/50 block border-b p-3 transition-colors last:border-0"
>
<h4 class="line-clamp-2 text-sm leading-tight font-medium">{maintenance.title}</h4>
{#if maintenance.description}
<p class="text-muted-foreground mt-1 line-clamp-3 text-xs leading-relaxed">
{maintenance.description}
</p>
{/if}
<div class="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span class="flex items-center gap-1">
<Clock class="h-3 w-3" />
{$formatDate(maintenance.start_date_time, "MMM d, HH:mm")}
</span>
<span class="flex items-center gap-1">
<Timer class="h-3 w-3" />
{$formatDuration(maintenance.start_date_time, maintenance.end_date_time)}
</span>
</div>
</a>
{/each}
{/if}
</div>
</div>
</div>
</div>
{/if}
+131 -7
View File
@@ -5,19 +5,36 @@
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
import { t } from "$lib/stores/i18n";
import trackEvent from "$lib/beacon";
import { Button } from "$lib/components/ui/button/index.js";
import TrendingUp from "@lucide/svelte/icons/trending-up";
import Percent from "@lucide/svelte/icons/percent";
import Timer from "@lucide/svelte/icons/timer";
import Copy from "@lucide/svelte/icons/copy";
import Sticker from "@lucide/svelte/icons/sticker";
import { page } from "$app/state";
interface Props {
open: boolean;
monitorTag: string;
protocol: string;
domain: string;
}
let { open = $bindable(false), monitorTag, protocol, domain }: Props = $props();
let { protocol, domain }: Props = $props();
let open = $state(false);
let showMenu = $derived(
page.route.id === "/(kener)/monitors/[monitor_tag]" &&
!!page.params.monitor_tag &&
page.data.monitorSharingOptions?.showShareBadgeMonitor &&
page.data.subMenuOptions?.showShareBadgeMonitor
);
let monitorTag = page.params.monitor_tag;
function handleBadgeCopy(
type: "status" | "uptime" | "latency_avg" | "latency_max" | "latency_min" | "dot" | "dot_ping"
) {
trackEvent("badge_copied", { type, monitorTag });
}
// Badge URLs
const badgeStatusUrl = $derived(
@@ -26,6 +43,19 @@
const badgeUptimeUrl = $derived(
protocol && domain ? `${protocol}//${domain}` + clientResolver(resolve, `/badge/${monitorTag}/uptime`) : ""
);
const badgeLatencyAvgUrl = $derived(
protocol && domain ? `${protocol}//${domain}` + clientResolver(resolve, `/badge/${monitorTag}/latency`) : ""
);
const badgeLatencyMaxUrl = $derived(
protocol && domain
? `${protocol}//${domain}` + clientResolver(resolve, `/badge/${monitorTag}/latency?metric=maximum`)
: ""
);
const badgeLatencyMinUrl = $derived(
protocol && domain
? `${protocol}//${domain}` + clientResolver(resolve, `/badge/${monitorTag}/latency?metric=minimum`)
: ""
);
const badgeDotUrl = $derived(
protocol && domain ? `${protocol}//${domain}` + clientResolver(resolve, `/badge/${monitorTag}/dot`) : ""
);
@@ -36,6 +66,19 @@
);
</script>
{#if showMenu}
<Button
variant="outline"
class="bg-background/80 dark:bg-background/70 border-foreground/10 relative cursor-pointer rounded-full border shadow-none backdrop-blur-md"
size="icon-sm"
onclick={() => {
open = true;
trackEvent("badges_menu_opened", { source: "theme_plus" });
}}
>
<Sticker />
</Button>
{/if}
<Dialog.Root bind:open>
<Dialog.Overlay class="backdrop-blur-[2px]" />
<Dialog.Content class="max-w-md rounded-3xl">
@@ -59,7 +102,13 @@
</div>
<img src={badgeStatusUrl} alt="Status Badge" class="h-5" />
<CopyButton variant="ghost" size="icon-sm" text={badgeStatusUrl} class="rounded-btn">
<CopyButton
variant="ghost"
size="icon-sm"
text={badgeStatusUrl}
class="rounded-btn"
onclick={() => handleBadgeCopy("status")}
>
<Copy />
</CopyButton>
</div>
@@ -72,7 +121,70 @@
<span class="text-xs font-medium">{$t("Uptime Badge")}</span>
</div>
<img src={badgeUptimeUrl} alt="Uptime Badge" class="h-5" />
<CopyButton variant="ghost" size="icon-sm" text={badgeUptimeUrl} class="rounded-btn hover:bg-transparent">
<CopyButton
variant="ghost"
size="icon-sm"
text={badgeUptimeUrl}
class="rounded-btn hover:bg-transparent"
onclick={() => handleBadgeCopy("uptime")}
>
<Copy />
</CopyButton>
</div>
{/if}
{#if badgeLatencyAvgUrl}
<div class="flex items-center justify-between gap-2 rounded-3xl border px-2 py-1">
<div class="flex items-center gap-2">
<Timer class="h-3 w-3" />
<span class="text-xs font-medium">{$t("Avg Latency")}</span>
</div>
<img src={badgeLatencyAvgUrl} alt="Avg Latency Badge" class="h-5" />
<CopyButton
variant="ghost"
size="icon-sm"
text={badgeLatencyAvgUrl}
class="rounded-btn"
onclick={() => handleBadgeCopy("latency_avg")}
>
<Copy />
</CopyButton>
</div>
{/if}
{#if badgeLatencyMaxUrl}
<div class="flex items-center justify-between gap-2 rounded-3xl border px-2 py-1">
<div class="flex items-center gap-2">
<Timer class="h-3 w-3" />
<span class="text-xs font-medium">{$t("Max Latency")}</span>
</div>
<img src={badgeLatencyMaxUrl} alt="Max Latency Badge" class="h-5" />
<CopyButton
variant="ghost"
size="icon-sm"
text={badgeLatencyMaxUrl}
class="rounded-btn"
onclick={() => handleBadgeCopy("latency_max")}
>
<Copy />
</CopyButton>
</div>
{/if}
{#if badgeLatencyMinUrl}
<div class="flex items-center justify-between gap-2 rounded-3xl border px-2 py-1">
<div class="flex items-center gap-2">
<Timer class="h-3 w-3" />
<span class="text-xs font-medium">{$t("Min Latency")}</span>
</div>
<img src={badgeLatencyMinUrl} alt="Min Latency Badge" class="h-5" />
<CopyButton
variant="ghost"
size="icon-sm"
text={badgeLatencyMinUrl}
class="rounded-btn"
onclick={() => handleBadgeCopy("latency_min")}
>
<Copy />
</CopyButton>
</div>
@@ -89,7 +201,13 @@
<div class="flex items-center gap-2 rounded-3xl border px-2 py-1">
<img src={badgeDotUrl} alt="Status Dot" class="h-4" />
<span class="text-xs font-medium">{$t("Standard")}</span>
<CopyButton variant="ghost" size="icon-sm" text={badgeDotUrl} class="rounded-btn">
<CopyButton
variant="ghost"
size="icon-sm"
text={badgeDotUrl}
class="rounded-btn"
onclick={() => handleBadgeCopy("dot")}
>
<Copy />
</CopyButton>
</div>
@@ -98,7 +216,13 @@
<div class="flex items-center gap-2 rounded-3xl border px-2 py-1">
<img src={badgeDotPingUrl} alt="Status Dot Ping" class="h-4" />
<span class="text-xs font-medium">{$t("Pinging")}</span>
<CopyButton variant="ghost" size="icon-sm" text={badgeDotPingUrl} class="rounded-btn">
<CopyButton
variant="ghost"
size="icon-sm"
text={badgeDotPingUrl}
class="rounded-btn"
onclick={() => handleBadgeCopy("dot_ping")}
>
<Copy />
</CopyButton>
</div>
+141 -93
View File
@@ -2,31 +2,65 @@
import * as Dialog from "$lib/components/ui/dialog/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import * as ButtonGroup from "$lib/components/ui/button-group/index.js";
import CopyButton from "$lib/components/CopyButton.svelte";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
import { t } from "$lib/stores/i18n";
import { mode } from "mode-watcher";
import trackEvent from "$lib/beacon";
import { page } from "$app/state";
import Copy from "@lucide/svelte/icons/clipboard";
import Check from "@lucide/svelte/icons/check";
import Code from "@lucide/svelte/icons/code";
import ExternalLink from "@lucide/svelte/icons/external-link";
import Copy from "@lucide/svelte/icons/copy";
interface Props {
open: boolean;
monitorTag: string;
protocol: string;
domain: string;
}
let { open = $bindable(false), monitorTag, protocol, domain }: Props = $props();
let { protocol, domain }: Props = $props();
let open = $state(false);
let showMenu = $derived(
page.route.id === "/(kener)/monitors/[monitor_tag]" &&
!!page.params.monitor_tag &&
page.data.monitorSharingOptions?.showShareEmbedMonitor &&
page.data.subMenuOptions?.showShareEmbedMonitor
);
let monitorTag = page.params.monitor_tag;
let monitorTheme = $state<"light" | "dark">(mode.current === "dark" ? "dark" : "light");
let monitorEmbedType = $state<"script" | "iframe">("iframe");
let latencyTheme = $state<"light" | "dark">(mode.current === "dark" ? "dark" : "light");
let latencyEmbedType = $state<"script" | "iframe">("iframe");
function setMonitorTheme(theme: "light" | "dark") {
monitorTheme = theme;
trackEvent("embed_theme_changed", { target: "monitor", theme, monitorTag });
}
function setMonitorEmbedType(type: "script" | "iframe") {
monitorEmbedType = type;
trackEvent("embed_format_changed", { target: "monitor", format: type, monitorTag });
}
function setLatencyTheme(theme: "light" | "dark") {
latencyTheme = theme;
trackEvent("embed_theme_changed", { target: "latency", theme, monitorTag });
}
function setLatencyEmbedType(type: "script" | "iframe") {
latencyEmbedType = type;
trackEvent("embed_format_changed", { target: "latency", format: type, monitorTag });
}
function handleEmbedCopy(target: "monitor" | "latency") {
const theme = target === "monitor" ? monitorTheme : latencyTheme;
const format = target === "monitor" ? monitorEmbedType : latencyEmbedType;
trackEvent("embed_code_copied", { target, theme, format, monitorTag });
}
// Monitor Embed URL
const monitorEmbedUrl = $derived(() => {
if (!protocol || !domain) return "";
@@ -78,9 +112,22 @@
});
</script>
{#if showMenu}
<Button
variant="outline"
class="bg-background/80 dark:bg-background/70 border-foreground/10 relative cursor-pointer rounded-full border shadow-none backdrop-blur-md"
size="icon-sm"
onclick={() => {
open = true;
trackEvent("embed_menu_opened", { source: "theme_plus" });
}}
>
<Code />
</Button>
{/if}
<Dialog.Root bind:open>
<Dialog.Overlay class="backdrop-blur-[2px]" />
<Dialog.Content class="min-w-157.5 rounded-3xl">
<Dialog.Content class="max-w-2xl rounded-3xl">
<Dialog.Header>
<Dialog.Title>{$t("Embed Monitor")}</Dialog.Title>
<Dialog.Description>{$t("Embed this monitor in your website or app")}</Dialog.Description>
@@ -90,53 +137,54 @@
<!-- Status Embed -->
<div>
<Label class="mb-2 block text-sm font-semibold">{$t("Status Embed")}</Label>
<div class="flex flex-col gap-4 rounded-3xl border p-4">
<div class="flex flex-col gap-3 rounded-3xl border p-4">
<iframe title="status embed preview" src={monitorPreviewUrl()} width="100%" height="70" frameborder="0"
></iframe>
<div class="flex items-center justify-between gap-4 p-2">
<div>
<Label class="mb-2 block text-xs">{$t("Theme")}</Label>
<div class="flex gap-2">
<Button
variant={monitorTheme === "light" ? "default" : "outline"}
size="sm"
onclick={() => (monitorTheme = "light")}
>
{$t("Light")}
</Button>
<Button
variant={monitorTheme === "dark" ? "default" : "outline"}
size="sm"
onclick={() => (monitorTheme = "dark")}
>
{$t("Dark")}
</Button>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<ButtonGroup.Root class="rounded-btn-grp">
<Button variant="outline" size="sm" onclick={() => setMonitorTheme("light")}>
{#if monitorTheme === "light"}
<Check class="text-accent-foreground h-3 w-3" />
{/if}
{$t("Light")}
</Button>
<Button variant="outline" size="sm" onclick={() => setMonitorTheme("dark")}>
{#if monitorTheme === "dark"}
<Check class="text-accent-foreground h-3 w-3" />
{/if}
{$t("Dark")}
</Button>
</ButtonGroup.Root>
</div>
<div class="flex items-center gap-2">
<ButtonGroup.Root class="rounded-btn-grp">
<Button variant="outline" size="sm" onclick={() => setMonitorEmbedType("iframe")}>
{#if monitorEmbedType === "iframe"}
<Check class="text-accent-foreground h-3 w-3" />
{/if}
{$t("iFrame")}
</Button>
<Button variant="outline" size="sm" onclick={() => setMonitorEmbedType("script")}>
{#if monitorEmbedType === "script"}
<Check class="text-accent-foreground h-3 w-3" />
{/if}
{$t("Script")}
</Button>
</ButtonGroup.Root>
</div>
</div>
<div>
<Label class="mb-2 block text-xs">{$t("Format")}</Label>
<div class="flex gap-2">
<Button
variant={monitorEmbedType === "iframe" ? "default" : "outline"}
size="sm"
onclick={() => (monitorEmbedType = "iframe")}
>
{$t("iFrame")}
</Button>
<Button
variant={monitorEmbedType === "script" ? "default" : "outline"}
size="sm"
onclick={() => (monitorEmbedType = "script")}
>
{$t("Script")}
</Button>
</div>
</div>
<div class="flex items-center gap-2">
<CopyButton variant="ghost" size="icon-sm" text={monitorEmbedCode()} class="rounded-btn">
<Copy class="h-3 w-3" />
</CopyButton>
</div>
<CopyButton
variant="outline"
class="rounded-btn"
size="icon-sm"
text={monitorEmbedCode()}
onclick={() => handleEmbedCopy("monitor")}
>
<Copy />
</CopyButton>
</div>
</div>
</div>
@@ -144,53 +192,53 @@
<!-- Latency Embed -->
<div>
<Label class="mb-2 block text-sm font-semibold">{$t("Latency Embed")}</Label>
<div class="flex flex-col gap-4 rounded-3xl border p-4">
<div class="flex flex-col gap-3 rounded-3xl border p-4">
<iframe title="latency embed preview" src={latencyPreviewUrl()} width="100%" height="200" frameborder="0"
></iframe>
<div class="flex items-end justify-between gap-4 p-2">
<div>
<Label class="mb-2 block text-xs">{$t("Theme")}</Label>
<div class="flex gap-2">
<Button
variant={latencyTheme === "light" ? "default" : "outline"}
size="sm"
onclick={() => (latencyTheme = "light")}
>
{$t("Light")}
</Button>
<Button
variant={latencyTheme === "dark" ? "default" : "outline"}
size="sm"
onclick={() => (latencyTheme = "dark")}
>
{$t("Dark")}
</Button>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<ButtonGroup.Root class="rounded-btn-grp">
<Button variant="outline" size="sm" onclick={() => setLatencyTheme("light")}>
{#if latencyTheme === "light"}
<Check class="text-accent-foreground h-3 w-3" />
{/if}
{$t("Light")}
</Button>
<Button variant="outline" size="sm" onclick={() => setLatencyTheme("dark")}>
{#if latencyTheme === "dark"}
<Check class="text-accent-foreground h-3 w-3" />
{/if}
{$t("Dark")}
</Button>
</ButtonGroup.Root>
</div>
<div class="flex items-center gap-2">
<ButtonGroup.Root class="rounded-btn-grp">
<Button variant="outline" size="sm" onclick={() => setLatencyEmbedType("iframe")}>
{#if latencyEmbedType === "iframe"}
<Check class="text-accent-foreground h-3 w-3" />
{/if}
{$t("iFrame")}
</Button>
<Button variant="outline" size="sm" onclick={() => setLatencyEmbedType("script")}>
{#if latencyEmbedType === "script"}
<Check class="text-accent-foreground h-3 w-3" />
{/if}
{$t("Script")}
</Button>
</ButtonGroup.Root>
</div>
</div>
<div class="">
<Label class="mb-2 block text-xs">{$t("Format")}</Label>
<div class="flex gap-2">
<Button
variant={latencyEmbedType === "iframe" ? "default" : "outline"}
size="sm"
onclick={() => (latencyEmbedType = "iframe")}
>
{$t("iFrame")}
</Button>
<Button
variant={latencyEmbedType === "script" ? "default" : "outline"}
size="sm"
onclick={() => (latencyEmbedType = "script")}
>
{$t("Script")}
</Button>
</div>
</div>
<div class="flex items-center gap-2">
<CopyButton variant="ghost" size="icon-sm" text={latencyEmbedCode()} class="rounded-btn">
<Copy class="h-3 w-3" />
</CopyButton>
</div>
<CopyButton
variant="outline"
class="rounded-btn"
size="icon-sm"
text={latencyEmbedCode()}
onclick={() => handleEmbedCopy("latency")}
>
<Copy />
</CopyButton>
</div>
</div>
</div>
+6 -70
View File
@@ -4,90 +4,26 @@
import { t } from "$lib/stores/i18n";
interface Props {
ongoingMaintenancesCount: number;
ongoingIncidentsCount: number;
upcomingMaintenancesCount: number;
statusClass: string;
statusText: string;
pageSettings: PageSettingsType | null;
}
let {
ongoingMaintenancesCount,
ongoingIncidentsCount,
upcomingMaintenancesCount,
statusClass,
statusText,
pageSettings
}: Props = $props();
let { statusClass, statusText }: Props = $props();
// Compute change info (direction, color, and formatted value)
//is maintenance show enabled
let showMaintenance = $derived(pageSettings?.include_maintenances.enabled ?? false);
let showIncidents = $derived(pageSettings?.incidents.enabled ?? false);
let showAny = $derived(showMaintenance || showIncidents);
</script>
<div class="flex gap-3">
<div class="flex h-20 flex-row justify-start gap-y-3 rounded-3xl border p-4">
<div class="flex flex-row items-center gap-4">
<div class="flex flex-col gap-3 lg:flex-row">
<div class="flex min-h-20 w-full flex-row justify-start gap-y-3 rounded-3xl border p-4 lg:w-full lg:min-w-72">
<div class="flex w-full flex-row items-center gap-4">
<div class="relative flex justify-between">
<span class="relative flex size-4">
<span class="{statusClass} absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
<span class="{statusClass} relative inline-flex size-4 rounded-full"></span>
</span>
</div>
<div class="flex flex-col items-start gap-2">
<p class=" text-2xl">{$t(statusText)}</p>
<div class="flex min-w-0 flex-col items-start gap-2">
<p class="text-xl leading-tight wrap-break-word sm:text-2xl">{$t(statusText)}</p>
</div>
</div>
</div>
{#if showAny}
<div class="flex h-20 flex-1 flex-col justify-around gap-y-4 rounded-3xl border p-4">
<div class=" flex gap-x-3">
{#if showIncidents}
<div class="flex flex-1 flex-row items-center gap-2">
{#if ongoingIncidentsCount === 0}
<Check class="text-up" />
{:else}
<p class="text-3xl">
{ongoingIncidentsCount}
</p>
{/if}
<p class="text-xs leading-4 font-medium">
{@html $t("Ongoing <br /> Incidents")}
</p>
</div>
{/if}
{#if showMaintenance}
<div class="flex flex-1 flex-row items-center gap-2">
{#if ongoingMaintenancesCount === 0}
<Check class="text-up" />
{:else}
<p class="text-3xl">
{ongoingMaintenancesCount}
</p>
{/if}
<p class="text-xs leading-4 font-medium">
{@html $t("Ongoing <br /> Maintenances")}
</p>
</div>
<div class="flex flex-1 flex-row items-center gap-2">
{#if upcomingMaintenancesCount === 0}
<Check class="text-up" />
{:else}
<p class="text-3xl">
{upcomingMaintenancesCount}
</p>
{/if}
<p class="text-xs leading-4 font-medium">
{@html $t("Upcoming <br /> Maintenances")}
</p>
</div>
{/if}
</div>
</div>
{/if}
</div>
@@ -0,0 +1,69 @@
<script lang="ts">
import { browser } from "$app/environment";
import type { Snippet } from "svelte";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import * as Drawer from "$lib/components/ui/drawer/index.js";
import { requestMonitorBar } from "$lib/client/monitor-bar-client";
import type { MonitorBarResponse } from "$lib/server/api-server/monitor-bar/get";
import MonitorBar from "$lib/components/MonitorBar.svelte";
import { t } from "$lib/stores/i18n";
interface Props {
tags: string[];
days: number;
endOfDayTodayAtTz: number;
children: Snippet;
}
let { tags, days, endOfDayTodayAtTz, children }: Props = $props();
let isOpen = $state(false);
let monitorBarPromiseByTag = $derived.by(() => {
if (!browser || !isOpen || tags.length === 0) {
return {} as Partial<Record<string, Promise<MonitorBarResponse>>>;
}
return Object.fromEntries(tags.map((tag) => [tag, requestMonitorBar(tag, days, endOfDayTodayAtTz)])) as Partial<
Record<string, Promise<MonitorBarResponse>>
>;
});
</script>
<div class="w-full">
<Drawer.Root bind:open={isOpen} direction="bottom">
<Drawer.Trigger
class={buttonVariants({
variant: "ghost",
size: "sm",
class: "rounded-btn bg-secondary w-full text-xs"
})}
>
{@render children()}
</Drawer.Trigger>
<Drawer.Content class="max-h-[80vh]">
<Drawer.Header>
<Drawer.Title>{$t("Included Monitors (%count)", { count: String(tags.length) })}</Drawer.Title>
</Drawer.Header>
<div class="scrollbar-hidden flex flex-col overflow-y-auto px-4 pb-4">
{#if tags.length === 0}
<div class="text-muted-foreground p-4 text-sm">{$t("No monitors available.")}</div>
{:else}
{#each tags as tag, i (tag)}
<div class="{i < tags.length - 1 ? 'border-b' : ''} py-2 pb-4">
{#if monitorBarPromiseByTag[tag]}
{#await monitorBarPromiseByTag[tag]}
<MonitorBar {tag} />
{:then monitorBarData}
<MonitorBar {tag} prefetchedData={monitorBarData} />
{:catch err}
<MonitorBar {tag} prefetchedError={err instanceof Error ? err.message : "Unknown error"} />
{/await}
{:else}
<MonitorBar {tag} />
{/if}
</div>
{/each}
{/if}
</div>
</Drawer.Content>
</Drawer.Root>
</div>
+141 -123
View File
@@ -5,162 +5,180 @@
import ArrowRight from "@lucide/svelte/icons/arrow-right";
import * as Popover from "$lib/components/ui/popover/index.js";
import * as Avatar from "$lib/components/ui/avatar/index.js";
import STATUS_ICON from "$lib/icons";
import { t } from "$lib/stores/i18n";
import { formatDate, formatDuration } from "$lib/stores/datetime";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
interface IncidentMonitorImpact {
monitor_tag: string;
monitor_impact: string;
monitor_name: string;
monitor_image: string | null;
}
interface Incident {
id: number;
title: string;
monitors: IncidentMonitorImpact[];
start_date_time: number;
end_date_time?: number | null;
}
import { GetInitials } from "$lib/clientTools.js";
import type { IncidentCommentRecord, IncidentForMonitorListWithComments } from "$lib/server/types/db";
import { SveltePurify } from "@humanspeak/svelte-purify";
import mdToHTML from "$lib/marked";
import { slide } from "svelte/transition";
import { page } from "$app/state";
interface Props {
incident: Incident;
incident: IncidentForMonitorListWithComments;
class?: string;
hideMonitors?: boolean;
showComments?: boolean;
showSummary?: boolean;
}
let { incident, class: className = "", hideMonitors = false }: Props = $props();
const STATUS_STROKE = {
UP: "stroke-up",
DOWN: "stroke-down",
DEGRADED: "stroke-degraded",
MAINTENANCE: "stroke-maintenance",
NO_DATA: "stroke-muted-foreground"
} as const;
// Get the highest severity impact from monitors array
function getHighestImpact(monitors: IncidentMonitorImpact[]): keyof typeof STATUS_ICON {
const priority: (keyof typeof STATUS_ICON)[] = ["DOWN", "DEGRADED", "MAINTENANCE"];
for (const impact of priority) {
if (monitors.some((m) => m.monitor_impact === impact)) {
return impact;
}
}
return (monitors[0]?.monitor_impact as keyof typeof STATUS_ICON) || "NO_DATA";
}
// Get initials from monitor name for avatar fallback
function getInitials(name: string): string {
return name
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
const highestImpact = $derived(getHighestImpact(incident.monitors));
const Icon = $derived(STATUS_ICON[highestImpact]);
const strokeClass = $derived(STATUS_STROKE[highestImpact as keyof typeof STATUS_STROKE] || "stroke-down");
let {
incident,
class: className = "",
hideMonitors = false,
showComments = true,
showSummary = true
}: Props = $props();
// Calculate duration between start and end (or now if ongoing)
// If ongoing, use current timestamp for duration calculation
const endTimeForDuration = $derived(incident.end_date_time ?? Math.floor(Date.now() / 1000));
const isEmbedded = page.route.id?.includes("(embed)");
const target = isEmbedded ? "_blank" : "_self";
</script>
<Item.Root class="p-0 {className}">
<Item.Media>
<Icon class="size-6 {strokeClass}" />
</Item.Media>
<Item.Content>
<div class="flex items-center gap-2">
<Item.Title>{incident.title}</Item.Title>
<Item.Root class="items-start p-0 {className} sm:items-center">
<Item.Content class="min-w-0 flex-1">
<div class="flex flex-col items-start justify-start gap-0.5">
<span class="text-xs font-medium text-{incident.state.toLowerCase()}">{$t(incident.state)}</span>
<Item.Title class="min-w-0 text-base wrap-break-word break-all">
<a {target} class="hover:underline" href={clientResolver(resolve, `/incidents/${incident.id}`)}>
{incident.title}
</a>
</Item.Title>
</div>
{#if incident.monitors && incident.monitors.length > 0 && !hideMonitors}
<div class="*:data-[slot=avatar]:ring-background my-1 flex -space-x-2 *:data-[slot=avatar]:ring-2">
{#each incident.monitors as monitor}
<Popover.Root>
<Popover.Trigger>
<Avatar.Root
class="bg-background size-8 cursor-pointer border-2 border-{monitor.monitor_impact.toLowerCase()} transition-transform duration-100 ease-in-out hover:scale-[1.1] hover:border"
<div class="my-1 overflow-x-auto p-1">
<div class="flex gap-2">
{#each incident.monitors as monitor (`${incident.id}-${monitor.monitor_tag}`)}
<Popover.Root>
<Popover.Trigger disabled={isEmbedded}>
<Badge
variant="outline"
class="border-{monitor.monitor_impact.toLowerCase()} cursor-pointer rounded-none border-0 border-b px-0 text-sm font-normal"
>
{monitor.monitor_name}
</Badge>
</Popover.Trigger>
<Popover.Content
class="bg-background/60 border-border w-64 rounded-3xl border shadow-2xl backdrop-blur-xl"
>
{#if monitor.monitor_image}
<Avatar.Image
src={clientResolver(resolve, monitor.monitor_image)}
alt={monitor.monitor_name}
class="object-cover"
/>
{/if}
<Avatar.Fallback class="text-xs">{getInitials(monitor.monitor_name)}</Avatar.Fallback>
</Avatar.Root>
</Popover.Trigger>
<Popover.Content class="w-64">
<div class="flex flex-col gap-3">
<div class="flex items-center gap-3">
<Avatar.Root>
{#if monitor.monitor_image}
<Avatar.Image src={clientResolver(resolve, monitor.monitor_image)} alt={monitor.monitor_name} />
{/if}
<Avatar.Fallback>{getInitials(monitor.monitor_name)}</Avatar.Fallback>
</Avatar.Root>
<div class="flex flex-col">
<span class="font-medium">{monitor.monitor_name}</span>
<span class="text-muted-foreground text-xs">{monitor.monitor_tag}</span>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-3">
<Avatar.Root>
{#if monitor.monitor_image}
<Avatar.Image src={clientResolver(resolve, monitor.monitor_image)} alt={monitor.monitor_name} />
{/if}
<Avatar.Fallback>{GetInitials(monitor.monitor_name)}</Avatar.Fallback>
</Avatar.Root>
<div class="flex flex-col">
<span class="font-medium">{monitor.monitor_name}</span>
<span class="text-muted-foreground text-xs">{monitor.monitor_tag}</span>
</div>
</div>
<div class="flex items-center justify-between">
<Badge variant="outline" class="text-{monitor.monitor_impact.toLowerCase()}">
{$t(monitor.monitor_impact)}
</Badge>
<Button
variant="outline"
class="rounded-btn"
size="icon-sm"
href={clientResolver(resolve, `/monitors/${monitor.monitor_tag}`)}
>
<ArrowRight class="size-3" />
</Button>
</div>
</div>
<div class="flex items-center justify-between">
<Badge variant="outline" class="text-{monitor.monitor_impact.toLowerCase()}">
{$t(monitor.monitor_impact)}
</Badge>
<Button
variant="outline"
class="rounded-btn"
size="icon-sm"
href={clientResolver(resolve, `/monitors/${monitor.monitor_tag}`)}
>
<ArrowRight class="size-3" />
</Button>
</div>
</div>
</Popover.Content>
</Popover.Root>
{/each}
</Popover.Content>
</Popover.Root>
{/each}
</div>
</div>
{/if}
<Item.Description class="mt-2 flex items-center justify-between text-xs font-medium">
<span class="rounded-full border px-3 py-2">
{$formatDate(incident.start_date_time, "PPp")}
<Item.Description
class="mt-2 flex w-full flex-col gap-2 text-xs font-medium sm:flex-row sm:items-center sm:justify-between"
>
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
{$formatDate(incident.start_date_time, page.data.dateAndTimeFormat.datePlusTime)}
</span>
<span class="relative flex-1 text-center">
<span class="absolute top-1/2 right-0 left-0 border-t"></span>
<span class="bg-background relative z-10 px-2 py-1"
>{$formatDuration(incident.start_date_time, endTimeForDuration)}</span
>
<span class="relative w-full text-center sm:flex-1">
<span
class="absolute top-0 bottom-0 left-1/2 border-l sm:top-1/2 sm:right-0 sm:bottom-auto sm:left-0 sm:border-t sm:border-l-0"
></span>
<span class="bg-background relative z-10 rounded-full px-0 py-1 sm:px-2">
{$formatDuration(incident.start_date_time, endTimeForDuration)}
</span>
</span>
{#if incident.end_date_time}
<span class="rounded-full border px-3 py-2">
{$formatDate(incident.end_date_time, "PPp")}
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
{$formatDate(incident.end_date_time, page.data.dateAndTimeFormat.datePlusTime)}
</span>
{:else}
<span class="rounded-full border px-3 py-2">
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
{$t("Ongoing")}
</span>
{/if}
</Item.Description>
{#if showSummary}
<div class="my-2 grid grid-cols-1 gap-4 text-xs font-medium sm:grid-cols-3">
<div class="text-muted-foreground bg-secondary flex items-center justify-between rounded-full border p-2 px-4">
<span>{$t("Last Updated")}</span>
<span>{$formatDate(incident.updated_at, page.data.dateAndTimeFormat.datePlusTime)}</span>
</div>
<div class="text-muted-foreground bg-secondary flex items-center justify-between rounded-full border p-2 px-4">
<span>{$t("Status")}</span>
<div class="flex items-center gap-2">
<span class="text-{incident.state.toLowerCase()}">
{$t(incident.state)}
</span>
</div>
</div>
<div
class="text-muted-foreground bg-secondary flex items-center justify-between gap-2 rounded-full border p-2 px-4"
>
<span>
{incident.comments && incident.comments.length > 0
? `${incident.comments.length} ${$t("Updates")}`
: $t("No Updates")}
</span>
<Button
variant="outline"
size="icon-sm"
class="rounded-btn -mr-2 {isEmbedded ? 'hidden' : ''}"
onclick={() => (showComments = !showComments)}
>
<ArrowRight class={`transition-transform duration-200 ${showComments ? "rotate-90" : ""}`} />
</Button>
</div>
</div>
{/if}
{#if showSummary && showComments && incident.comments && incident.comments.length > 0}
<div transition:slide={{ duration: 220 }} class=" flex flex-col gap-4">
{#each incident.comments as comment (comment.id)}
<div class="flex flex-col gap-2 border-b pb-4 last:border-b-0 last:pb-0">
<div class="flex justify-start gap-2">
<Badge variant="outline" class="text-{comment.state.toLowerCase()} rounded-none border-0 p-0">
{$t(comment.state)}
</Badge>
<span class="text-muted-foreground text-xs">
{$formatDate(comment.commented_at, page.data.dateAndTimeFormat.datePlusTime)}
</span>
</div>
<div
class="prose prose-sm dark:prose-invert max-w-none min-w-0 overflow-x-auto wrap-break-word"
style="font-size: 14px;"
>
<SveltePurify html={mdToHTML(comment.comment)} />
</div>
</div>
{/each}
</div>
{/if}
</Item.Content>
<Item.Actions>
<Button
variant="outline"
class="cursor-pointer rounded-full shadow-none"
href={clientResolver(resolve, `/incidents/${incident.id}`)}
size="icon"
>
<ArrowRight />
</Button>
</Item.Actions>
</Item.Root>
+6 -29
View File
@@ -1,24 +1,10 @@
<script lang="ts">
import { Badge } from "$lib/components/ui/badge/index.js";
import IncidentItem from "$lib/components/IncidentItem.svelte";
interface IncidentMonitorImpact {
monitor_tag: string;
monitor_impact: string;
monitor_name: string;
monitor_image: string | null;
}
interface Incident {
id: number;
title: string;
monitors: IncidentMonitorImpact[];
start_date_time: number;
end_date_time?: number | null;
}
import type { IncidentForMonitorListWithComments } from "$lib/server/types/db";
interface Props {
incidents: Incident[];
incidents: IncidentForMonitorListWithComments[];
title: string;
class?: string;
}
@@ -26,17 +12,8 @@
let { incidents, title, class: className = "" }: Props = $props();
</script>
{#if incidents && incidents.length > 0}
<div class="bg-background rounded-3xl border p-0 {className}">
<div class="flex items-center justify-between p-4">
<Badge variant="secondary" class="gap-1">{title}</Badge>
</div>
<div class="">
{#each incidents as incident (incident.id)}
<div class="border-b p-4 last:border-b-0">
<IncidentItem {incident} />
</div>
{/each}
</div>
{#each incidents as incident (incident.id)}
<div class=" rounded-3xl border p-3 sm:p-4">
<IncidentItem {incident} />
</div>
{/if}
{/each}
+76 -10
View File
@@ -1,23 +1,46 @@
<script lang="ts">
import { page } from "$app/state";
import * as NavigationMenu from "$lib/components/ui/navigation-menu/index.js";
import * as Dropdown from "$lib/components/ui/dropdown-menu/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { navigationMenuTriggerStyle } from "$lib/components/ui/navigation-menu/navigation-menu-trigger.svelte";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
import trackEvent from "$lib/beacon";
import MenuIcon from "@lucide/svelte/icons/menu";
let { data } = page;
const navItems: { name: string; url: string; iconURL: string }[] = data.navItems || [];
const { siteName, siteUrl, logo } = data;
const { siteName, logo, globalPageVisibilitySettings } = data;
const brandPath = $derived.by(() => {
if (globalPageVisibilitySettings?.forceExclusivity) {
const currentPagePath = page.params?.page_path?.trim();
return currentPagePath ? `/${currentPagePath}` : "/";
}
return "/";
});
function trackBrandClick() {
trackEvent("nav_brand_clicked", { name: siteName });
}
function trackNavClick(item: { name: string; url: string }) {
trackEvent("nav_link_clicked", { name: item.name, external: item.url.startsWith("http") });
}
</script>
<div class="fixed inset-x-0 top-0 z-10 py-2">
<div class="mx-auto max-w-5xl">
<div class="bg-background flex items-center justify-between rounded-3xl border p-1">
<div class="mx-auto max-w-5xl px-4">
<div
class="bg-background/80 dark:bg-background/70 flex items-center justify-between rounded-3xl border p-1 backdrop-blur-md"
>
<!-- Brand -->
<a
href={clientResolver(resolve, siteUrl)}
class="{navigationMenuTriggerStyle()} hover:border-border border border-transparent text-xs hover:bg-transparent"
href={clientResolver(resolve, brandPath)}
class="{navigationMenuTriggerStyle()} hover:border-border border border-transparent bg-transparent text-xs hover:bg-transparent"
style="border-radius: var(--radius-3xl)"
onclick={trackBrandClick}
>
{#if logo}
<img src={clientResolver(resolve, logo)} alt={siteName} class="mr-2 h-6 w-6 rounded-full object-cover" />
@@ -25,22 +48,28 @@
{siteName}
</a>
<!-- Nav Links -->
<NavigationMenu.Root>
<!-- Desktop Nav Links -->
<NavigationMenu.Root class="hidden sm:block">
<NavigationMenu.List>
{#each navItems as item}
{#each navItems as item (item.url)}
<NavigationMenu.Item>
<NavigationMenu.Link>
{#snippet child()}
<a
data-sveltekit-preload-data="off"
href={clientResolver(resolve, item.url)}
class="{navigationMenuTriggerStyle()} hover:border-border border border-transparent text-xs hover:bg-transparent"
class="{navigationMenuTriggerStyle()} hover:border-border border border-transparent bg-transparent text-xs hover:bg-transparent"
target={item.url.startsWith("http") ? "_blank" : undefined}
rel={item.url.startsWith("http") ? "noopener noreferrer" : undefined}
style="border-radius: var(--radius-3xl)"
onclick={() => trackNavClick(item)}
>
{#if item.iconURL}
<img src={clientResolver(resolve, item.iconURL)} alt={item.name} class="mr-2 h-4 w-4" />
<img
src={clientResolver(resolve, item.iconURL)}
alt={item.name}
class="mr-2 h-4 w-4 object-cover"
/>
{/if}
{item.name}
</a>
@@ -50,6 +79,43 @@
{/each}
</NavigationMenu.List>
</NavigationMenu.Root>
<!-- Mobile Nav -->
{#if navItems.length > 0}
<Dropdown.Root>
<Dropdown.Trigger>
{#snippet child({ props })}
<button
{...props}
type="button"
class="{navigationMenuTriggerStyle()} hover:border-border border border-transparent bg-transparent text-xs hover:bg-transparent sm:hidden"
style="border-radius: var(--radius-3xl)"
aria-label="Open navigation menu"
>
<MenuIcon class="h-4 w-4" />
</button>
{/snippet}
</Dropdown.Trigger>
<Dropdown.Content align="end" class="w-56 rounded-3xl p-2">
{#each navItems as item (item.url)}
<Button
variant="ghost"
size="sm"
href={clientResolver(resolve, item.url)}
class="w-full justify-start rounded-full text-xs"
target={item.url.startsWith("http") ? "_blank" : undefined}
rel={item.url.startsWith("http") ? "noopener noreferrer" : undefined}
onclick={() => trackNavClick(item)}
>
{#if item.iconURL}
<img src={clientResolver(resolve, item.iconURL)} alt={item.name} class="mr-2 h-4 w-4 object-cover" />
{/if}
{item.name}
</Button>
{/each}
</Dropdown.Content>
</Dropdown.Root>
{/if}
</div>
</div>
</div>
+19 -3
View File
@@ -1,7 +1,15 @@
<script lang="ts">
import * as Select from "$lib/components/ui/select/index.js";
import { i18n, t } from "$lib/stores/i18n";
import Languages from "lucide-svelte/icons/languages";
import Languages from "@lucide/svelte/icons/languages";
import { cn } from "$lib/utils.js";
import trackEvent from "$lib/beacon";
interface Props {
compact?: boolean;
}
let { compact = false }: Props = $props();
// Get the current locale from the store
let selectedLang = $state($i18n.currentLocale);
@@ -15,6 +23,7 @@
async function handleLocaleChange(newLocale: string) {
if (newLocale && newLocale !== $i18n.currentLocale) {
await i18n.setLocale(newLocale);
trackEvent("language_changed", { locale: newLocale });
}
}
@@ -34,10 +43,17 @@
<Select.Root type="single" name="language" bind:value={selectedLang}>
<Select.Trigger
size="sm"
class="ksel hover:text-accent-foreground bg-background/80 dark:bg-background/70 border-foreground/10 cursor-pointer rounded-full border text-xs font-medium shadow-none backdrop-blur-md"
class={cn(
"ksel hover:text-accent-foreground bg-background/80 dark:bg-background/70 border-foreground/15 flex cursor-pointer justify-center rounded-full border text-xs font-medium shadow-none backdrop-blur-md",
compact ? "size-8 p-0" : "max-w-[10rem] sm:max-w-none"
)}
>
<Languages class="text-inherit" />
{triggerContent}
{#if compact}
<span class="sr-only">{triggerContent}</span>
{:else}
<span class="truncate">{triggerContent}</span>
{/if}
</Select.Trigger>
<Select.Content>
<Select.Group>
+24 -25
View File
@@ -1,40 +1,37 @@
<script lang="ts">
import { format } from "date-fns";
import { AreaChart, Area, LinearGradient } from "layerchart";
import { curveCatmullRom } from "d3-shape";
import { scaleTime } from "d3-scale";
import * as Chart from "$lib/components/ui/chart/index.js";
import type { TimestampStatusCount } from "$lib/server/types/db";
import { t } from "$lib/stores/i18n";
import { ta } from "date-fns/locale";
import { ParseLatency } from "$lib/clientTools";
import { formatDate } from "$lib/stores/datetime";
import { page } from "$app/state";
interface ChartPoint {
date: Date;
value: number;
}
interface Props {
data: TimestampStatusCount[];
data: ChartPoint[];
label?: string;
height?: number;
class?: string;
}
let { data, height = 128, class: className = "" }: Props = $props();
let { data, label = $t("Avg Latency"), height = 128, class: className = "" }: Props = $props();
// Chart config
const chartConfig = {
avgLatency: {
label: $t("Avg Latency"),
let chartConfig = $derived({
value: {
label: label,
color: "var(--chart-1)"
}
} satisfies Chart.ChartConfig;
} satisfies Chart.ChartConfig);
// Transform data for chart
let chartData = $derived.by(() => {
if (!data) return [];
return data
.filter((d) => d.avgLatency > 0)
.map((d) => ({
date: new Date(d.ts * 1000),
avgLatency: d.avgLatency
}));
});
// Filter out zero values
let chartData = $derived(data.filter((d) => d.value > 0));
</script>
<div class="{className} ">
@@ -44,16 +41,16 @@
data={chartData}
x="date"
xScale={scaleTime()}
y="avgLatency"
y="value"
yDomain={[0, null]}
yNice
axis="x"
grid={false}
series={[
{
key: "avgLatency",
label: $t("Avg Latency"),
color: "var(--color-avgLatency)"
key: "value",
label: label,
color: "var(--color-value)"
}
]}
props={{
@@ -63,7 +60,7 @@
line: { class: "stroke-1" }
},
xAxis: {
format: (d: Date) => format(d, "MMM d")
format: (d: Date) => $formatDate(d, "MMM d")
}
}}
>
@@ -86,7 +83,9 @@
></div>
<div class="flex flex-1 flex-col items-start justify-between gap-1 leading-none">
<span class="text-muted-foreground text-xs"
>{item.payload?.date ? format(item.payload.date, "MMM d") : ""}</span
>{item.payload?.date
? $formatDate(item.payload.date, page.data.dateAndTimeFormat.dateOnly)
: ""}</span
>
<div class="flex items-center gap-2">
<span class="text-foreground font-mono font-medium tabular-nums">
+41 -118
View File
@@ -10,123 +10,54 @@
import { formatDate, formatDuration } from "$lib/stores/datetime";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
interface MaintenanceMonitorImpact {
monitor_tag: string;
monitor_impact: string;
monitor_name: string;
monitor_image: string | null;
}
interface Maintenance {
id: number;
title: string;
description: string | null;
monitors: MaintenanceMonitorImpact[];
start_date_time: number;
end_date_time: number;
}
import { GetInitials } from "$lib/clientTools.js";
import type { MaintenanceEventsMonitorList } from "$lib/server/types/db";
import { page } from "$app/state";
interface Props {
maintenance: Maintenance;
maintenance: MaintenanceEventsMonitorList;
class?: string;
hideMonitors?: boolean;
}
let { maintenance, class: className = "", hideMonitors = false }: Props = $props();
const STATUS_STROKE = {
UP: "stroke-up",
DOWN: "stroke-down",
DEGRADED: "stroke-degraded",
MAINTENANCE: "stroke-maintenance",
NO_DATA: "stroke-muted-foreground"
} as const;
const STATUS_BORDER = {
UP: "border-up",
DOWN: "border-down",
DEGRADED: "border-degraded",
MAINTENANCE: "border-maintenance",
NO_DATA: "border-muted-foreground"
} as const;
// Get the highest severity impact from monitors array
function getHighestImpact(monitors: MaintenanceMonitorImpact[]): keyof typeof STATUS_ICON {
const priority: (keyof typeof STATUS_ICON)[] = ["DOWN", "DEGRADED", "MAINTENANCE"];
for (const impact of priority) {
if (monitors.some((m) => m.monitor_impact === impact)) {
return impact;
}
}
return (monitors[0]?.monitor_impact as keyof typeof STATUS_ICON) || "MAINTENANCE";
}
// Get initials from monitor name for avatar fallback
function getInitials(name: string): string {
return name
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
const highestImpact = $derived(getHighestImpact(maintenance.monitors));
const Icon = $derived(STATUS_ICON[highestImpact]);
const strokeClass = $derived(STATUS_STROKE[highestImpact as keyof typeof STATUS_STROKE] || "stroke-maintenance");
const borderClass = $derived(STATUS_BORDER[highestImpact as keyof typeof STATUS_BORDER] || "border-maintenance");
const textClass = $derived("text-" + highestImpact.toLowerCase());
// Check if maintenance is ongoing (current time is between start and end)
const isOngoing = $derived(() => {
const now = Date.now() / 1000;
return now >= maintenance.start_date_time && now <= maintenance.end_date_time;
});
// Truncate description to approximately 2-3 lines
function truncateDescription(text: string | null, maxLength: number = 150): string {
if (!text) return "";
if (text.length <= maxLength) return text;
return text.slice(0, maxLength).trim() + "...";
}
const isEmbedded = page.route.id?.includes("(embed)");
const target = isEmbedded ? "_blank" : "_self";
</script>
<Item.Root class="p-0 {className}">
<Item.Media>
<Icon class="size-6 {strokeClass}" />
</Item.Media>
<Item.Content>
<div class="flex items-center gap-2">
<Item.Title>{maintenance.title}</Item.Title>
{#if isOngoing()}
<Badge variant="outline" class="text-maintenance border-maintenance text-xs">{$t("In Progress")}</Badge>
{/if}
<Item.Root class="items-start p-0 {className} sm:items-center">
<Item.Content class="min-w-0 flex-1">
<div class="flex flex-col items-start justify-start gap-0.5">
<span class="text-xs font-medium text-{maintenance.status.toLowerCase()}">{$t(maintenance.status)}</span>
<Item.Title class="min-w-0 text-base wrap-break-word break-all">
<a {target} class="hover:underline" href={clientResolver(resolve, `/maintenances/${maintenance.id}`)}
>{maintenance.title}</a
>
</Item.Title>
</div>
{#if maintenance.description}
<p class="text-muted-foreground mt-1 text-sm">
{truncateDescription(maintenance.description)}
{maintenance.description}
</p>
{/if}
{#if maintenance.monitors && maintenance.monitors.length > 0 && !hideMonitors}
<div class="*:data-[slot=avatar]:ring-background my-2 flex -space-x-2 *:data-[slot=avatar]:ring-2">
<div class="flex gap-2">
{#each maintenance.monitors as monitor}
<Popover.Root>
<Popover.Trigger>
<Avatar.Root
class="bg-background border-{monitor.monitor_impact.toLowerCase()} size-8 cursor-pointer border-2 transition-transform duration-100 ease-in-out hover:scale-[1.1] hover:border"
<Popover.Trigger disabled={isEmbedded}>
<Badge
variant="outline"
class="border-{monitor.monitor_impact.toLowerCase()} cursor-pointer rounded-none border-0 border-b px-0 text-sm font-normal"
>
{#if monitor.monitor_image}
<Avatar.Image
src={clientResolver(resolve, monitor.monitor_image)}
alt={monitor.monitor_name}
class="object-cover"
/>
{/if}
<Avatar.Fallback class="text-xs">{getInitials(monitor.monitor_name)}</Avatar.Fallback>
</Avatar.Root>
{monitor.monitor_name}
</Badge>
</Popover.Trigger>
<Popover.Content class="w-64">
<div class="flex flex-col gap-3">
@@ -135,7 +66,7 @@
{#if monitor.monitor_image}
<Avatar.Image src={clientResolver(resolve, monitor.monitor_image)} alt={monitor.monitor_name} />
{/if}
<Avatar.Fallback>{getInitials(monitor.monitor_name)}</Avatar.Fallback>
<Avatar.Fallback>{GetInitials(monitor.monitor_name)}</Avatar.Fallback>
</Avatar.Root>
<div class="flex flex-col">
<span class="font-medium">{monitor.monitor_name}</span>
@@ -162,31 +93,23 @@
</div>
{/if}
<Item.Description>
<div class="mt-2 flex items-center justify-between text-xs font-medium">
<div class="rounded-full border px-3 py-2">
{$formatDate(maintenance.start_date_time, "PPp")}
</div>
<div class="relative flex-1 text-center">
<div class="absolute top-1/2 right-0 left-0 border-t border-solid"></div>
<span class="bg-background relative z-10 px-2 py-1 font-medium"
>{$formatDuration(maintenance.start_date_time, maintenance.end_date_time)}</span
>
</div>
<div class="rounded-full border px-3 py-2">
{$formatDate(maintenance.end_date_time, "PPp")}
</div>
</div>
<Item.Description
class="mt-2 flex w-full flex-col gap-2 text-xs font-medium sm:flex-row sm:items-center sm:justify-between"
>
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
{$formatDate(maintenance.start_date_time, page.data.dateAndTimeFormat.datePlusTime)}
</span>
<span class="relative w-full text-center sm:flex-1">
<span
class="absolute top-0 bottom-0 left-1/2 border-l sm:top-1/2 sm:right-0 sm:bottom-auto sm:left-0 sm:border-t sm:border-l-0"
></span>
<span class="bg-background relative z-10 rounded-full px-0 py-1 sm:px-2">
{$formatDuration(maintenance.start_date_time, maintenance.end_date_time)}
</span>
</span>
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
{$formatDate(maintenance.end_date_time, page.data.dateAndTimeFormat.datePlusTime)}
</span>
</Item.Description>
</Item.Content>
<Item.Actions>
<Button
variant="outline"
class="cursor-pointer rounded-full shadow-none"
href={clientResolver(resolve, `/maintenances/${maintenance.id}`)}
size="icon"
>
<ArrowRight />
</Button>
</Item.Actions>
</Item.Root>
+27 -13
View File
@@ -2,9 +2,11 @@
import { onMount } from "svelte";
import { mode } from "mode-watcher";
import { page } from "$app/state";
import TrendingUp from "lucide-svelte/icons/trending-up";
import TrendingUp from "@lucide/svelte/icons/trending-up";
import { t } from "$lib/stores/i18n";
import { formatDate } from "$lib/stores/datetime";
import { selectedTimezone } from "$lib/stores/timezone";
import { toZonedTime } from "date-fns-tz";
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
interface MinuteData {
@@ -32,14 +34,22 @@
let hoveredMinute = $state<{ x: number; y: number; data: MinuteData } | null>(null);
// Layout constants
const SQUARE_SIZE = 10;
const SQUARE_GAP = 1;
const HOUR_LABEL_WIDTH = 0;
const SECTION_LABEL_HEIGHT = 20;
const SECTION_GAP = 12;
const MINUTES_PER_HOUR = 60;
const HOURS_PER_SECTION = 6;
const ROW_HEIGHT = SQUARE_SIZE + SQUARE_GAP;
// Responsive square size: fit 60 squares + gaps into the available width
let squareSize = $derived.by(() => {
if (canvasWidth <= 0) return 10;
const available = canvasWidth - HOUR_LABEL_WIDTH;
// 60 squares with gaps between them: 60 * size + 59 * gap <= available
const size = Math.floor((available - (MINUTES_PER_HOUR - 1) * SQUARE_GAP) / MINUTES_PER_HOUR);
return Math.max(3, Math.min(size, 10)); // clamp between 3px and 10px
});
let rowHeight = $derived(squareSize + SQUARE_GAP);
// Colors from page data
const colorUp = $derived(page.data.siteStatusColors?.UP || "#22c55e");
@@ -66,7 +76,7 @@
const minutesByHour: Map<number, MinuteData[]> = new Map();
for (const minute of minutes) {
const date = new Date(minute.timestamp * 1000);
const date = toZonedTime(minute.timestamp * 1000, $selectedTimezone);
const hour = date.getHours();
if (!minutesByHour.has(hour)) {
@@ -95,7 +105,7 @@
let height = 0;
for (const section of sections) {
height += SECTION_LABEL_HEIGHT;
height += section.hours.length * ROW_HEIGHT;
height += section.hours.length * rowHeight;
height += SECTION_GAP;
}
return Math.max(height, 50);
@@ -183,23 +193,23 @@
// Draw minute squares
for (let minIdx = 0; minIdx < hourMinutes.length; minIdx++) {
const minute = hourMinutes[minIdx];
const x = HOUR_LABEL_WIDTH + minIdx * (SQUARE_SIZE + SQUARE_GAP);
const x = HOUR_LABEL_WIDTH + minIdx * (squareSize + SQUARE_GAP);
const y = currentY;
ctx.fillStyle = getStatusColor(minute.status);
ctx.fillRect(x, y, SQUARE_SIZE, SQUARE_SIZE);
ctx.fillRect(x, y, squareSize, squareSize);
// Store rect for hit testing
minuteRects.push({
x,
y,
width: SQUARE_SIZE,
height: SQUARE_SIZE,
width: squareSize,
height: squareSize,
data: minute
});
}
currentY += ROW_HEIGHT;
currentY += rowHeight;
}
currentY += SECTION_GAP;
@@ -307,11 +317,15 @@
{#if hoveredMinute}
<div
bind:this={tooltipEl}
class="bg-foreground text-secondary pointer-events-none absolute z-20 w-max -translate-x-1/2 rounded-md px-2 py-1 text-xs font-medium whitespace-nowrap"
class="bg-popover text-popover-foreground border-border pointer-events-none absolute z-20 w-max -translate-x-1/2 rounded-md border px-2 py-1 text-xs font-medium whitespace-nowrap"
style={tooltipStyle}
>
{$formatDate(hoveredMinute.data.timestamp, "HH:mm")} -
<span class="text-{hoveredMinute.data.status.toLowerCase()}">{$t(hoveredMinute.data.status)}</span>
<span class="text-{hoveredMinute.data.status.toLowerCase()}">
{$t(hoveredMinute.data.status)} @ {$formatDate(
hoveredMinute.data.timestamp,
page.data.dateAndTimeFormat.timeOnly
)}
</span>
</div>
{/if}
</div>
+100 -123
View File
@@ -1,26 +1,45 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import * as Item from "$lib/components/ui/item/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
import * as Avatar from "$lib/components/ui/avatar/index.js";
import ICONS from "$lib/icons";
import { getEndOfDayAtTz } from "$lib/client/datetime";
import StatusBarCalendar from "$lib/components/StatusBarCalendar.svelte";
import { selectedTimezone } from "$lib/stores/timezone";
import type { MonitorBarResponse, BarData } from "$lib/server/api-server/monitor-bar/get.js";
import type { MonitorBarResponse } from "$lib/server/api-server/monitor-bar/get.js";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
import { formatDate } from "$lib/stores/datetime";
import { GetInitials } from "$lib/clientTools.js";
import GroupMonitorPopover from "./GroupMonitorPopover.svelte";
import { t } from "$lib/stores/i18n";
import { page } from "$app/state";
interface Props {
tag: string;
prefetchedData?: MonitorBarResponse;
prefetchedError?: string;
groupChildTags?: string[];
days?: number;
endOfDayTodayAtTz?: number;
compact?: boolean;
grid?: boolean;
}
let { tag }: Props = $props();
let loading = $state(true);
let data = $state<MonitorBarResponse | null>(null);
let error = $state<string | null>(null);
let {
tag,
prefetchedData,
prefetchedError,
groupChildTags = [],
days,
endOfDayTodayAtTz,
compact = false,
grid = false
}: Props = $props();
let data = $derived(prefetchedData ?? null);
let error = $derived(prefetchedError ?? null);
let loading = $derived(!data && !error);
let showGroupPopover = $derived(
groupChildTags.length > 0 && typeof days === "number" && typeof endOfDayTodayAtTz === "number"
);
const STATUS_ICON = {
UP: ICONS.UP,
@@ -37,90 +56,41 @@
MAINTENANCE: "stroke-maintenance",
NO_DATA: "stroke-muted-foreground"
} as const;
function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString("en-US", {
day: "numeric",
month: "short",
year: "numeric",
timeZone: $selectedTimezone
});
}
async function fetchData() {
loading = true;
error = null;
try {
const endOfDayTodayAtTz = getEndOfDayAtTz($selectedTimezone);
const response = await fetch(
clientResolver(
resolve,
`/dashboard-apis/monitor-bar?tag=${encodeURIComponent(tag)}&endOfDayTodayAtTz=${endOfDayTodayAtTz}`
)
);
if (!response.ok) {
throw new Error("Failed to fetch monitor data");
}
data = await response.json();
} catch (e) {
console.error("Failed to fetch monitor bar data:", e);
error = e instanceof Error ? e.message : "Unknown error";
} finally {
loading = false;
}
}
onMount(() => {
fetchData();
});
// Re-fetch when timezone changes
let initialLoad = true;
$effect(() => {
// Use untrack to avoid tracking loading/data state changes
untrack(() => {
if (initialLoad) {
initialLoad = false;
return;
}
fetchData();
});
});
</script>
<div>
{#if loading}
<!-- Skeleton loader -->
<Item.Root>
<Item.Media variant="image">
<Skeleton class="size-8 rounded" />
</Item.Media>
<Item.Content>
<Skeleton class="mb-2 h-5 w-40" />
<Skeleton class="h-4 w-64" />
<Item.Root class="items-start sm:items-center">
{#if !compact}
<Item.Media variant="image">
<Skeleton class="size-8 rounded" />
</Item.Media>
{/if}
<Item.Content class="min-w-0 flex-1">
<Skeleton class="mb-2 h-5 w-full" />
<Skeleton class="h-4 w-full" />
</Item.Content>
<Item.Content class="flex-none text-center">
<Skeleton class="h-8 w-24" />
<Item.Content class="order-3 w-full text-left sm:order-0 sm:w-auto sm:flex-none sm:text-center">
<Skeleton class="h-8 w-full" />
</Item.Content>
<Item.Actions>
<Skeleton class="size-9" />
</Item.Actions>
</Item.Root>
<div class="mx-auto flex w-full flex-col gap-1 px-4">
<div class="flex justify-end overflow-hidden rounded-full">
{#each Array(54) as _, i}
<Skeleton
class="h-4 w-4 shrink-0 {i === 0 ? 'rounded-tl-full rounded-bl-full' : ''} {i === 53
? 'rounded-tr-full rounded-br-full'
: ''}"
/>
{/each}
{#if !compact}
<div class="mx-auto flex w-full flex-col gap-1 px-4">
<div class="flex justify-end overflow-hidden rounded-full">
{#each Array(54) as _, i (i)}
<Skeleton
class="h-4 w-4 shrink-0 {i === 0 ? 'rounded-tl-full rounded-bl-full' : ''} {i === 53
? 'rounded-tr-full rounded-br-full'
: ''}"
/>
{/each}
</div>
<div class="flex justify-end">
<Skeleton class="h-3 w-32" />
</div>
</div>
<div class="flex justify-end">
<Skeleton class="h-3 w-32" />
</div>
</div>
{/if}
{:else if error}
<!-- Error state -->
<div class="text-destructive p-4 text-center">
@@ -129,55 +99,62 @@
{:else if data}
<!-- Loaded state -->
{@const StatusIcon = STATUS_ICON[data.currentStatus]}
<Item.Root>
<Item.Media variant="image">
<Avatar.Root class="size-10">
{#if data.image}
<Avatar.Image src={clientResolver(resolve, data.image)} alt={data.name} class=" " />
{/if}
<Avatar.Fallback>{data.name.charAt(1)}</Avatar.Fallback>
</Avatar.Root>
</Item.Media>
<Item.Content>
<Item.Title>{data.name}</Item.Title>
<Item.Root class="items-start ">
{#if !compact}
<Item.Media variant="image" class="hidden sm:block">
<Avatar.Root class="size-10">
{#if data.image}
<Avatar.Image src={clientResolver(resolve, data.image)} alt={data.name} class=" " />
{/if}
<Avatar.Fallback>{GetInitials(data.name)}</Avatar.Fallback>
</Avatar.Root>
</Item.Media>
{/if}
<Item.Content class="min-w-0 flex-1">
<Item.Title class="w-full truncate">
<a class="hover:underline" href={clientResolver(resolve, `/monitors/${tag}`)}>{data.name}</a>
</Item.Title>
{#if data.description}
<Item.Description>{data.description}</Item.Description>
<Item.Description class="line-clamp-2 wrap-break-word">{data.description}</Item.Description>
{/if}
</Item.Content>
<Item.Content class="flex-none text-center">
<Item.Content class="order-3 w-full text-left sm:order-0 sm:w-auto sm:flex-none sm:text-center">
<Item.Title class="items-start text-2xl">
<StatusIcon class="{STATUS_STROKE[data.currentStatus]} mt-1.5" />
<div class="flex flex-col items-start gap-1">
<span>{data.uptime}%</span>
<span class="text-muted-foreground text-xs">
<StatusIcon class="{STATUS_STROKE[data.currentStatus]} {grid ? 'mt-1 size-5' : 'mt-1.5 size-6'}" />
<div class="flex flex-col items-start gap-1 sm:items-end">
<span class={grid ? "text-base sm:text-lg" : "text-lg sm:text-xl"}>{data.uptime}%</span>
<span class="text-muted-foreground text-right text-xs">
{data.avgLatency}
</span>
</div>
</Item.Title>
</Item.Content>
<Item.Actions>
<Button
size="icon"
variant="outline"
class="cursor-pointer rounded-full shadow-none"
href={clientResolver(resolve, `/monitors/${tag}`)}
>
<ICONS.ARROW_RIGHT />
</Button>
</Item.Actions>
</Item.Root>
<div class="mx-auto flex w-full flex-col gap-1 px-4">
<StatusBarCalendar data={data.uptimeData} monitorTag={tag} barHeight={40} radius={8} />
<div class="flex justify-between">
<p class="text-muted-foreground text-xs font-medium">
{formatTimestamp(data.fromTimeStamp)}
</p>
<p class="text-muted-foreground text-xs font-medium">
{formatTimestamp(data.toTimeStamp)}
</p>
{#if !compact}
<div class="mx-auto flex w-full flex-col gap-1 px-4">
<StatusBarCalendar data={data.uptimeData} monitorTag={tag} barHeight={40} radius={8} />
<div class="flex min-w-0 justify-between gap-3">
<p class="text-muted-foreground min-w-0 truncate text-xs font-medium">
{$formatDate(new Date(data.fromTimeStamp * 1000), page.data.dateAndTimeFormat.dateOnly)}
</p>
<p class="text-muted-foreground min-w-0 truncate text-right text-xs font-medium">
{$formatDate(new Date(data.toTimeStamp * 1000), page.data.dateAndTimeFormat.dateOnly)}
</p>
</div>
</div>
</div>
{/if}
{#if showGroupPopover}
<div class="mt-2 flex justify-center gap-2 px-4">
<GroupMonitorPopover
tags={groupChildTags}
days={days as number}
endOfDayTodayAtTz={endOfDayTodayAtTz as number}
>
{$t("Included Monitors (%count)", { count: String(groupChildTags.length) })}
</GroupMonitorPopover>
</div>
{/if}
{/if}
</div>
+58 -66
View File
@@ -2,29 +2,27 @@
import { t } from "$lib/stores/i18n";
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import { format } from "date-fns";
import { resolve } from "$app/paths";
import TrendingUp from "lucide-svelte/icons/trending-up";
import Clock from "lucide-svelte/icons/clock";
import Activity from "lucide-svelte/icons/activity";
import { Button } from "$lib/components/ui/button/index.js";
import Clock from "@lucide/svelte/icons/clock";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
import LoaderBoxes from "$lib/components/loaderbox.svelte";
import constants from "$lib/global-constants.js";
import * as Tabs from "$lib/components/ui/tabs/index.js";
import { page } from "$app/state";
import { AreaChart, Area, LinearGradient } from "layerchart";
import { curveCatmullRom } from "d3-shape";
import { scaleOrdinal, scaleSequential, scaleTime } from "d3-scale";
import { scaleTime } from "d3-scale";
import IncidentItem from "$lib/components/IncidentItem.svelte";
import MaintenanceItem from "$lib/components/MaintenanceItem.svelte";
import MinuteGrid from "$lib/components/MinuteGrid.svelte";
import clientResolver from "$lib/client/resolver.js";
import { ParseLatency } from "$lib/clientTools";
import * as Chart from "$lib/components/ui/chart/index.js";
import type { IncidentForMonitorList, MaintenanceEventsMonitorList } from "$lib/server/types/db";
import type { IncidentForMonitorListWithComments, MaintenanceEventsMonitorList } from "$lib/server/types/db";
import { formatDate } from "$lib/stores/datetime";
import trackEvent from "$lib/beacon";
interface DayDetailData {
minutes: Array<{
timestamp: number;
@@ -57,9 +55,10 @@
let incidentsLoading = $state(false);
let maintenancesLoading = $state(false);
let activeView = $state<"status" | "latency" | "incidents" | "maintenances">("status");
let lastTrackedView = $state<"status" | "latency" | "incidents" | "maintenances">("status");
let dayDetailData = $state<DayDetailData | null>(null);
let dayLatencyData = $state<DayLatencyData | null>(null);
let dayIncidentsData = $state<IncidentForMonitorList[]>([]);
let dayIncidentsData = $state<IncidentForMonitorListWithComments[]>([]);
let dayMaintenancesData = $state<MaintenanceEventsMonitorList[]>([]);
// Chart config for latency
const chartConfig = {
@@ -186,14 +185,6 @@
maintenancesLoading = false;
}
}
// Format date for display
function formatDate(timestamp: number): string {
return format(new Date(timestamp * 1000), "EEEE, MMMM do, yyyy");
}
function formatTime(timestamp: number): string {
return format(new Date(timestamp * 1000), "HH:mm");
}
// Fetch data when dialog opens with a new day
$effect(() => {
@@ -204,28 +195,48 @@
fetchDayMaintenances();
}
});
$effect(() => {
if (open && activeView !== lastTrackedView) {
trackEvent("monitor_day_tab_changed", { view: activeView, monitorTag });
lastTrackedView = activeView;
}
});
</script>
<Dialog.Root bind:open>
<Dialog.Overlay class="backdrop-blur-[2px]" />
<Dialog.Content class="max-h-[80vh] overflow-y-auto rounded-3xl sm:max-w-[46.5rem]">
<Dialog.Content class="max-h-[90vh] overflow-y-auto rounded-3xl p-4 sm:max-w-[46.5rem] sm:p-6">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<Activity class="h-5 w-5" />
{selectedDay ? formatDate(selectedDay.timestamp) : ""}
<Dialog.Title class="flex items-center gap-2 text-base sm:text-lg">
<span class="truncate">
{selectedDay ? $formatDate(new Date(selectedDay.timestamp * 1000), page.data.dateAndTimeFormat.dateOnly) : ""}
</span>
</Dialog.Title>
<Dialog.Description>{$t("Minute-by-minute status data for this day")}</Dialog.Description>
<Dialog.Description class="text-xs sm:text-sm"
>{$t("Minute-by-minute status data for this day")}</Dialog.Description
>
</Dialog.Header>
<Tabs.Root value={activeView} class="bg-background ktabs w-full overflow-hidden rounded-3xl border">
<Tabs.List class="h-auto w-full justify-end rounded-none px-2 py-2">
<Tabs.Trigger value="status" class="rounded-3xl py-2">{$t("Status")}</Tabs.Trigger>
<Tabs.Trigger value="latency" class="rounded-3xl py-2">{$t("Latency")}</Tabs.Trigger>
<Tabs.Trigger value="incidents" class="rounded-3xl py-2">{$t("Incidents")}</Tabs.Trigger>
<Tabs.Trigger value="maintenances" class="rounded-3xl py-2">{$t("Maintenances")}</Tabs.Trigger>
<Tabs.Root value={activeView} class="bg-background ktabs w-full overflow-hidden rounded-3xl border">
<Tabs.List
class="scrollbar-hidden h-auto w-full justify-start gap-1 overflow-x-auto rounded-none px-2 py-2 sm:justify-end"
>
<Tabs.Trigger value="status" class="shrink-0 rounded-3xl px-2 py-1.5 text-xs sm:px-3 sm:py-2 sm:text-sm"
>{$t("Status")}</Tabs.Trigger
>
<Tabs.Trigger value="latency" class="shrink-0 rounded-3xl px-2 py-1.5 text-xs sm:px-3 sm:py-2 sm:text-sm"
>{$t("Latency")}</Tabs.Trigger
>
<Tabs.Trigger value="incidents" class="shrink-0 rounded-3xl px-2 py-1.5 text-xs sm:px-3 sm:py-2 sm:text-sm"
>{$t("Incidents")}</Tabs.Trigger
>
<Tabs.Trigger value="maintenances" class="shrink-0 rounded-3xl px-2 py-1.5 text-xs sm:px-3 sm:py-2 sm:text-sm"
>{$t("Maintenances")}</Tabs.Trigger
>
</Tabs.List>
<!-- status view -->
<Tabs.Content value="status" class="p-4">
<Tabs.Content value="status" class="p-2 sm:p-4">
{#if loading}
<!-- Loading state -->
<div class="space-y-4 py-4">
@@ -247,7 +258,7 @@
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="latency" class="p-4">
<Tabs.Content value="latency" class="p-2 sm:p-4">
{#if latencyLoading}
<!-- Loading state -->
<div class="space-y-4 py-4">
@@ -276,7 +287,7 @@
</div>
<!-- Latency chart -->
<Chart.Container config={chartConfig} class="min-h-64 w-full">
<Chart.Container config={chartConfig} class="min-h-48 w-full sm:min-h-64">
<AreaChart
data={chartData}
x="date"
@@ -300,7 +311,7 @@
line: { class: "stroke-1" }
},
xAxis: {
format: (d: Date) => format(d, "HH:mm")
format: (d: Date) => $formatDate(d, page.data.dateAndTimeFormat.timeOnly)
}
}}
>
@@ -325,12 +336,14 @@
class="mt-0.5 size-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
></div>
<div class="flex flex-1 flex-col items-start justify-between gap-1 leading-none">
<span class="text-muted-foreground text-xs"
>{item.payload?.date ? format(item.payload.date, "HH:mm") : ""}</span
>
<span class="text-muted-foreground text-xs">
{item.payload?.date
? $formatDate(item.payload.date, page.data.dateAndTimeFormat.timeOnly)
: ""}
</span>
<div class="flex items-center gap-2">
<span class="text-foreground font-mono font-medium tabular-nums">
{Math.round(Number(value))} ms
{ParseLatency(Math.round(Number(value)))}
</span>
</div>
</div>
@@ -351,7 +364,7 @@
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="incidents" class="p-4">
<Tabs.Content value="incidents" class="p-2 sm:p-4">
{#if incidentsLoading}
<!-- Loading state -->
<div class="space-y-4 py-4">
@@ -365,19 +378,10 @@
<div class="space-y-4">
<!-- Incident list -->
<div>
<div class="text-muted-foreground mb-2 flex items-center justify-between text-sm font-medium">
<p class="">{$t("Incidents")}</p>
<div class="flex items-center gap-1">
<Clock class="h-3 w-3" />
{dayIncidentsData.length}
{$t("Total")}
</div>
</div>
<div class="scrollbar-hidden flex max-h-100 flex-col gap-4 overflow-y-auto">
{#each dayIncidentsData as incident (incident.id)}
<div class="border-b pb-5 last:border-b-0">
<IncidentItem {incident} hideMonitors={true} />
<IncidentItem {incident} hideMonitors={true} showComments={false} showSummary={false} />
</div>
{/each}
</div>
@@ -389,7 +393,7 @@
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="maintenances" class="p-4">
<Tabs.Content value="maintenances" class="p-2 sm:p-4">
{#if maintenancesLoading}
<!-- Loading state -->
<div class="space-y-4 py-4">
@@ -400,24 +404,12 @@
<Skeleton class="h-64 w-full" />
</div>
{:else if dayMaintenancesData.length > 0}
<div class="space-y-4">
<!-- Maintenance list -->
<div>
<div class="text-muted-foreground mb-2 flex items-center justify-between text-sm font-medium">
<p class="">{$t("Maintenances")}</p>
<div class="flex items-center gap-1">
<Clock class="h-3 w-3" />
{dayMaintenancesData.length} Total
</div>
<div class="scrollbar-hidden flex max-h-100 flex-col gap-4 space-y-4 overflow-y-auto">
{#each dayMaintenancesData as maintenance (maintenance.id)}
<div class="border-b pb-5 last:border-b-0">
<MaintenanceItem {maintenance} hideMonitors={true} />
</div>
<div class="scrollbar-hidden flex max-h-100 flex-col gap-4 overflow-y-auto">
{#each dayMaintenancesData as maintenance (maintenance.id)}
<div class="border-b pb-5 last:border-b-0">
<MaintenanceItem {maintenance} hideMonitors={true} />
</div>
{/each}
</div>
</div>
{/each}
</div>
{:else}
<div class="py-8 text-center">
+123 -41
View File
@@ -1,64 +1,98 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { resolve } from "$app/paths";
import * as Card from "$lib/components/ui/card/index.js";
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import StatusBarCalendar from "$lib/components/StatusBarCalendar.svelte";
import LatencyTrendChart from "$lib/components/LatencyTrendChart.svelte";
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import ArrowUp from "@lucide/svelte/icons/arrow-up";
import type { MonitorBarResponse } from "$lib/server/api-server/monitor-bar/get";
import { Button } from "$lib/components/ui/button";
import { t } from "$lib/stores/i18n";
import { selectedTimezone } from "$lib/stores/timezone";
import { getEndOfDayAtTz } from "$lib/client/datetime";
import { formatDate } from "$lib/stores/datetime";
import clientResolver from "$lib/client/resolver.js";
import { requestMonitorBar, clearMonitorBarCache } from "$lib/client/monitor-bar-client";
import * as Popover from "$lib/components/ui/popover/index.js";
import * as ToggleGroup from "$lib/components/ui/toggle-group/index.js";
import GroupMonitorPopover from "$lib/components/GroupMonitorPopover.svelte";
import { page } from "$app/state";
interface Props {
monitorTag: string;
class?: string;
maxDays?: number;
groupTags?: string[];
}
let { monitorTag, class: className = "" }: Props = $props();
let { monitorTag, class: className = "", maxDays = 90, groupTags = [] }: Props = $props();
// State
let loading = $state(true);
let overviewData = $state<MonitorBarResponse | null>(null);
let error = $state<string | null>(null);
// Day range options
const dayOptions = [
{ days: 90, text: $t("90 Days") },
{ days: 30, text: $t("30 Days") },
{ days: 7, text: $t("7 Days") },
{ days: 1, text: $t("24 Hours") }
];
// All possible day range options (ascending)
const allDayOptions = [1, 7, 14, 30, 60, 90];
// Filter options up to maxDays, always include maxDays itself
let dayOptions = $derived.by(() => {
const filtered = allDayOptions.filter((d) => d <= maxDays);
if (!filtered.includes(maxDays)) {
filtered.push(maxDays);
}
// Sort descending for dropdown display
filtered.sort((a, b) => b - a);
return filtered.map((d) => ({
days: d,
text: `${d} ${d === 1 ? $t("Day") : $t("Days")}`
}));
});
// Default to maxDays (first item since sorted descending)
let selectedDayIndex = $state(0);
let selectedDays = $derived(dayOptions[selectedDayIndex]?.days ?? maxDays);
let endOfDayTodayAtTz = $derived(getEndOfDayAtTz($selectedTimezone));
// Latency metric toggle: "average" | "maximum" | "minimum"
let latencyMetric = $state("average");
// Display values from API response (already formatted as strings)
let displayUptime = $derived(overviewData?.uptime ?? "--");
let displayAvgLatency = $derived(overviewData?.avgLatency ?? "--");
let displayMaxLatency = $derived(overviewData?.maxLatency ?? "--");
let displayMinLatency = $derived(overviewData?.minLatency ?? "--");
// Data for calendar/chart comes directly from API
let displayData = $derived(overviewData?.uptimeData ?? []);
// Latency metric label map
const metricLabels: Record<string, string> = {
average: $t("Avg Latency"),
maximum: $t("Max Latency"),
minimum: $t("Min Latency")
};
// Transform uptimeData into chart-ready points based on selected metric
let latencyChartData = $derived.by(() => {
if (!displayData) return [];
return displayData.map((d) => ({
date: new Date(d.ts * 1000),
value: latencyMetric === "maximum" ? d.maxLatency : latencyMetric === "minimum" ? d.minLatency : d.avgLatency
}));
});
let latencyChartLabel = $derived(metricLabels[latencyMetric] ?? metricLabels.average);
// Fetch data with days parameter
async function fetchData(days: number) {
loading = true;
error = null;
try {
const endOfDayTodayAtTz = getEndOfDayAtTz($selectedTimezone);
const url = `?tag=${monitorTag}&endOfDayTodayAtTz=${endOfDayTodayAtTz}&days=${days}`;
const response = await fetch(clientResolver(resolve, "/dashboard-apis/monitor-bar") + url);
if (!response.ok) {
throw new Error("Failed to fetch monitor data");
}
overviewData = await response.json();
overviewData = await requestMonitorBar(monitorTag, days, endOfDayTodayAtTz);
} catch (e) {
console.error("Failed to fetch monitor data:", e);
error = e instanceof Error ? e.message : "Failed to load data";
@@ -71,6 +105,8 @@
function handleDayChange(index: number) {
if (index !== selectedDayIndex) {
selectedDayIndex = index;
// Evict cache so the new range fetches fresh data
clearMonitorBarCache();
fetchData(dayOptions[index].days);
}
}
@@ -118,7 +154,7 @@
<DropdownMenu.Content align="end">
<DropdownMenu.Label>{$t("Select Range")}</DropdownMenu.Label>
<DropdownMenu.Group>
{#each dayOptions as option, i}
{#each dayOptions as option, i (option.days)}
<DropdownMenu.Item
class="cursor-pointer text-xs {selectedDayIndex === i ? 'bg-secondary' : ''}"
onclick={() => handleDayChange(i)}
@@ -157,37 +193,83 @@
<div class="flex gap-4">
<div class="flex flex-1 flex-col items-start gap-1">
<p class="text-2xl font-semibold">{displayUptime}%</p>
<p class="text-muted-foreground text-sm">{$t("Uptime")}</p>
</div>
<div class="flex flex-1 flex-col items-end gap-1">
<p class="text-2xl font-semibold">{displayAvgLatency}</p>
<p class="text-muted-foreground text-sm">{$t("Avg Latency")}</p>
<p class="text-muted-foreground text-sm font-medium">{$t("Uptime")}</p>
</div>
</div>
<!-- Status Bar Calendar -->
<StatusBarCalendar data={displayData} {monitorTag} barHeight={40} radius={8} />
<div class="flex flex-col gap-1">
<StatusBarCalendar data={displayData} {monitorTag} barHeight={40} radius={8} />
<!-- Date labels -->
<div class="flex justify-between">
<p class="text-muted-foreground text-xs font-medium">
{#if displayData.length > 0}
{$formatDate(displayData[0].ts, "d MMM yyyy")}
{/if}
</p>
<p class="text-muted-foreground text-xs font-medium">
{#if displayData.length > 0}
{$formatDate(displayData[displayData.length - 1].ts, "d MMM yyyy")}
{/if}
</p>
<!-- Date labels -->
<div class="flex justify-between">
<p class="text-muted-foreground text-xs font-medium">
{#if displayData.length > 0}
{$formatDate(displayData[0].ts, page.data.dateAndTimeFormat.dateOnly)}
{/if}
</p>
<p class="text-muted-foreground text-xs font-medium">
{#if displayData.length > 0}
{$formatDate(displayData[displayData.length - 1].ts, page.data.dateAndTimeFormat.dateOnly)}
{/if}
</p>
</div>
</div>
<!-- Add group GroupMonitorPopover here -->
{#if groupTags.length > 0}
<div class="flex justify-center">
<GroupMonitorPopover tags={groupTags} days={selectedDays} {endOfDayTodayAtTz}>
{$t("Included Monitors (%count)", { count: String(groupTags.length) })}
<ArrowUp class="size-3" />
</GroupMonitorPopover>
</div>
{/if}
<!-- Latency Chart -->
<div class="pt-2">
<p class="text-muted-foreground mb-2 text-xs font-medium">
<p class="text-muted-foreground mb-2 text-sm font-medium">
{$t("Latency Trend")}
<Popover.Root>
<Popover.Trigger class="text-foreground cursor-pointer underline">{latencyChartLabel}</Popover.Trigger>
<Popover.Content class="flex w-fit flex-col gap-2">
<p class="text-muted-foreground text-xs font-medium">
{$t("Select latency metric to display")}
</p>
<ToggleGroup.Root
type="single"
spacing={2}
size="sm"
value={latencyMetric}
onValueChange={(v) => {
if (v) latencyMetric = v;
}}
class="flex justify-between"
>
<ToggleGroup.Item value="average" aria-label="Average latency">{$t("Avg Latency")}</ToggleGroup.Item>
<ToggleGroup.Item value="maximum" aria-label="Maximum latency">{$t("Max Latency")}</ToggleGroup.Item>
<ToggleGroup.Item value="minimum" aria-label="Minimum latency">{$t("Min Latency")}</ToggleGroup.Item>
</ToggleGroup.Root>
</Popover.Content>
</Popover.Root>
</p>
<LatencyTrendChart data={displayData} height={128} />
<!-- Latency Stats -->
<div class="mb-3 flex justify-between gap-4">
<div class="flex flex-col items-start gap-1">
<p class="text-lg font-semibold">{displayMinLatency}</p>
<p class="text-muted-foreground text-xs">{$t("Minimum Latency")}</p>
</div>
<div class="flex flex-col items-center gap-1">
<p class="text-lg font-semibold">{displayAvgLatency}</p>
<p class="text-muted-foreground text-xs">{$t("Average Latency")}</p>
</div>
<div class="flex flex-col items-end gap-1">
<p class="text-lg font-semibold">{displayMaxLatency}</p>
<p class="text-muted-foreground text-xs">{$t("Maximum Latency")}</p>
</div>
</div>
<LatencyTrendChart data={latencyChartData} label={latencyChartLabel} height={128} />
</div>
{/if}
</Card.Content>
@@ -0,0 +1,126 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { page } from "$app/state";
import { Button } from "$lib/components/ui/button/index.js";
import * as Popover from "$lib/components/ui/popover/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import ICONS from "$lib/icons";
import clientResolver from "$lib/client/resolver.js";
import { formatDate, formatDuration } from "$lib/stores/datetime";
import { t } from "$lib/stores/i18n";
import type { NotificationEvent } from "$lib/server/controllers/dashboardController.js";
import Calendar from "@lucide/svelte/icons/calendar-1";
import { format } from "date-fns";
import { onMount } from "svelte";
interface Props {
monitorTags?: string[];
compact?: boolean;
eventsPath: string;
}
let { monitorTags = [], compact = true, eventsPath = "" }: Props = $props();
let open = $state(false);
let notifications = $state<NotificationEvent[]>([]);
let loading = $state(false);
const defaultEventsPath = $derived(`/events/${format(new Date(), "MMMM-yyyy")}`);
const resolvedEventsPath = $derived.by(() => {
const finalEventsPath = eventsPath || defaultEventsPath;
if (page.data?.globalPageVisibilitySettings?.forceExclusivity) {
const currentPagePath = page.params?.page_path?.trim();
return currentPagePath ? `/${currentPagePath}${finalEventsPath}` : finalEventsPath;
}
return finalEventsPath;
});
async function fetchNotifications() {
loading = true;
try {
const query = monitorTags.length > 0 ? `?tags=${encodeURIComponent(monitorTags.join(","))}` : "";
const response = await fetch(clientResolver(resolve, "/dashboard-apis/notifications") + query);
if (response.ok) {
const payload = await response.json();
notifications = payload.notifications || [];
}
} catch {
// silently fail
} finally {
loading = false;
}
}
onMount(() => {
fetchNotifications();
});
</script>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
size={compact ? "icon-sm" : "sm"}
class="bg-background/80 dark:bg-background/70 border-foreground/10 relative mr-2 rounded-full border text-xs shadow-none backdrop-blur-md"
aria-label={$t("Notifications")}
>
{#if loading}
<Spinner class="h-4 w-4" />
{:else}
<ICONS.Bell class="h-4 w-4" />
{#if notifications.length > 0}
<span
class="bg-accent-foreground text-accent absolute -top-1 -right-1 min-w-4 rounded-full px-1 text-[10px] leading-4"
>
{notifications.length}
</span>
{/if}
{/if}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content
align="end"
class="bg-background/30 supports-backdrop-filter:bg-background/20 w-96 rounded-3xl border p-0 shadow-2xl backdrop-blur-2xl"
sideOffset={8}
>
<div class="flex items-center justify-between border-b px-4 py-3">
<h4 class="text-sm font-semibold">{$t("Events")}</h4>
<Button variant="outline" href={clientResolver(resolve, resolvedEventsPath)} size="icon-sm" class="rounded-btn">
<Calendar class="size-4" />
</Button>
</div>
{#if notifications.length === 0}
<div class="text-muted-foreground px-4 py-6 text-sm">
{$t("No events to show")}
</div>
{:else}
<div class="scrollbar-hidden max-h-96 overflow-y-auto">
{#each notifications as item, i (`${item.eventType}-${item.eventURL}-${item.eventDate}-${i}`)}
<a
href={clientResolver(resolve, item.eventURL)}
class="hover:bg-muted/60 block border-b px-4 py-3 last:border-b-0"
>
<div class="my-0.5 flex items-center justify-between gap-2 text-xs">
<span class="text-muted-foreground text-[11px] uppercase">{$t(item.eventType)}</span>
<span class="text-{item.eventStatus.toLowerCase()}">{$t(item.eventStatus)}</span>
</div>
<div class="flex items-start justify-between gap-2">
<p class="line-clamp-2 text-sm">{item.eventTitle}</p>
</div>
<div class="text-muted-foreground mt-1 flex flex-wrap items-center gap-2 text-xs">
<span>{$formatDate(item.eventDate, page.data.dateAndTimeFormat.datePlusTime)}</span>
<span></span>
<span>{$formatDuration(item.eventStartDateTime, item.eventEndDateTime, $t("Ongoing"))}</span>
</div>
</a>
{/each}
</div>
{/if}
</Popover.Content>
</Popover.Root>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
import { onMount } from "svelte";
import type { PageNavItem } from "$lib/server/controllers/dashboardController.js";
import { page } from "$app/state";
import * as Item from "$lib/components/ui/item/index.js";
import ChevronRight from "@lucide/svelte/icons/chevron-right";
let currentPath = $derived(page.params.page_path);
let pages = $state<PageNavItem[]>([]);
let pagesLoading = $state(false);
const defaultHomePage = $derived(pages.find((p) => p.page_path == ""));
const currentPage = $derived(pages.find((p) => p.page_path === currentPath) || defaultHomePage);
async function fetchPages() {
pagesLoading = true;
try {
const response = await fetch(clientResolver(resolve, "/dashboard-apis/pages"));
if (response.ok) {
pages = await response.json();
console.log(">>>>>>---- PageList:25 ", pages);
}
} catch {
// silently fail, pages dropdown will just not show
} finally {
pagesLoading = false;
}
}
onMount(() => {
fetchPages();
});
</script>
<div class="flex shrink-0 items-center gap-2">
{#if pagesLoading}
<Button
variant="outline"
size="sm"
class="bg-background/80 dark:bg-background/70 border-foreground/10 flex items-center justify-center rounded-full border text-xs shadow-none backdrop-blur-md"
disabled
>
<Spinner class="h-4 w-4" />
</Button>
{:else if pages.length > 0}
<!-- loop through pages -->
<div class="flex w-full flex-col gap-2">
{#each pages as page}
<Item.Root variant="outline" class="rounded-3xl">
{#snippet child({ props })}
<a href={clientResolver(resolve, `/${page.page_path}`)} {...props}>
{#if page.page_logo}
<Item.Media variant="image">
<img
src={page.page_logo}
alt={page.page_title}
width="32"
height="32"
class="size-8 rounded object-cover"
/>
</Item.Media>
{/if}
<Item.Content>
<Item.Title class="line-clamp-1">
{page.page_title}
</Item.Title>
<Item.Description>{page.page_header}</Item.Description>
</Item.Content>
<Item.Actions>
<ChevronRight class="size-4" />
</Item.Actions>
</a>
{/snippet}
</Item.Root>
{/each}
</div>
{/if}
</div>

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