Compare commits

...

247 Commits

Author SHA1 Message Date
Raj Nandan Sharma 74b6311e81 style: prettify files touched by Last Known Status work
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 23:18:22 +05:30
Raj Nandan Sharma c4c16d65a6 docs: document Last known status default and amend ADR 0005
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:53:57 +05:30
Raj Nandan Sharma 758cf5e4d5 feat(manage): Last known status option with callout in Default Status dropdown
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:49:10 +05:30
Raj Nandan Sharma 7c17db12dc style(migrations): match down() signature to house convention
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:44:58 +05:30
Raj Nandan Sharma d9954ea085 feat(db): migrate default_status to closed value set
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:41:57 +05:30
Raj Nandan Sharma 305c0a05fe feat(api): enforce closed default_status set with LAST_KNOWN scope rule
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:35:42 +05:30
Raj Nandan Sharma f83447f806 feat(scheduler): write CARRIED samples for LAST_KNOWN default status fixes #721
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:30:12 +05:30
Raj Nandan Sharma 42ca67b172 docs(db): clarify carry-chain inclusion and any-type caveat on latest-data queries
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:28:37 +05:30
Raj Nandan Sharma 806df0d73c feat(db): add getLatestAlertVisibleData query for last-known-status carry source
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:25:03 +05:30
Raj Nandan Sharma 5f66cada43 feat(constants): add CARRIED sample type and LAST_KNOWN default status
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:20:42 +05:30
Raj Nandan Sharma 85788c76f7 docs: design for Last Known Status default (fix #721)
ADR 0006, glossary terms, and implementation plan from the grilling session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:18:48 +05:30
Raj Nandan Sharma 604210568b Merge pull request #747 from rajnandan1/fix/633
refactor(alerts): expand alert evaluation to include all alert-visibl…
2026-06-07 19:27:57 +05:30
Raj Nandan Sharma 41f5296227 refactor(alertingQueue): remove unused imports for cleaner code 2026-06-07 19:27:34 +05:30
Raj Nandan Sharma 8c1a97d844 fix(api): make alert enqueue best-effort and align range-PATCH evaluation timestamp
Review feedback on #747:
- Wrap alertingQueue.push in try/catch in both data PATCH endpoints — the
  sample is already committed, so a Redis/BullMQ outage must not turn a
  successful write into a 500 (which would invite client retries and
  duplicate MANUAL rows).
- Compute lastWrittenTs with GetMinuteStartTimestampUTC(body.end_ts) —
  UpdateMonitoringData floors both bounds to minute starts and writes
  through the floored end inclusive, so the previous raw-offset formula
  could name a timestamp no stored row has when start_ts was unaligned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:54:50 +05:30
Raj Nandan Sharma ed1a70d75b refactor(alerts): expand alert evaluation to include all alert-visible sample types fixes #633 2026-06-07 18:43:38 +05:30
Raj Nandan Sharma e63a2f6311 Merge pull request #746 from rajnandan1/fix/issue-736
refactor(api): enhance page settings management and validation implem…
2026-06-07 13:34:41 +05:30
Raj Nandan Sharma 951ab06f7e fix(manage): require whole-number status history days in both editors
Addresses coderabbit review on #746: the bounds checks allowed decimals.
Number.isInteger is now part of the validity deriveds in the monitor-level
status history card, and the pages editor gains the same guard (it previously
saved display settings with no validation at all) plus step=1 and error
styling on the inputs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:28:08 +05:30
Raj Nandan Sharma af4684a90f refactor(monitors): extract per-field validity deriveds in status history card
Addresses coderabbit's outside-diff comment on #746: isDesktopValid and
isMobileValid are derived once and reused by isValid and both input error
states, instead of repeating the bounds check four times.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:19:04 +05:30
Raj Nandan Sharma da6eaee3ab fix(api): sanitize stored event-branch leaves on read and validate them on write
Address coderabbit review on #746:
- toApiPageSettings runs type-filtering sanitizers over stored incidents and
  include_maintenances so wrong-typed leaves (enabled: "yes",
  max_count: "five") never override defaults in responses
- validatePageSettings checks every known leaf: booleans for enabled/show,
  non-negative integers for max_count/days fields
- DeepPartial recurses only into plain object maps; arrays pass through

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:05:10 +05:30
Raj Nandan Sharma f264115ab8 fix(api): make page settings patching a true deep merge with normalized reads
Address review feedback on #746:
- applyPageSettingsPatch now deep-merges the mapped patch into the stored
  JSON: nested unknown keys survive, untouched siblings survive, and extra
  client keys are persisted as the schema's additionalProperties allows
- toApiPageSettings normalizes invalid stored values (unknown layout style,
  out-of-range or non-integer history days) so responses always satisfy the
  OpenAPI enum and bounds
- new PageSettingsPatch (DeepPartial) type lets TS clients send partial
  nested updates like { monitor_status_history_days: { mobile: 14 } }
- incidents / include_maintenances and their known sub-objects are now
  validated as objects (incidents: 123 returns 400)
- CONTEXT.md wording: the UI and API expose the same settings, surfaces may
  name fields differently

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 12:35:54 +05:30
Raj Nandan Sharma 15b78dab66 refactor(api): centralize status history days and monitor layout styles using global constants 2026-06-07 12:29:54 +05:30
Raj Nandan Sharma 54277ece9a refactor(api): enhance page settings management and validation implements fixes 736 2026-06-07 12:07:34 +05:30
Raj Nandan Sharma 54f056ad34 Merge pull request #745 from rajnandan1/fix/737
feat(api): address the home page as ~home in the v4 pages API
2026-06-06 23:13:40 +05:30
Raj Nandan Sharma 8d2808c291 fix(api): forbid deleting the home page via ~home
DeletePage in pagesController already enforces this invariant for the manage
UI (the UI's delete confirm for home was always rejected server-side), and
the public site root assumes the home page exists. Before the ~home token
the v4 DELETE could never reach the home page; now that it can, it returns
400 like the rest of the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:12:28 +05:30
Raj Nandan Sharma bd638ccf24 feat(api): render the home page's page_path as ~home in api responses
What a consumer reads is now exactly what it can address: the list, single,
and write responses all show ~home for the home page instead of an empty
string, so list -> pick -> PATCH round-trips cleanly. Read-modify-write
bodies that send ~home back are treated as no path change. The token moves
to global-constants as HOME_PAGE_TOKEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:03:16 +05:30
Raj Nandan Sharma c2945485e2 feat(api): address the home page as ~home and return json 404s for unmatched api routes
The home page is stored with an empty page_path, which can not appear as a
URL segment, so /api/v4/pages/{page_path} could not address it at all. The
middleware now maps the special segment ~home to the empty-path lookup. The
token can never collide with a real page because the path sanitizer strips
tildes, and tilde is RFC 3986 unreserved so clients never need to encode it
(percent-encoded %7Ehome works too).

Semantics follow the manage UI: PATCH via ~home accepts every field except
page_path, which is fixed for the home page, and DELETE is allowed.

Requests to /api/ paths with no matching route (e.g. GET /api/pages/) now
return a json NOT_FOUND error instead of SvelteKit's html error page.

Documented in the OpenAPI spec (PagePath parameter + PATCH note), ADR 0004,
and the CONTEXT.md glossary.

Fixes #737

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:52:46 +05:30
Raj Nandan Sharma a57c92fc0e Merge pull request #744 from rajnandan1/fix/692
refactor(database): implement connection pool tuning and health check…
2026-06-06 21:47:02 +05:30
Raj Nandan Sharma 8e7bc47b14 refactor(monitors): update visibility toggle logic and labels for clarity 2026-06-06 21:46:25 +05:30
Raj Nandan Sharma cd26c46493 log(database): output database type during configuration 2026-06-06 21:41:45 +05:30
Raj Nandan Sharma 508b08f8f3 fix(database): clamp pool bounds, guard redis probe, harden error page
Address review feedback on #744:
- clamp DATABASE_POOL_MAX to >= 1 and DATABASE_POOL_MIN to <= max so bad
  env values can not produce a pool that fails every acquire
- healthcheck redis probe checks client status before PING so commands are
  not queued indefinitely while redis is down (maxRetriesPerRequest is null)
- probe() clears its timeout timer once the check settles
- error.html shows only the status code, not the error message
- docs: correct SQLite default to kener.sqlite.db to match knexfile

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:24:03 +05:30
Raj Nandan Sharma 638393efac refactor(database): implement connection pool tuning and health checks for improved reliability 2026-06-06 21:06:06 +05:30
Raj Nandan Sharma 5a54d69d87 Merge pull request #743 from rajnandan1/fix/739
feat(monitors): implement toggle functionality for monitor status and…
2026-06-06 19:49:15 +05:30
Raj Nandan Sharma 7e5ea5fda1 feat(monitors): implement toggle functionality for monitor status and visibility, implements #739 2026-06-06 19:45:00 +05:30
Raj Nandan Sharma 175cf605c6 Merge pull request #742 from rajnandan1/fix/723
feat(api): add absolute `url` field to v4 incident, maintenance, and maintenance event responses
2026-06-06 19:02:29 +05:30
Raj Nandan Sharma 6a9bfffbd4 fix(api): guarantee absolute site url and include READY in event status casts
Address review feedback on #742:
- GetSiteURL now only returns absolute http(s) origins, falling back to the
  ORIGIN env var when the stored siteURL is unset or scheme-less, so the v4
  url fields honor the OpenAPI format: uri contract
- event status casts now use the response interface types
  (MaintenanceEventResponse["status"], MaintenanceEventDetailResponse["event_status"])
  so READY is included and the casts can not drift from the API contract

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:38:25 +05:30
Raj Nandan Sharma 7b120911b4 chore: remove redundant code blocks from the repository 2026-06-06 18:26:57 +05:30
Raj Nandan Sharma 35817bc20a feat(api): add absolute url field to v4 incident, maintenance, and maintenance event responses
The public /maintenances/<id> route is keyed by maintenance EVENT id by
default, while /api/v4/maintenances returns maintenance ids. Consumers
that concatenated API ids onto the public path landed on the wrong page
(#723) — an apparent off-by-one title mismatch with no actual data
corruption.

Instead of flipping the route default (which would break every internal
link, subscriber email, and bookmarked URL), v4 API responses now carry
an absolute `url` field built from the configured Site URL:

- Maintenance responses link via /maintenances/<id>?type=maintenance
- Maintenance event responses link via /maintenances/<event_id>
- Incident responses link via /incidents/<id> (parity)

Also updates the OpenAPI spec, records the decision in
docs/adr/0002, and pins Maintenance vs Maintenance Event in CONTEXT.md.

Fixes #723

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:55:35 +05:30
Raj Nandan Sharma a8fbac1b69 Merge pull request #741 from rajnandan1/fix/694
chore: update documentation and enhance monitor group functionality, …
2026-06-06 13:05:15 +05:30
Raj Nandan Sharma cfc99e2f14 chore: update documentation and enhance monitor group functionality, fixes #694 2026-06-06 13:04:13 +05:30
Raj Nandan Sharma 31ba10f434 Merge pull request #740 from rajnandan1/fix/707
refactor: implement absolute URL resolution for social media meta tags
2026-06-06 12:26:31 +05:30
Raj Nandan Sharma 1750e2a341 refactor: implement absolute URL resolution for social media meta tags 2026-06-06 12:25:42 +05:30
Raj Nandan Sharma a12df92b94 Merge pull request #734 from Kukks/fix/sticky-header-backdrop
Fix overlapping header sections on the public page
2026-06-06 11:32:38 +05:30
Andrew Camilleri 5d86084138 fix(public): add frosted backdrop behind fixed nav and sticky theme-plus bar
The sticky theme-plus bar has no background, and the gap between it and the
fixed nav stays transparent, so page content shows through both sections as
it scrolls. Paint one blurred layer behind both (above content, below nav
z-10 and bar z-20).
2026-05-29 10:30:27 +02:00
Raj Nandan Sharma 7050f780a3 Merge pull request #702 from smeeckaert/feature/change-alert-body-description
Update alert body description
2026-04-09 10:21:50 +05:30
Raj Nandan Sharma cb93089dcc Merge pull request #703 from LoveGmod/patch-1
Update french locale syntax
2026-04-09 10:21:12 +05:30
LoveGmod fcd05e1d68 Update fr.json
french syntax changes
2026-04-05 23:54:33 +02:00
Martin SMEECKAERT db9d7807e0 update alert body description 2026-04-03 08:05:54 +02:00
Raj Nandan Sharma b7e0756c54 Merge pull request #699 from toporivskiy/main 2026-04-02 14:20:05 +05:30
Andrew Toporivskiy b920d2f9bc Ukrainian translate 2026-04-02 10:35:54 +03:00
github-actions[bot] 560c87219b chore(release): bump version to 4.0.23 2026-04-01 03:18:39 +00:00
Raj Nandan Sharma 94e24eec04 Include allPerms.ts in the Docker build process 2026-03-31 23:02:22 +05:30
Raj Nandan Sharma 15680a58aa Merge pull request #695 from rajnandan1/rbac-1-2
Implement role and permission management system with seeding scripts …
2026-03-31 22:08:34 +05:30
Raj Nandan Sharma 59f0eaef27 Refactor layout component to streamline dynamic styles and improve color handling 2026-03-31 22:08:08 +05:30
Raj Nandan Sharma 8362a73058 Implement indexing for users_roles table and enhance roles seeding logic to prevent FK constraint errors 2026-03-31 21:58:43 +05:30
Raj Nandan Sharma 52f8c50f50 Implement v4.0.23 changelog with new features, improvements, and breaking changes 2026-03-31 21:37:54 +05:30
Raj Nandan Sharma 60868d55ca Implement role validation in login action to ensure users have active roles assigned 2026-03-31 21:04:21 +05:30
Raj Nandan Sharma f7e657ee95 Implement role backfilling logic in down migration and remove unused vault permission from ROUTE_PERMISSION_MAP 2026-03-31 20:48:41 +05:30
Raj Nandan Sharma 63e5ec2886 Refactor role validation to ensure all role_ids are active and update role badge logic to prioritize active roles 2026-03-31 20:11:11 +05:30
Raj Nandan Sharma 2aef97c1ed Implement role migration and enforce role permissions in user management 2026-03-31 19:10:37 +05:30
Raj Nandan Sharma 51b2da97e0 Implement role and permission management system with seeding scripts for permissions and roles, including user role migration and UI components for role management. 2026-03-31 16:55:40 +05:30
Raj Nandan Sharma 50bddcd9a3 Merge pull request #691 from p-klassen/p-klassen-patch-1 2026-03-28 18:10:31 +05:30
p-klassen bd36533b05 Update de.json
Update wording and sentences (depending in their context) to be more natural and closer to  how they are actually commonly used in standard German.
2026-03-28 12:58:03 +01:00
github-actions[bot] 17500a0b43 chore(release): bump version to 4.0.22 2026-03-28 06:02:11 +00:00
Raj Nandan Sharma 0050cd810b Merge pull request #690 from rajnandan1/fix/sqlite-migration
Fix/sqlite migration
2026-03-28 11:23:34 +05:30
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
292 changed files with 15691 additions and 4807 deletions
+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
}
}
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/ss-shadcn-svelte
+3 -10
View File
@@ -1,8 +1,9 @@
---
name: tailwindcss
displayName: Tailwind CSS
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.
version: 1.0.0
user-invokable: false
metadata:
category: styling
---
# Tailwind CSS v4 Development Guidelines
@@ -358,11 +359,3 @@ Tailwind v4 delivers 3.5x faster full builds (~100ms) compared to v3 using moder
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
## Additional Resources
For detailed information, see:
- [Utility Patterns](resources/utility-patterns.md)
- [Component Library](resources/component-library.md)
- [Configuration Guide](resources/configuration.md)
+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 -43
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,11 +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.
# Other skills
Read files in .claude/skills for more instructions on specific tasks or file types.
+3 -2
View File
@@ -1,4 +1,4 @@
name: Create Release (Deterministic)
name: Create Release
on:
workflow_dispatch:
@@ -53,7 +53,7 @@ jobs:
- name: Bump package version
run: |
VERSION="${{ inputs.version }}"
CURRENT_VERSION="$(node -p \"require('./package.json').version\")"
CURRENT_VERSION=$(node -p 'require("./package.json").version')
if [ "$CURRENT_VERSION" != "$VERSION" ]; then
npm version "$VERSION" --no-git-tag-version --allow-same-version
@@ -95,3 +95,4 @@ jobs:
generate_release_notes: true
make_latest: ${{ inputs.make_latest && 'true' || 'false' }}
prerelease: ${{ inputs.prerelease }}
token: ${{ secrets.RELEASE_TOKEN }}
+5 -2
View File
@@ -40,7 +40,7 @@ jobs:
run: |
TAG="${{ github.event.release.tag_name || github.ref_name }}"
EXPECTED_VERSION="${TAG#v}"
PACKAGE_VERSION="$(node -p \"require('./package.json').version\")"
PACKAGE_VERSION=$(node -p 'require("./package.json").version')
if [ "$PACKAGE_VERSION" != "$EXPECTED_VERSION" ]; then
echo "package.json version mismatch"
@@ -83,6 +83,8 @@ jobs:
BASE_SUFFIX=""
fi
WITH_DOCS="false"
if [ "${{ matrix.variant }}" = "alpine" ]; then
VARIANT_SUFFIX="-alpine"
else
@@ -94,6 +96,7 @@ jobs:
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"
@@ -129,7 +132,7 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VARIANT=${{ matrix.variant }}
WITH_DOCS=true
WITH_DOCS=${{ steps.vars.outputs.with_docs }}
KENER_BASE_PATH=${{ matrix.base_path }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
+2 -1
View File
@@ -19,4 +19,5 @@ config/static/*
!config/static/.kener
**/*.yaml
**/*.yml
.github/
.github/
src/lib/components/ui
+9
View File
@@ -22,6 +22,15 @@ 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:
+137
View File
@@ -0,0 +1,137 @@
# 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
## Agent skills
### Issue tracker
Issues and PRDs are tracked in GitHub Issues for `rajnandan1/kener`. See `docs/agents/issue-tracker.md`.
### Triage labels
Triage uses the default mattpocock/skills label vocabulary. See `docs/agents/triage-labels.md`.
### Domain docs
This repo uses a single-context domain-doc layout. See `docs/agents/domain.md`.
+107
View File
@@ -0,0 +1,107 @@
# Kener
An open-source status page application providing real-time monitoring, uptime tracking, incident management, and customizable dashboards.
## Language
### Monitoring
**Monitor**:
A single check against a service, with a unique tag, a type (API, Ping, TCP, DNS, SSL, SQL, Heartbeat, GameDig, Group, gRPC, None), and a status (ACTIVE or otherwise).
_Avoid_: Check, probe, service
**Inactive Monitor**:
A monitor that is not checked at all: the scheduler drops its job and no monitoring data is collected until it is made ACTIVE again. Independent of visibility (see Hidden Monitor).
_Avoid_: Disabled monitor, paused monitor
**Hidden Monitor**:
A monitor excluded from all status pages while remaining fully checked and alerted. Independent of ACTIVE/INACTIVE.
_Avoid_: Invisible monitor, private monitor
**Group Monitor**:
A monitor whose status is derived from other monitors via a weighted score (UP=1, DEGRADED=0.5, DOWN=0; maintenance counts as UP). A group cannot contain another group.
_Avoid_: Monitor group, composite monitor
**Member**:
A monitor belonging to a Group Monitor, carrying a weight and a position in the execution order. Membership is an explicit stored list, never a dynamic rule (e.g. tag wildcards).
_Avoid_: Child monitor, sub-monitor
**Weight**:
A member's share of the group score, between 0 and 1. Weights across a group's members must sum to 1. Any membership change (add or remove) redistributes all weights equally; manual tuning happens after membership is settled.
**Execution Order**:
The stored order in which a Group Monitor's members are checked before aggregation. Manually arranged, not derived.
**Eligible Monitor**:
A monitor that may become a Member: ACTIVE, not a Group Monitor, and not the group being edited itself.
**Monitoring Sample**:
One recorded data point for a monitor at a timestamp: a status, a latency, and a sample type describing how it was produced. Every sample is either Observed or Synthetic.
_Avoid_: Data point, record, check result
**Observed Sample**:
A Monitoring Sample produced by a check that actually ran against the target, whatever the outcome: a clean evaluation (`REALTIME`), a timed-out check (`TIMEOUT`), or a check that errored (`ERROR`). A check failing to reach the target is itself an observation — for most monitor types that is exactly what "down" looks like.
**Synthetic Sample**:
A Monitoring Sample written by the system or an admin rather than by a check: a raw heartbeat receipt (`SIGNAL`), a status pushed through the data API (`MANUAL`), a default-status fill (`DEFAULT_STATUS`), a last-known-status fill (`CARRIED`), or an incident/maintenance overlay (`INCIDENT`, `MAINTENANCE`).
**Default Status**:
A monitor's answer to what a minute without a Monitoring Sample means. Exactly one choice from a closed set: nothing (`NONE` — the minute shows no data), a fixed status (`UP`, `DOWN`, `DEGRADED`) written as default-status fill, or Last Known Status. `MAINTENANCE` is not a Default Status (a maintenance overlay is an event, not a fill).
_Avoid_: Fallback status, fill status
**Last Known Status**:
A Default Status choice where a minute without a sample repeats the most recent Alert-Visible Sample — status and latency alike — written as a Carried Sample (`CARRIED`). Carried Samples are themselves alert-visible, so the chain continues from the last live statement: overlays and heartbeat receipts never become sticky, backdated corrections do not change the present, and alerts trigger and resolve on carried minutes like any other. Carry never expires and never backfills: it starts at the tick after the choice is made, and Carried Samples persist as history if the choice is later changed. A monitor with no Alert-Visible Sample yet has nothing to carry — its minutes show no data. Only None-type monitors may choose it; changing the monitor's type away from None resets the Default Status to UP.
_Avoid_: Sticky status, carry-forward mode
**Stale Member**:
A Member whose monitor is no longer an Eligible Monitor (paused or deleted after being added). It remains a Member until explicitly removed, but is excluded from the group score.
### Alerting
**Alert Configuration**:
A per-monitor alerting rule: a condition (status, latency, or uptime against a value), a Failure Threshold, a Success Threshold, whether triggering creates an incident, and the Triggers to notify.
_Avoid_: Alert rule, alarm
**Alert**:
A fired instance of an Alert Configuration for a monitor. TRIGGERED when the condition holds, RESOLVED when the resolve condition later holds. May own the incident it created.
_Avoid_: Alarm, notification (that's what Triggers send)
**Trigger**:
A notification channel (email, webhook, Discord, Slack) that Alert Configurations notify on trigger and on resolve.
_Avoid_: Notifier, channel
**Alert-Visible Sample**:
A Monitoring Sample that alert evaluation can see: every Observed Sample, plus data-API pushes (`MANUAL`), default-status fill (`DEFAULT_STATUS`), and last-known-status fill (`CARRIED`). Raw heartbeat receipts (`SIGNAL`) and incident/maintenance overlays are never alert-visible — while an overlay is active the alert window freezes (alerts neither trigger nor resolve). All alert conditions (status and latency alike) evaluate the same alert-visible timeline.
**Failure Threshold**:
The number of consecutive Alert-Visible Samples matching the condition required to trigger an Alert.
**Success Threshold**:
The number of consecutive Alert-Visible Samples meeting the resolve condition required to resolve an Alert.
### Pages
**Page**:
A public status page with its own path, title, monitors, and display settings. Served at `/<page_path>`.
**Home Page**:
The Page served at the site root. Its stored path is empty, it always exists (it can not be deleted), and its path can not be changed. Addressed in the API by the `~home` token.
_Avoid_: Default page, base page, root page
**Status History Window**:
The number of days of per-day status shown for a monitor, per device class (desktop/mobile). Configurable at two levels with the same defaults and bounds: per Page (applies to all its monitors) and per Monitor (overrides the page level).
_Avoid_: History days, bar count
**Page Settings**:
A Page's display configuration: status-history window per device class, monitor layout style, per-page meta/social overrides, and event display preferences. The admin UI and the API expose the same settings, though each surface may name fields differently; a writer must never drop fields it does not understand.
_Avoid_: Display settings (ambiguous with site-wide event display settings)
### Maintenance
**Maintenance**:
A recurring maintenance definition: title, description, an RRULE schedule, a duration, and affected monitors. Identified by its own id.
_Avoid_: Maintenance window, maintenance event (that's an occurrence, see below)
**Maintenance Event**:
A single occurrence of a Maintenance, generated from its RRULE: a concrete start/end time with a lifecycle status (SCHEDULED → READY → ONGOING → COMPLETED). Has its own id, independent of the Maintenance id. The public maintenance page is keyed by Maintenance Event id.
_Avoid_: Occurrence, maintenance instance
+5
View File
@@ -135,6 +135,7 @@ 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"
@@ -162,8 +163,12 @@ COPY --chown=node:node --from=builder /app/seeds ./seeds
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/allPerms.ts ./src/lib/allPerms.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
+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.
+12 -18
View File
@@ -1,10 +1,5 @@
# Kener - Stunning Status Pages
<details>
<summary>Upcoming Version 4.0.0</summary>
Currently we are working on updating kener to the latest svelte version with typescript
</details>
<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>
@@ -25,6 +20,7 @@
<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>
<p align="center">
@@ -51,6 +47,14 @@
| [🌍 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.
@@ -173,11 +177,7 @@ PORT=3000
For the full quick start (including local Docker builds and dev mode), see the docs:
- https://kener.ing/docs/quick-start
## One Click Deployment
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/template/spSvic?referralCode=1Pn7vs)
- https://kener.ing/docs/v4/getting-started/quick-start
## Features
@@ -207,10 +207,6 @@ Kener combines public status page essentials with advanced admin workflows.
- Integrate analytics providers like GA, Plausible, Mixpanel, Umami, and Clarity
- Access the full REST API for incidents, monitors, and reporting
<div align="left">
<img alt="Visitor Stats" src="https://widgetbite.com/stats/rajnandan"/>
</div>
## Technologies Used
- [SvelteKit](https://kit.svelte.dev/)
@@ -220,11 +216,9 @@ Kener combines public status page essentials with advanced admin workflows.
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)
- [Sponsor Me Using GitHub](https://github.com/sponsors/rajnandan1)
☕ &nbsp;[Buy Me a Coffee](https://www.buymeacoffee.com/rajnandan1)
![image](https://badges.pufler.dev/visits/rajnandan1/kener)
- [Buy Me a Coffee](https://www.buymeacoffee.com/rajnandan1)
## Contributing
+3
View File
@@ -1,6 +1,9 @@
#!/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..."
@@ -0,0 +1,5 @@
# Group membership is an explicit stored list, not a rule
When making group-monitor member selection searchable (#694), the requester also proposed dynamic membership by tag pattern (e.g. `site1-*` auto-adds matching monitors). We decided group membership stays an explicit, stored list of members. Dynamic membership contradicts the group model: each member carries an explicit weight (weights must sum to 1) and a manual execution order — a rule that adds/removes members over time would need an auto-weighting policy, silent weight redistribution when monitors are created or deleted, and an undefined execution order for matched members. Bulk needs are served in the editor instead: search plus "Add all N matching" makes large explicit groups cheap to build.
If wildcard groups are requested again, the answer is here: it's a different feature (a rule-based aggregate without weights or order), not an extension of Group Monitors.
@@ -0,0 +1,5 @@
# Public maintenance page is keyed by Maintenance Event id
The public route `/maintenances/<id>` interprets `<id>` as a Maintenance Event id by default (with `?type=maintenance` to address a Maintenance definition instead), even though the route param is named `maintenance_id`. Issue #723 showed this misleads API consumers: `/api/v4/maintenances` returns Maintenance ids, and linking those to `/maintenances/<id>` lands on whatever Event happens to carry that id — an apparent "off-by-one title mismatch" with no actual data corruption.
We considered flipping the route default to Maintenance ids but rejected it: every internal status-page link, subscriber email, and externally bookmarked URL is keyed by Event id, and all of those would break. Instead, v4 API responses carry an absolute `url` field (built from the configured Site URL) that resolves correctly — `?type=maintenance` for Maintenance objects, the plain Event-id path for Maintenance Events. Consumers should link via `url`, never by concatenating ids onto paths.
@@ -0,0 +1,9 @@
# Fail-fast, self-healing database pool defaults
`knexfile.ts` overrides knex's pool defaults for network databases (Postgres, MySQL): `pool.min` is 0 instead of 2, acquire/create timeouts are 15s instead of 60s/30s, and TCP keepalive is enabled on connections. All knobs are overridable via `DATABASE_*` env vars.
Two production incidents drove this. On Railway, a Postgres outage caused every request to hang for knex's default 60s `acquireConnectionTimeout` before failing with `KnexTimeoutError`, and after the database recovered the app stayed broken until a manual restart. In Docker Swarm (#692), the overlay network's conntrack silently dropped idle TCP connections after ~20 minutes, so the first request after an idle period drew a dead socket from the pool and returned a 500; the reporter worked around it with server-side Postgres `tcp_keepalives_*` settings and asked for an application-level fix.
Both share one root cause: knex keeps `pool.min` connections forever and never validates them. Those permanently-idle sockets are exactly the ones cloud networks (Railway proxies, Swarm overlays, k8s) silently kill, and after any database blip they wedge the pool with corpses. `min: 0` lets the reaper retire every idle connection (`idleTimeoutMillis` 30s, well under typical conntrack windows), keepalive lets the OS detect silently-dropped sockets, and the 15s timeouts turn a minute-long hang into a fast failure during an outage.
The trade-off: a quiet instance pays connection setup on the first query after idle (tens of milliseconds), and a database that takes longer than 15s to accept connections will see failures where the old defaults would have waited a minute. Deployments with such databases can raise `DATABASE_ACQUIRE_TIMEOUT_MS` / `DATABASE_CREATE_TIMEOUT_MS` rather than the project reverting to defaults that wedge everyone else.
+9
View File
@@ -0,0 +1,9 @@
# Home page is addressed as `~home` in the v4 API
The Home Page is stored with an empty `page_path`, which can not appear as a URL segment, so `/api/v4/pages/{page_path}` could not address it at all (#737). The v4 API now accepts the special segment `~home` for it: the request middleware maps `~home` to a lookup of the empty path before handlers run.
We considered the reporter's suggestion of a `default` keyword, and `home`, but both are valid page paths under the sanitizer (`[a-z0-9_-]`), so a real page could shadow the keyword or force reservation rules and migration edge cases. We also considered addressing pages by id, which either breaks the existing path-based contract or is ambiguous with numeric page paths. `~home` can never collide because the sanitizer strips `~`, and tilde is an RFC 3986 unreserved character, so clients never need to encode it (percent-encoded `%7Ehome` works too, since the middleware decodes segments).
Semantics follow the server-side invariants the manage UI relies on: `PATCH` via `~home` accepts every field except `page_path`, which is fixed for the home page (the UI disables the field), and `DELETE` is rejected — `DeletePage` in `pagesController` throws "Cannot delete the home page" and the rest of the app assumes the home page exists.
API responses also render the home page's `page_path` as `~home` (list, single, and write responses), so what a consumer reads is exactly what it can address — list → pick → `PATCH` round-trips cleanly, including read-modify-write bodies that send `~home` back (treated as "no path change"). The stored path remains empty and the public URL remains the site root; consumers must not build public URLs by concatenating `page_path`.
@@ -0,0 +1,9 @@
# Alerts evaluate alert-visible samples, not just REALTIME ones
The consecutive-sample checks behind alert evaluation (`consecutivelyStatusFor`, `consecutivelyLatencyGreaterThan`, `consecutivelyLatencyLessThan` in `src/lib/server/db/repositories/monitoring.ts`) consider samples whose type is `REALTIME`, `ERROR`, `TIMEOUT`, `MANUAL`, or `DEFAULT_STATUS` — the "alert-visible" set — instead of `REALTIME` only. Both data-API PATCH endpoints (single timestamp and range) enqueue one alert evaluation after writing `MANUAL` rows. `SIGNAL` rows and `INCIDENT`/`MAINTENANCE` overlay rows remain invisible to alerting. Amended by ADR 0006: last-known-status fill (`CARRIED`) later joined the alert-visible set under the same invariant.
Two issues drove this. In #633, a GameDig monitor showed DOWN on the status page but never alerted: a down game server makes `GameDig.query` throw, so every down-sample is recorded as `ERROR`, which the old `type = REALTIME` filter excluded — the "N consecutive DOWN" condition could never become true. The same failure mode silently broke gRPC, SQL, and SSL monitors (hard-down records `ERROR`) and API monitors whose outage manifests as timeouts (`TIMEOUT`). In #720, a NONE monitor driven by the data API never alerted for two stacked reasons: PATCH writes `MANUAL` rows the filter excluded, and the endpoint never enqueued evaluation at all. The status page and UPTIME alerts have no type filter, which is why users saw DOWN while alerts stayed silent.
The whitelist is exactly the set of types written by flows that trigger alert evaluation — scheduler checks (`REALTIME`/`ERROR`/`TIMEOUT`), default-status fill (`DEFAULT_STATUS`), and data-API pushes (`MANUAL`). That invariant ("every enqueuer of evaluation contributes a row the evaluator can see") is what keeps the un-time-bounded last-N query self-healing: without `DEFAULT_STATUS` in the set, a NONE monitor with a default status evaluates every minute against rows it cannot see, so stale backfilled `MANUAL` rows would rank as "the last N" and fire alerts weeks after the fact. The rejected alternatives: fixing only `gamedigCall` to emit `REALTIME` on query failure (fixes one monitor type out of five, loses the stored down-vs-errored diagnostic, and does nothing for existing data), and time-bounding the query (the cutoff must scale with each monitor's cron, which means parsing cron expressions in the alert path for marginal benefit).
The trade-offs, accepted deliberately for one uniform rule across status and latency alerts: a service that degrades from slow to hard-down resolves an active latency alert (error samples carry latency 0, satisfying "consecutively below threshold") — the status alert is the one that covers outages; a NONE monitor with a default status auto-resolves MANUAL-pushed alerts once default fill resumes, because a default status is an explicit statement that absence of pushes means that status; and the fix is retroactive, so monitors that were already down at upgrade time alert shortly after — which is the bug report, inverted.
+7
View File
@@ -0,0 +1,7 @@
# Last Known Status is a Default Status choice that repeats the latest alert-visible sample
Issue #721: push-driven NONE monitors lost their status between pushes in v4 — one red minute, then gray forever — because the per-minute sample model only fills gaps with a static `default_status`. We added a fifth Default Status choice, `LAST_KNOWN`, where each tick with no observed sample writes a `CARRIED` row repeating the most recent alert-visible sample — status and latency alike. It is modeled as a dropdown value rather than a separate "sticky" checkbox so that "what does a minute without a sample mean" stays a single dimension with no conflicting combinations; the same cleanup removed the `MAINTENANCE` option, which the UI offered but the fill engine had always silently ignored (stored `MAINTENANCE`/unknown values migrate to `NONE`, preserving behavior).
The carry source is the most recent **Alert-Visible Sample** by timestamp, and `CARRIED` itself joins the alert-visible set. That one rule does three jobs: incident/maintenance overlays and raw heartbeat `SIGNAL` receipts can never become sticky; backdated data-API corrections cannot rewrite the present (newer carried rows outrank them); and ADR 0005's invariant — every flow that enqueues alert evaluation contributes a row the evaluator can see — keeps holding. The consequences were accepted deliberately: a single DOWN push triggers status alerts once carried minutes meet the failure threshold, alerts never auto-resolve (recovery must be pushed — unlike a fixed default, nothing "resumes"), and enabling the option on a monitor whose last push was DOWN fires the alert shortly after — the bug report, inverted, same as ADR 0005.
Rejected alternatives: a staleness cap ("carry for at most X, then gray") re-introduces "absence means unknown" — the opposite of what the admin just selected — and dead-integration detection is what Heartbeat monitors are for; reusing the `DEFAULT` sample type for carried rows loses the stored distinction between "admin declared absence means X" and "system repeated the last live statement"; backfilling the gap on enable would mutate historical uptime, so carry is tick-forward only and `CARRIED` rows persist as history if the setting is later changed. `LAST_KNOWN` is selectable only on NONE-type monitors — for polled types an observed sample wins the merge every tick, so offering it would be a dormant knob; changing a monitor's type away from NONE auto-resets the Default Status to UP so the invalid combination never persists.
+37
View File
@@ -0,0 +1,37 @@
# Domain Docs
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
This repo is configured as a **single-context** repo.
## Before exploring, read these
- **`CONTEXT.md`** at the repo root.
- **`docs/adr/`** — read ADRs that touch the area you're about to work in.
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved.
## File structure
Single-context repo:
```text
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```
## Use the glossary's vocabulary
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`).
## Flag ADR conflicts
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
+22
View File
@@ -0,0 +1,22 @@
# Issue tracker: GitHub
Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations.
## Conventions
- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
- **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
- **Comment on an issue**: `gh issue comment <number> --body "..."`
- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
- **Close**: `gh issue close <number> --comment "..."`
Infer the repo from `git remote -v``gh` does this automatically when run inside a clone.
## When a skill says "publish to the issue tracker"
Create a GitHub issue.
## When a skill says "fetch the relevant ticket"
Run `gh issue view <number> --comments`.
+15
View File
@@ -0,0 +1,15 @@
# Triage Labels
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
| Label in mattpocock/skills | Label in our tracker | Meaning |
| -------------------------- | -------------------- | ---------------------------------------- |
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
| `needs-info` | `needs-info` | Waiting on reporter for more information |
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
| `ready-for-human` | `ready-for-human` | Requires human implementation |
| `wontfix` | `wontfix` | Will not be actioned |
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
Edit the right-hand column to match whatever vocabulary you actually use.
@@ -0,0 +1,794 @@
# Last Known Status (fix #721) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a `LAST_KNOWN` Default Status choice for NONE-type (Manual) monitors: each scheduler tick without new data writes a `CARRIED` sample repeating the most recent alert-visible sample (status + latency), so push-driven monitors keep their status between pushes.
**Architecture:** The carry fill slots into the existing `defaultData` branch of the monitor-execute worker (`monitorExecuteQueue.ts`), sourcing from a new repository query for the latest alert-visible sample. A single normalization helper in `monitorsController.ts` enforces the closed `default_status` value set (`NONE|UP|DOWN|DEGRADED|LAST_KNOWN`) and the "LAST_KNOWN only on NONE-type, auto-reset to UP otherwise" rule across all three monitor write paths (manage UI action, v4 POST, v4 PATCH). A migration normalizes legacy values (`MAINTENANCE`/unknown/NULL → `NONE`).
**Tech Stack:** SvelteKit 2 (Svelte 5 runes), Knex migrations, BullMQ workers, shadcn-svelte UI.
**Design authority:** `docs/adr/0006-last-known-status-fill.md` and the `Default Status` / `Last Known Status` / `Alert-Visible Sample` entries in `CONTEXT.md`. If a step seems to contradict those, the docs win.
**Verification approach:** This repo has NO test infrastructure (no test script, no tests/ dir). Backend logic is verified with throwaway `vite-node` scripts driving repository classes against in-memory better-sqlite3 (delete the scripts before committing), plus a live end-to-end pass against the dev server. `npm run check` gates every commit.
---
### Task 1: Constants + alert-visible whitelist
**Files:**
- Modify: `src/lib/global-constants.ts:43`
- Modify: `src/lib/server/db/repositories/monitoring.ts:13-20`
- [ ] **Step 1: Add the two constants**
In `src/lib/global-constants.ts`, the default export object currently has (line 43):
```typescript
DEFAULT_STATUS: "DEFAULT",
```
Add two lines directly after it:
```typescript
DEFAULT_STATUS: "DEFAULT",
CARRIED: "CARRIED",
LAST_KNOWN: "LAST_KNOWN",
```
(`CARRIED` is a **sample type** written by last-known-status fill; `LAST_KNOWN` is a **default_status value** stored on the monitor. They are different namespaces that happen to live in the same constants object — keep both names exactly as above.)
- [ ] **Step 2: Add CARRIED to the alert-visible whitelist**
In `src/lib/server/db/repositories/monitoring.ts`, replace lines 13-20:
```typescript
/**
* Sample types alert evaluation can see (see docs/adr/0005-alerts-evaluate-alert-visible-samples.md).
* Exactly the types written by flows that enqueue alert evaluation: scheduler checks
* (REALTIME/ERROR/TIMEOUT), default-status fill (DEFAULT_STATUS), and data-API pushes (MANUAL).
* SIGNAL rows (raw heartbeat receipts) and INCIDENT/MAINTENANCE overlays stay invisible, so the
* alert window freezes during manual overlays instead of triggering or resolving on them.
*/
const ALERT_VISIBLE_TYPES = [GC.REALTIME, GC.ERROR, GC.TIMEOUT, GC.MANUAL, GC.DEFAULT_STATUS]
```
with:
```typescript
/**
* Sample types alert evaluation can see (see docs/adr/0005-alerts-evaluate-alert-visible-samples.md
* and docs/adr/0006-last-known-status-fill.md).
* Exactly the types written by flows that enqueue alert evaluation: scheduler checks
* (REALTIME/ERROR/TIMEOUT), default-status fill (DEFAULT_STATUS), last-known-status fill (CARRIED),
* and data-API pushes (MANUAL).
* SIGNAL rows (raw heartbeat receipts) and INCIDENT/MAINTENANCE overlays stay invisible, so the
* alert window freezes during manual overlays instead of triggering or resolving on them.
*/
const ALERT_VISIBLE_TYPES = [GC.REALTIME, GC.ERROR, GC.TIMEOUT, GC.MANUAL, GC.DEFAULT_STATUS, GC.CARRIED]
```
- [ ] **Step 3: Type-check**
Run: `npm run check`
Expected: 0 errors (same error/warning count as before the change — run it on a clean tree first if unsure).
- [ ] **Step 4: Commit**
```bash
git add src/lib/global-constants.ts src/lib/server/db/repositories/monitoring.ts
git commit -m "feat(constants): add CARRIED sample type and LAST_KNOWN default status"
```
---
### Task 2: Repository — latest alert-visible sample query
**Files:**
- Modify: `src/lib/server/db/repositories/monitoring.ts` (after `getLatestMonitoringData`, line 79)
- Modify: `src/lib/server/db/dbimpl.ts:52-53` (declaration) and `:412` (binding)
- [ ] **Step 1: Add the repository method**
In `src/lib/server/db/repositories/monitoring.ts`, directly after the `getLatestMonitoringData` method (ends line 79), add:
```typescript
/**
* Latest sample the alert evaluator (and last-known-status fill) can see.
* Carry source for Default Status = LAST_KNOWN (docs/adr/0006): overlays
* (INCIDENT/MAINTENANCE) and raw heartbeat receipts (SIGNAL) are excluded,
* so they can never become sticky.
*/
async getLatestAlertVisibleData(monitor_tag: string): Promise<MonitoringData | undefined> {
return await this.knex("monitoring_data")
.where("monitor_tag", monitor_tag)
.whereIn("type", ALERT_VISIBLE_TYPES)
.orderBy("timestamp", "desc")
.limit(1)
.first();
}
```
- [ ] **Step 2: Expose it on the db singleton**
In `src/lib/server/db/dbimpl.ts`, after line 52 (`getLatestMonitoringData!: ...`), add the declaration:
```typescript
getLatestAlertVisibleData!: MonitoringRepository["getLatestAlertVisibleData"];
```
and after line 412 (`this.getLatestMonitoringData = ...bind(this.monitoring);`), add the binding:
```typescript
this.getLatestAlertVisibleData = this.monitoring.getLatestAlertVisibleData.bind(this.monitoring)
```
- [ ] **Step 3: Write the throwaway verification script**
Create `scripts/tmp-verify-carry-source.ts` (will be deleted, never committed):
```typescript
import Knex from "knex"
import { MonitoringRepository } from "../src/lib/server/db/repositories/monitoring"
const knex = Knex({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true })
await knex.schema.createTable("monitoring_data", (t) => {
t.string("monitor_tag")
t.integer("timestamp")
t.string("status")
t.float("latency")
t.string("type")
t.text("error_message")
t.primary(["monitor_tag", "timestamp"])
})
const repo = new MonitoringRepository(knex)
// Timeline: MANUAL DOWN, then a CARRIED copy, then an INCIDENT overlay, then a SIGNAL receipt.
await knex("monitoring_data").insert([
{ monitor_tag: "t", timestamp: 100, status: "DOWN", latency: 42, type: "MANUAL" },
{ monitor_tag: "t", timestamp: 160, status: "DOWN", latency: 42, type: "CARRIED" },
{ monitor_tag: "t", timestamp: 220, status: "UP", latency: 0, type: "INCIDENT" },
{ monitor_tag: "t", timestamp: 280, status: "UP", latency: 0, type: "SIGNAL" }
])
const latest = await repo.getLatestAlertVisibleData("t")
console.log("latest:", latest)
if (!latest || latest.timestamp !== 160 || latest.type !== "CARRIED" || latest.status !== "DOWN") {
throw new Error("FAIL: expected the CARRIED row at ts=160 (INCIDENT/SIGNAL must be skipped)")
}
const none = await repo.getLatestAlertVisibleData("missing")
if (none !== undefined) throw new Error("FAIL: expected undefined for unknown tag")
console.log("PASS")
await knex.destroy()
```
- [ ] **Step 4: Run it**
Run: `npx vite-node scripts/tmp-verify-carry-source.ts`
Expected: prints the ts=160 CARRIED row, then `PASS`.
- [ ] **Step 5: Delete the script, type-check, commit**
```bash
rm scripts/tmp-verify-carry-source.ts
npm run check
git add src/lib/server/db/repositories/monitoring.ts src/lib/server/db/dbimpl.ts
git commit -m "feat(db): add getLatestAlertVisibleData query for last-known-status carry source"
```
---
### Task 3: Engine — carry fill in the execute worker
**Files:**
- Modify: `src/lib/server/queues/monitorExecuteQueue.ts:125-156`
- [ ] **Step 1: Extend the defaultData branch**
In `src/lib/server/queues/monitorExecuteQueue.ts`, replace lines 125-139:
```typescript
let defaultData: MonitoringResultTS = {}
let mergedData: MonitoringResultTS = {}
if (monitor.default_status !== undefined && monitor.default_status !== null) {
if (([GC.UP, GC.DOWN, GC.DEGRADED] as string[]).indexOf(monitor.default_status) !== -1) {
defaultData[ts] = {
status: monitor.default_status,
latency: 0,
type: GC.DEFAULT_STATUS
}
if (monitor.default_status !== GC.UP) {
defaultData[ts].error_message = "Default status applied"
}
}
}
```
with:
```typescript
let defaultData: MonitoringResultTS = {}
let mergedData: MonitoringResultTS = {}
if (monitor.default_status !== undefined && monitor.default_status !== null) {
if (([GC.UP, GC.DOWN, GC.DEGRADED] as string[]).indexOf(monitor.default_status) !== -1) {
defaultData[ts] = {
status: monitor.default_status,
latency: 0,
type: GC.DEFAULT_STATUS
}
if (monitor.default_status !== GC.UP) {
defaultData[ts].error_message = "Default status applied"
}
} else if (monitor.default_status === GC.LAST_KNOWN) {
// Last Known Status fill (docs/adr/0006): repeat the most recent alert-visible
// sample — status and latency alike. No sample yet → nothing to carry → no fill.
const lastKnown = await db.getLatestAlertVisibleData(monitor.tag)
if (lastKnown && lastKnown.status) {
defaultData[ts] = {
status: lastKnown.status,
latency: lastKnown.latency ?? 0,
type: GC.CARRIED
}
if (lastKnown.status !== GC.UP) {
defaultData[ts].error_message = "Last known status applied"
}
}
}
}
```
- [ ] **Step 2: Fix the NO_DATA-preference block to preserve the fill's type**
Still in the same file, the block at (previously) lines 141-156 hardcodes `type: GC.DEFAULT_STATUS` when realtime returns NO_DATA but a fill exists. A heartbeat monitor with `LAST_KNOWN` would mislabel its carried rows. Replace:
```typescript
const defaultStatus = defaultData[ts]?.status
const realtimeStatus = realtimeData[ts]?.status
let realtimeDataForMerge = realtimeData
if (defaultStatus && realtimeStatus === GC.NO_DATA) {
// Apply the preference *before* merging so incident/maintenance can still override later.
// Also avoid carrying over realtime NO_DATA error_message.
realtimeDataForMerge = { ...realtimeData }
realtimeDataForMerge[ts] = {
...realtimeDataForMerge[ts],
status: defaultStatus,
type: GC.DEFAULT_STATUS
}
delete realtimeDataForMerge[ts].error_message
}
```
with:
```typescript
const defaultStatus = defaultData[ts]?.status
const realtimeStatus = realtimeData[ts]?.status
let realtimeDataForMerge = realtimeData
if (defaultStatus && realtimeStatus === GC.NO_DATA) {
// Apply the preference *before* merging so incident/maintenance can still override later.
// Also avoid carrying over realtime NO_DATA error_message.
// Keep the fill's own type: DEFAULT for fixed fill, CARRIED for last-known fill.
realtimeDataForMerge = { ...realtimeData }
realtimeDataForMerge[ts] = {
...realtimeDataForMerge[ts],
status: defaultStatus,
type: defaultData[ts].type
}
delete realtimeDataForMerge[ts].error_message
}
```
Note: `latency` in this branch intentionally stays whatever realtime reported — unchanged from today for fixed fill; for LAST_KNOWN-on-heartbeat the carried latency was already placed in `defaultData[ts]` and `mergedData` spread order (`{ ...defaultData, ...realtimeDataForMerge, ... }`) means the realtime object wins the spread; this matches existing fixed-fill behavior, do not "improve" it here.
- [ ] **Step 3: Type-check**
Run: `npm run check`
Expected: 0 new errors.
- [ ] **Step 4: Commit**
```bash
git add src/lib/server/queues/monitorExecuteQueue.ts
git commit -m "feat(scheduler): write CARRIED samples for LAST_KNOWN default status fixes #721"
```
(Live behavior is verified end-to-end in Task 8 — the worker needs Redis + the cron loop, so there is no isolated script for this task.)
---
### Task 4: Normalization chokepoint for all monitor writes
**Files:**
- Modify: `src/lib/server/controllers/monitorsController.ts` (near `CreateUpdateMonitor`, line 193)
- Modify: `src/routes/(api)/api/v4/monitors/+server.ts:104-121`
- Modify: `src/routes/(api)/api/v4/monitors/[monitor_tag]/+server.ts:72-78`
- [ ] **Step 1: Add the helper to monitorsController.ts**
Directly above `CreateUpdateMonitor` (line 193), add:
```typescript
const VALID_DEFAULT_STATUSES = ["NONE", GC.UP, GC.DOWN, GC.DEGRADED, GC.LAST_KNOWN] as const
/**
* Enforce the closed default_status value set and the LAST_KNOWN scope rule
* (docs/adr/0006): LAST_KNOWN is only meaningful on NONE-type (Manual) monitors;
* on any other type it silently resets to UP so the invalid combination never persists.
* Throws on values outside the closed set.
*/
export const NormalizeDefaultStatus = (monitorType: string | null | undefined, defaultStatus: string | null | undefined): string => {
const value = defaultStatus ?? "NONE"
if (!(VALID_DEFAULT_STATUSES as readonly string[]).includes(value)) {
throw new Error(`default_status must be one of: ${VALID_DEFAULT_STATUSES.join(", ")}`)
}
if (value === GC.LAST_KNOWN && monitorType !== "NONE") {
return GC.UP
}
return value
}
```
(`monitorsController.ts` already imports `GC` at line 25 — no import change needed.)
- [ ] **Step 2: Apply it in the manage-UI path**
Replace `CreateUpdateMonitor` (lines 193-201):
```typescript
export const CreateUpdateMonitor = async (monitor: MonitorInput): Promise<number | number[]> => {
let monitorData = { ...monitor }
if (monitorData.id) {
return await db.updateMonitor(monitorData as MonitorRecord)
} else {
validateMonitorTag(monitorData.tag)
return await db.insertMonitor(monitorData)
}
}
```
with:
```typescript
export const CreateUpdateMonitor = async (monitor: MonitorInput): Promise<number | number[]> => {
let monitorData = { ...monitor }
monitorData.default_status = NormalizeDefaultStatus(monitorData.monitor_type, monitorData.default_status)
if (monitorData.id) {
return await db.updateMonitor(monitorData as MonitorRecord)
} else {
validateMonitorTag(monitorData.tag)
return await db.insertMonitor(monitorData)
}
}
```
(The manage API route at `src/routes/(manage)/manage/api/+server.ts` wraps the action switch in try/catch (line 660) and surfaces thrown `Error.message` — no route change needed.)
- [ ] **Step 3: Apply it in v4 POST**
In `src/routes/(api)/api/v4/monitors/+server.ts`, add to the imports from the monitors controller (`GetMonitorsParsed` is already imported — extend that import):
```typescript
import { GetMonitorsParsed, NormalizeDefaultStatus } from "$lib/server/controllers/monitorsController"
```
(match the existing import line's exact shape — if `GetMonitorsParsed` is imported from a different specifier, add `NormalizeDefaultStatus` to that same line).
Then replace line 111:
```typescript
default_status: body.default_status ?? "UP",
```
with a pre-validated variable. Above the `const monitorData = {` block (line 104), insert:
```typescript
let defaultStatus: string
try {
defaultStatus = NormalizeDefaultStatus(body.monitor_type ?? "API", body.default_status ?? "UP")
} catch (e) {
const errorResponse: BadRequestResponse = {
error: {
code: "BAD_REQUEST",
message: e instanceof Error ? e.message : "Invalid default_status"
}
}
return json(errorResponse, { status: 400 })
}
```
and in `monitorData` use:
```typescript
default_status: defaultStatus,
```
- [ ] **Step 4: Apply it in v4 PATCH**
In `src/routes/(api)/api/v4/monitors/[monitor_tag]/+server.ts`, the handler resolves `updateData.monitor_type` at line 78 _after_ `updateData.default_status` at line 75 — the normalization must run after BOTH are resolved. Replace line 75:
```typescript
updateData.default_status = body.default_status !== undefined ? body.default_status : existingMonitor.default_status
```
with (keep it in place so field ordering stays readable, but move the value through the helper after line 78):
```typescript
updateData.default_status = body.default_status !== undefined ? body.default_status : existingMonitor.default_status
```
…and after line 78 (`updateData.monitor_type = ...`), insert:
```typescript
// Closed-set validation + LAST_KNOWN scope rule (docs/adr/0006). Runs after monitor_type
// is resolved so a type change away from NONE auto-resets LAST_KNOWN to UP.
try {
updateData.default_status = NormalizeDefaultStatus(updateData.monitor_type as string, updateData.default_status as string | null)
} catch (e) {
const errorResponse: BadRequestResponse = {
error: {
code: "BAD_REQUEST",
message: e instanceof Error ? e.message : "Invalid default_status"
}
}
return json(errorResponse, { status: 400 })
}
```
Add `NormalizeDefaultStatus` to this file's monitors-controller import the same way as in Step 3.
- [ ] **Step 5: Verify with a throwaway script**
Create `scripts/tmp-verify-normalize.ts`:
```typescript
import { NormalizeDefaultStatus } from "../src/lib/server/controllers/monitorsController"
const cases: Array<[string, string | null, string]> = [
["NONE", "LAST_KNOWN", "LAST_KNOWN"], // allowed on Manual monitors
["API", "LAST_KNOWN", "UP"], // auto-reset on any other type
["NONE", null, "NONE"], // null → NONE
["API", "DOWN", "DOWN"] // fixed values pass through
]
for (const [type, input, expected] of cases) {
const got = NormalizeDefaultStatus(type, input)
if (got !== expected) throw new Error(`FAIL: (${type}, ${input}) → ${got}, expected ${expected}`)
}
let threw = false
try {
NormalizeDefaultStatus("API", "MAINTENANCE")
} catch {
threw = true
}
if (!threw) throw new Error("FAIL: MAINTENANCE must be rejected")
console.log("PASS")
```
Run: `npx vite-node scripts/tmp-verify-normalize.ts`
Expected: `PASS`. (Importing monitorsController transitively pulls in the db singleton; vite-node loads the repo `.env` automatically, so the configured `DATABASE_URL`/`REDIS_URL` satisfy it. If module side-effects still fail outside the dev process, inline the `NormalizeDefaultStatus` cases into a temporary copy instead — the function is pure.)
- [ ] **Step 6: Delete script, type-check, commit**
```bash
rm scripts/tmp-verify-normalize.ts
npm run check
git add src/lib/server/controllers/monitorsController.ts "src/routes/(api)/api/v4/monitors/+server.ts" "src/routes/(api)/api/v4/monitors/[monitor_tag]/+server.ts"
git commit -m "feat(api): enforce closed default_status set with LAST_KNOWN scope rule"
```
---
### Task 5: Migration — normalize legacy default_status values
**Files:**
- Create: `migrations/20260607150000_normalize_default_status.ts`
- [ ] **Step 1: Write the migration**
```typescript
import type { Knex } from "knex"
// Closed default_status set as of docs/adr/0006. MAINTENANCE was offered by the old
// UI but never honored by the fill engine — it behaved exactly like "no fill", so it
// (and any other unknown value, and NULL) normalizes to NONE, preserving behavior.
const VALID = ["NONE", "UP", "DOWN", "DEGRADED", "LAST_KNOWN"]
export async function up(knex: Knex): Promise<void> {
await knex("monitors").whereNull("default_status").update({ default_status: "NONE" })
await knex("monitors").whereNotIn("default_status", VALID).update({ default_status: "NONE" })
}
export async function down(): Promise<void> {
// Irreversible by design: the values rewritten to NONE were dead (never honored
// by the fill engine), so there is nothing meaningful to restore.
}
```
- [ ] **Step 2: Run it against the dev database**
Run: `npm run migrate`
Expected: `Batch N run: 1 migrations` with no errors.
- [ ] **Step 3: Spot-check via the dev API**
With the dev server running (`npm run dev` if not already):
```bash
curl -s 'http://localhost:3000/api/v4/monitors' -H 'Authorization: Bearer <API_KEY>' | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const r=JSON.parse(s);const bad=(r.monitors||r.data||[]).filter(m=>!['NONE','UP','DOWN','DEGRADED','LAST_KNOWN'].includes(m.default_status));console.log('invalid default_status rows:',bad.length)})"
```
Expected: `invalid default_status rows: 0`.
- [ ] **Step 4: Commit**
```bash
git add migrations/20260607150000_normalize_default_status.ts
git commit -m "feat(db): migrate default_status to closed value set"
```
---
### Task 6: Manage UI — dropdown options + callout + auto-reset
**Files:**
- Modify: `src/routes/(manage)/manage/app/monitors/[tag]/components/GeneralSettingsCard.svelte:230-247`
**REQUIRED SUB-SKILL for this task: `svelte-code-writer` (per CLAUDE.md, mandatory for all .svelte edits).**
- [ ] **Step 1: Add imports and labels**
In the `<script lang="ts">` block (GC is already imported at line 20), add to the imports:
```typescript
import * as Alert from "$lib/components/ui/alert/index.js"
import TriangleAlertIcon from "@lucide/svelte/icons/triangle-alert"
```
and below the props destructuring add:
```typescript
const defaultStatusLabels: Record<string, string> = {
NONE: "None (show gaps as no data)",
UP: "UP",
DOWN: "DOWN",
DEGRADED: "DEGRADED",
LAST_KNOWN: "Last known status"
}
// LAST_KNOWN is only valid on Manual (NONE-type) monitors; the server enforces the
// same rule (NormalizeDefaultStatus), this effect just keeps the UI honest live.
$effect(() => {
if (monitor.monitor_type !== "NONE" && monitor.default_status === GC.LAST_KNOWN) {
monitor.default_status = GC.UP
toast.info("Default status was reset to UP — Last known status is only available for Manual monitors.")
}
})
```
(`toast` is already imported from `svelte-sonner` at line 16.)
- [ ] **Step 2: Replace the Default Status select**
Replace lines 230-247:
```svelte
<Label for="monitor-default-status">Default Status</Label>
<Select.Root
type="single"
value={monitor.default_status}
onValueChange={(v) => {
if (v) monitor.default_status = v
}}
>
<Select.Trigger id="monitor-default-status" class="w-full">
{monitor.default_status}
</Select.Trigger>
<Select.Content>
<Select.Item value="UP">UP</Select.Item>
<Select.Item value="DOWN">DOWN</Select.Item>
<Select.Item value="DEGRADED">DEGRADED</Select.Item>
<Select.Item value="MAINTENANCE">MAINTENANCE</Select.Item>
</Select.Content>
</Select.Root>
```
with:
```svelte
<Label for="monitor-default-status">Default Status</Label>
<Select.Root
type="single"
value={monitor.default_status ?? "NONE"}
onValueChange={(v) => {
if (v) monitor.default_status = v
}}
>
<Select.Trigger id="monitor-default-status" class="w-full">
{defaultStatusLabels[monitor.default_status ?? "NONE"] ?? monitor.default_status}
</Select.Trigger>
<Select.Content>
<Select.Item value="NONE">None (show gaps as no data)</Select.Item>
<Select.Item value="UP">UP</Select.Item>
<Select.Item value="DOWN">DOWN</Select.Item>
<Select.Item value="DEGRADED">DEGRADED</Select.Item>
{#if monitor.monitor_type === "NONE"}
<Select.Item value="LAST_KNOWN">Last known status</Select.Item>
{/if}
</Select.Content>
</Select.Root>
{#if monitor.default_status === GC.LAST_KNOWN}
<Alert.Root>
<TriangleAlertIcon />
<Alert.Title>Last known status</Alert.Title>
<Alert.Description>
<p>Kener will repeat the most recent status and latency every minute until your integration sends new data.</p>
<ul class="list-disc pl-4">
<li>
If your integration stops sending, the page keeps showing the last status indefinitely — Kener cannot tell "still up" from "stopped reporting". Use a Heartbeat
monitor to catch a silent integration.
</li>
<li>
Carried minutes count toward alert thresholds: a single DOWN push will trigger alerts after your failure threshold, and they stay triggered until you push a
recovery.
</li>
</ul>
</Alert.Description>
</Alert.Root>
{/if}
```
- [ ] **Step 3: Run the svelte autofixer / check**
Run: `npm run check`
Expected: 0 new errors or warnings for `GeneralSettingsCard.svelte`. Also run the svelte MCP autofixer on the component if the svelte-code-writer skill instructs it.
- [ ] **Step 4: Visual verification**
With `npm run dev` running, screenshot the editor for the seeded NONE-type monitor (`earth`):
```bash
npx playwright screenshot --channel chrome --color-scheme light --viewport-size "1440,900" --full-page --wait-for-timeout 3000 http://localhost:3000/manage/app/monitors/earth /tmp/lk-ui.png
```
(Authed page — if it renders the login screen, follow the storage-state cookie recipe in the project memory `kener-ui-verification-recipe`, or verify manually in the browser.)
Expected: dropdown shows the five options ("Last known status" present because earth is Manual/NONE type, "MAINTENANCE" gone); selecting "Last known status" reveals the callout.
- [ ] **Step 5: Commit**
```bash
git add "src/routes/(manage)/manage/app/monitors/[tag]/components/GeneralSettingsCard.svelte"
git commit -m "feat(manage): Last known status option with callout in Default Status dropdown"
```
---
### Task 7: Docs — ADR cross-link + user documentation
**Files:**
- Modify: `docs/adr/0005-alerts-evaluate-alert-visible-samples.md` (append one sentence)
- Modify: `src/routes/(docs)/docs/content/v4/monitors/overview.md` (Default Status section)
**REQUIRED SUB-SKILL for the `src/routes/(docs)/docs/content/` edit: `documentation-writer` (per CLAUDE.md, mandatory for docs content).**
- [ ] **Step 1: Amend ADR 0005**
Append this sentence to the end of the first paragraph of `docs/adr/0005-alerts-evaluate-alert-visible-samples.md` (after "...remain invisible to alerting."):
```
Amended by ADR 0006: last-known-status fill (`CARRIED`) later joined the alert-visible set under the same invariant.
```
- [ ] **Step 2: Document Last Known Status in the monitor docs**
`src/routes/(docs)/docs/content/v4/monitors/overview.md` does not mention `default_status` today (verified) — add a new "Default Status" section to it. Following the documentation-writer skill's conventions, document:
- The five values: `NONE`, `UP`, `DOWN`, `DEGRADED`, `LAST_KNOWN`.
- `LAST_KNOWN` is only accepted for Manual (`NONE`-type) monitors; on any other type the API resets it to `UP`.
- Behavior: every minute without new data, Kener writes a `CARRIED` sample repeating the most recent alert-visible sample (status and latency). Carry never expires and starts at the next tick after the setting is saved (no backfill).
- The two warnings from the UI callout (stale-forever if the integration goes silent → use a Heartbeat monitor; carried minutes count toward alert thresholds and alerts only resolve on a pushed recovery).
- A curl example mirroring #721's flow:
```bash
curl -X PATCH 'https://status.example.com/api/v4/monitors/my-service/data/{current_unix_minute}' \
-H 'Authorization: Bearer <api-key>' \
-H 'Content-Type: application/json' \
--data '{"status": "DOWN", "latency": 100}'
# With Default Status = Last known status, the monitor stays DOWN until you push UP.
```
- [ ] **Step 3: Commit**
```bash
git add docs/adr/0005-alerts-evaluate-alert-visible-samples.md "src/routes/(docs)/docs/content/v4/monitors/overview.md"
git commit -m "docs: document Last known status default and amend ADR 0005"
```
---
### Task 8: End-to-end verification against the dev server
**Files:** none (verification only; uses the running `npm run dev` with Redis + Postgres up)
- [ ] **Step 1: Create a throwaway NONE monitor with LAST_KNOWN**
```bash
curl -s -X POST 'http://localhost:3000/api/v4/monitors' \
-H 'Authorization: Bearer <API_KEY>' -H 'Content-Type: application/json' \
--data '{"tag":"lk-e2e","name":"LK E2E","monitor_type":"NONE","default_status":"LAST_KNOWN","cron":"* * * * *"}'
```
Expected: 201, monitor JSON with `"default_status":"LAST_KNOWN"`.
- [ ] **Step 2: Confirm no fill before any push**
Wait ~70 seconds (one scheduler tick), then:
```bash
NOW=$(date -u +%s); curl -s "http://localhost:3000/api/v4/monitors/lk-e2e/data?start_ts=$((NOW-300))&end_ts=$NOW" -H 'Authorization: Bearer <API_KEY>'
```
Expected: `{"data":[]}` — nothing to carry yet (never-pushed monitors stay no-data).
- [ ] **Step 3: Push DOWN once, watch CARRIED rows appear**
```bash
NOW=$(date -u +%s)
curl -s -X PATCH "http://localhost:3000/api/v4/monitors/lk-e2e/data/$NOW" \
-H 'Authorization: Bearer <API_KEY>' -H 'Content-Type: application/json' \
--data '{"status":"DOWN","latency":2201}'
```
Wait ~130 seconds (two ticks), then re-run the Step 2 range query.
Expected: one `"type":"MANUAL"` DOWN row at the pushed minute, followed by `"type":"CARRIED"` rows with `"status":"DOWN","latency":2201` for each subsequent minute.
- [ ] **Step 4: Push recovery, confirm carry follows**
Repeat Step 3's PATCH with `{"status":"UP","latency":5}`. Wait ~70s.
Expected: subsequent CARRIED rows are `UP` with latency 5. The status page (`http://localhost:3000`) shows the monitor UP with the red DOWN window in today's bar.
- [ ] **Step 5: Verify the type-change auto-reset**
```bash
curl -s -X PATCH 'http://localhost:3000/api/v4/monitors/lk-e2e' \
-H 'Authorization: Bearer <API_KEY>' -H 'Content-Type: application/json' \
--data '{"monitor_type":"API","type_data":{"url":"https://example.com","timeout":5000}}'
```
Expected: 200 with `"default_status":"UP"` in the response (LAST_KNOWN auto-reset because the type left NONE). Then verify rejection:
```bash
curl -s -X PATCH 'http://localhost:3000/api/v4/monitors/lk-e2e' \
-H 'Authorization: Bearer <API_KEY>' -H 'Content-Type: application/json' \
--data '{"default_status":"MAINTENANCE"}'
```
Expected: 400 with `default_status must be one of: NONE, UP, DOWN, DEGRADED, LAST_KNOWN`.
- [ ] **Step 6: Clean up the test monitor**
The v4 monitor route has no DELETE handler (only GET/PATCH), so delete via the manage API action the dashboard uses, or from the UI at `http://localhost:3000/manage/app/monitors/lk-e2e` (Danger Zone → Delete). Verify it is gone from `http://localhost:3000/`.
- [ ] **Step 7: Final gate**
Run: `npm run check && npm run prettify`
Expected: clean check; prettify produces no diff beyond the files already touched (re-commit formatting if it does).
---
## Self-review notes
- **Spec coverage:** Q1 dropdown (Task 6), Q2 MAINTENANCE migration + closed set (Tasks 4, 5, 6), Q3 carry source (Task 2), Q4 CARRIED type + whitelist (Task 1), Q5 status+latency payload (Task 3), Q6/Q7 NONE-only + auto-reset (Tasks 4, 6), Q8 no expiry (no code — absence is the feature; documented in Task 7), Q9 tick-forward/no-backfill (no code — the engine only writes at `ts`; verified in Task 8 Step 2-3), Q10 alert consequences (Task 1 whitelist + existing evaluator, no further code), Q11 callout copy (Task 6).
- **Known non-goals:** no staleness cap, no backfill, no purge on disable, no change to `CloneMonitor` (it copies `monitor_type` + `default_status` together from an already-normalized source, so the pair stays valid).
- **Pre-existing race left untouched (deliberate):** a same-minute tick can overwrite a just-pushed MANUAL row's type via the response-queue upsert; this exists today for DEFAULT fill and is orthogonal to this change.
+47 -3
View File
@@ -7,13 +7,45 @@ const databaseURLParts = databaseURL.split("://");
const databaseType = databaseURLParts[0];
const databasePath = databaseURLParts[1];
const intFromEnv = (name: string, fallback: number): number => {
const raw = process.env[name];
if (raw === undefined) return fallback;
const parsed = parseInt(raw, 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
};
// TCP keepalive on pooled connections, on by default. Cloud networks (Railway,
// Docker Swarm overlays, k8s) silently drop idle TCP connections; without
// keepalive the pool keeps handing out dead sockets after an idle period or a
// database restart. See docs/adr/0003-fail-fast-self-healing-db-pool.md.
const keepAliveEnabled = process.env.DATABASE_KEEPALIVE !== "false";
// Pool defaults deviate from knex's on purpose:
// - min 0: knex's min 2 connections are never reaped, so they are exactly the
// ones that go stale and wedge the app until a manual restart
// - 15s acquire/create timeouts: fail fast instead of hanging requests for
// knex's default 60s during a database outage
// Tarn requires max >= 1 and min <= max; clamp so a bad env value can not
// produce a pool that fails every acquire
const poolMax = Math.max(1, intFromEnv("DATABASE_POOL_MAX", 10));
const poolMin = Math.min(intFromEnv("DATABASE_POOL_MIN", 0), poolMax);
const pool = {
min: poolMin,
max: poolMax,
idleTimeoutMillis: intFromEnv("DATABASE_IDLE_TIMEOUT_MS", 30000),
createTimeoutMillis: intFromEnv("DATABASE_CREATE_TIMEOUT_MS", 15000),
};
const acquireConnectionTimeout = intFromEnv("DATABASE_ACQUIRE_TIMEOUT_MS", 15000);
interface KnexConfig {
migrations: { directory: string };
seeds: { directory: string };
databaseType: string;
client?: string;
connection?: string | { filename: string };
connection?: string | { filename: string } | Record<string, unknown>;
useNullAsDefault?: boolean;
pool?: typeof pool;
acquireConnectionTimeout?: number;
}
const knexOb: KnexConfig = {
@@ -25,6 +57,7 @@ const knexOb: KnexConfig = {
},
databaseType,
};
console.log(`Configuring database with type ${databaseType}`);
if (databaseType === "sqlite") {
knexOb.client = "better-sqlite3";
knexOb.connection = {
@@ -33,10 +66,21 @@ if (databaseType === "sqlite") {
knexOb.useNullAsDefault = true;
} else if (databaseType === "postgresql") {
knexOb.client = "pg";
knexOb.connection = databaseURL;
knexOb.connection = {
connectionString: databaseURL,
keepAlive: keepAliveEnabled,
};
knexOb.pool = pool;
knexOb.acquireConnectionTimeout = acquireConnectionTimeout;
} else if (databaseType === "mysql") {
knexOb.client = "mysql2";
knexOb.connection = databaseURL;
knexOb.connection = {
uri: databaseURL,
enableKeepAlive: keepAliveEnabled,
keepAliveInitialDelay: 10000,
};
knexOb.pool = pool;
knexOb.acquireConnectionTimeout = acquireConnectionTimeout;
} else {
console.error("Invalid database type");
process.exit(1);
-27
View File
@@ -1,27 +0,0 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": ["src/app.html", "build/main.js", "scripts/**/*.{js,ts}", "migrations/**/*.{js,ts}", "seeds/**/*.{js,ts}"],
"project": ["src/**/*.{js,ts,svelte}", "scripts/**/*.{js,ts}", "migrations/**/*.{js,ts}", "seeds/**/*.{js,ts}"],
"ignore": ["src/lib/components/ui/**"],
"ignoreDependencies": [
"@babel/runtime",
"js-yaml",
"mysql2",
"node-cache",
"pg",
"pg-pool",
"randomstring",
"style-to-object",
"lucide-svelte",
"marked-gfm-heading-id"
],
"ignoreExportsUsedInFile": {
"interface": true,
"type": true
},
"ignoreBinaries": [],
"compilers": {
"css": ["postcss"],
"svelte": ["svelte"]
}
}
+55 -23
View File
@@ -1,17 +1,19 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema
.createTable("monitoring_data", (table) => {
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"]);
})
// Create monitor_alerts table
.createTable("monitor_alerts", (table) => {
});
}
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();
@@ -20,18 +22,29 @@ export async function up(knex: Knex): Promise<void> {
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) => {
});
}
// 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());
})
.createTable("monitors", (table) => {
});
}
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();
@@ -50,8 +63,11 @@ export async function up(knex: Knex): Promise<void> {
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) => {
});
}
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);
@@ -60,8 +76,11 @@ export async function up(knex: Knex): Promise<void> {
table.text("trigger_meta");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
})
.createTable("users", (table) => {
});
}
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();
@@ -71,8 +90,11 @@ export async function up(knex: Knex): Promise<void> {
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) => {
});
}
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();
@@ -80,8 +102,11 @@ export async function up(knex: Knex): Promise<void> {
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) => {
});
}
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();
@@ -90,8 +115,11 @@ export async function up(knex: Knex): Promise<void> {
table.timestamp("updated_at").defaultTo(knex.fn.now());
table.string("status", 255).defaultTo("ACTIVE");
table.string("state", 255).defaultTo("INVESTIGATING");
})
.createTable("incident_monitors", (table) => {
});
}
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);
@@ -99,8 +127,11 @@ export async function up(knex: Knex): Promise<void> {
table.timestamp("updated_at").defaultTo(knex.fn.now());
table.integer("incident_id").notNullable();
table.unique(["monitor_tag", "incident_id"]);
})
.createTable("incident_comments", (table) => {
});
}
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();
@@ -110,6 +141,7 @@ export async function up(knex: Knex): Promise<void> {
table.string("status", 255).defaultTo("ACTIVE");
table.string("state", 255).defaultTo("INVESTIGATING");
});
}
}
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("incidents", function (table) {
table.text("incident_type").defaultTo("INCIDENT");
});
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> {
@@ -1,10 +1,12 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("incidents", function (table) {
table.text("incident_source").defaultTo("DASHBOARD");
});
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> {
@@ -1,6 +1,8 @@
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();
@@ -1,8 +1,8 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema
.createTable("subscribers", (table) => {
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();
@@ -16,8 +16,11 @@ export async function up(knex: Knex): Promise<void> {
// 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();
@@ -32,8 +35,11 @@ export async function up(knex: Knex): Promise<void> {
// 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();
@@ -41,6 +47,7 @@ export async function up(knex: Knex): Promise<void> {
table.datetime("created_at").defaultTo(knex.fn.now());
table.datetime("updated_at").defaultTo(knex.fn.now());
});
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema
@@ -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,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,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> {
@@ -1,10 +1,20 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("monitoring_data", (table) => {
table.index(["timestamp"], "idx_monitoring_data_timestamp");
table.index(["monitor_tag", "type", "timestamp"], "idx_monitoring_data_monitor_tag_type_timestamp");
});
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> {
@@ -1,19 +1,27 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.table("incidents", (table) => {
table.string("is_global", 15).notNullable().defaultTo("YES");
});
await knex.schema.table("maintenances", (table) => {
table.string("is_global", 15).notNullable().defaultTo("YES");
});
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> {
await knex.schema.table("incidents", (table) => {
table.dropColumn("is_global");
});
await knex.schema.table("maintenances", (table) => {
table.dropColumn("is_global");
});
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;
}
}
@@ -0,0 +1,58 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// 1. Roles table
if (!(await knex.schema.hasTable("roles"))) {
await knex.schema.createTable("roles", (table) => {
table.string("id", 100).primary();
table.text("role_name").notNullable();
table.integer("readonly").notNullable().defaultTo(0);
table.string("status", 20).notNullable().defaultTo("ACTIVE");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
});
}
// 2. Permissions table
if (!(await knex.schema.hasTable("permissions"))) {
await knex.schema.createTable("permissions", (table) => {
table.string("id", 100).primary();
table.text("permission_name").notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
});
}
// 3. Roles ↔ Permissions junction table
if (!(await knex.schema.hasTable("roles_permissions"))) {
await knex.schema.createTable("roles_permissions", (table) => {
table.string("roles_id", 100).notNullable().references("id").inTable("roles").onDelete("CASCADE");
table.string("permissions_id", 100).notNullable().references("id").inTable("permissions").onDelete("CASCADE");
table.string("status", 20).notNullable().defaultTo("ACTIVE");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
table.primary(["roles_id", "permissions_id"]);
});
}
// 4. Users ↔ Roles junction table
if (!(await knex.schema.hasTable("users_roles"))) {
await knex.schema.createTable("users_roles", (table) => {
table.string("roles_id", 100).notNullable().references("id").inTable("roles").onDelete("CASCADE");
table.integer("users_id").unsigned().notNullable().references("id").inTable("users").onDelete("CASCADE");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
table.primary(["roles_id", "users_id"]);
table.index("users_id", "idx_users_roles_users_id");
});
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("users_roles");
await knex.schema.dropTableIfExists("roles_permissions");
await knex.schema.dropTableIfExists("permissions");
await knex.schema.dropTableIfExists("roles");
}
@@ -0,0 +1,94 @@
import type { Knex } from "knex";
// Maps the legacy users.role string to the new roles.id value.
// The old default was "user"; everything unmapped falls back to "member".
const ROLE_MAP: Record<string, string> = {
admin: "admin",
editor: "editor",
member: "member",
user: "member",
};
export async function up(knex: Knex): Promise<void> {
const hasColumn = await knex.schema.hasColumn("users", "role");
if (!hasColumn) return;
// 1. Ensure the three target roles exist so FK inserts succeed.
// Seeds will reconcile permissions later; we only need the rows.
const rolesToEnsure = [
{ id: "admin", role_name: "Administrator" },
{ id: "editor", role_name: "Editor" },
{ id: "member", role_name: "Member" },
];
for (const role of rolesToEnsure) {
const exists = await knex("roles").where("id", role.id).first();
if (!exists) {
await knex("roles").insert({
id: role.id,
role_name: role.role_name,
readonly: 1,
status: "ACTIVE",
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
}
// 2. Read users.role into memory BEFORE dropping the column.
// On SQLite, dropColumn recreates the table (create → copy → drop → rename),
// which can discard DML inserts to tables with FKs pointing at users.
const users: Array<{ id: number; role: string }> = await knex("users").select("id", "role");
// 3. Drop the column first.
await knex.schema.alterTable("users", (table) => {
table.dropColumn("role");
});
// 4. Now populate users_roles from the in-memory snapshot.
for (const user of users) {
const newRoleId = ROLE_MAP[user.role] ?? "member";
const alreadyAssigned = await knex("users_roles").where({ roles_id: newRoleId, users_id: user.id }).first();
if (!alreadyAssigned) {
await knex("users_roles").insert({
roles_id: newRoleId,
users_id: user.id,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
}
}
// Reverse map: pick the highest-precedence role when backfilling.
const REVERSE_ROLE_PRECEDENCE: string[] = ["admin", "editor", "member"];
export async function down(knex: Knex): Promise<void> {
const hasColumn = await knex.schema.hasColumn("users", "role");
if (!hasColumn) {
await knex.schema.alterTable("users", (table) => {
table.string("role").defaultTo("member");
});
}
// Backfill users.role from users_roles using deterministic precedence
const assignments: Array<{ users_id: number; roles_id: string }> = await knex("users_roles").select(
"users_id",
"roles_id",
);
// Group roles by user
const userRolesMap = new Map<number, string[]>();
for (const row of assignments) {
const list = userRolesMap.get(row.users_id) || [];
list.push(row.roles_id);
userRolesMap.set(row.users_id, list);
}
// Pick highest-precedence role for each user
for (const [userId, roleIds] of userRolesMap) {
const bestRole = REVERSE_ROLE_PRECEDENCE.find((r) => roleIds.includes(r)) || roleIds[0] || "member";
await knex("users").where("id", userId).update({ role: bestRole });
}
}
@@ -0,0 +1,16 @@
import type { Knex } from "knex";
// Closed default_status set as of docs/adr/0006. MAINTENANCE was offered by the old
// UI but never honored by the fill engine — it behaved exactly like "no fill", so it
// (and any other unknown value, and NULL) normalizes to NONE, preserving behavior.
const VALID = ["NONE", "UP", "DOWN", "DEGRADED", "LAST_KNOWN"];
export async function up(knex: Knex): Promise<void> {
await knex("monitors").whereNull("default_status").update({ default_status: "NONE" });
await knex("monitors").whereNotIn("default_status", VALID).update({ default_status: "NONE" });
}
export async function down(_knex: Knex): Promise<void> {
// Irreversible by design: the values rewritten to NONE were dead (never honored
// by the fill engine), so there is nothing meaningful to restore.
}
+405 -293
View File
File diff suppressed because it is too large Load Diff
+19 -7
View File
@@ -1,6 +1,6 @@
{
"name": "kener",
"version": "4.0.0",
"version": "4.0.23",
"type": "module",
"private": false,
"license": "MIT",
@@ -40,10 +40,10 @@
"devschedule": "vite-node src/lib/server/startup.ts",
"generate-readme": "node scripts/generate-readme.js",
"index-docs": "vite-node scripts/index-docs.ts",
"migrate": "npx knex migrate:latest",
"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",
@@ -55,7 +55,7 @@
"@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",
@@ -64,7 +64,7 @@
"@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",
@@ -80,7 +80,7 @@
"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-sonner": "^1.0.7",
@@ -113,6 +113,8 @@
"@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",
"@scalar/express-api-reference": "^0.8.28",
@@ -136,9 +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",
@@ -170,5 +172,15 @@
"style-to-object": "^1.0.14",
"svelte-codemirror-editor": "^2.1.0",
"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
-247
View File
@@ -1,247 +0,0 @@
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 localesDir = path.join(projectRoot, "src", "lib", "locales");
const WHITELISTED_DYNAMIC_KEYS = new Set([
"All Systems Operational",
"Degraded Performance",
"Partial Degraded Performance",
"Partial System Outage",
"Major System Outage",
"No Status Available",
]);
function fail(message) {
throw new Error(message);
}
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function parseArgs(argv) {
let reportPath;
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--report") {
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
fail("Missing value for --report. Usage: --report <path>");
}
reportPath = next;
i += 1;
continue;
}
if (arg.startsWith("--report=")) {
reportPath = arg.slice("--report=".length);
if (!reportPath) {
fail("Missing value for --report. Usage: --report <path>");
}
continue;
}
fail(`Unknown argument: ${arg}`);
}
return { reportPath };
}
function detectReportPath(overridePath) {
if (overridePath) {
const resolved = path.resolve(projectRoot, overridePath);
if (!fs.existsSync(resolved)) {
fail(`Report file not found: ${resolved}`);
}
return resolved;
}
const jsonPath = path.join(projectRoot, "translation-report.json");
const yamlPath = path.join(projectRoot, "translation-report.yaml");
if (fs.existsSync(jsonPath)) return jsonPath;
if (fs.existsSync(yamlPath)) return yamlPath;
fail(
"Could not find translation report. Expected translation-report.json or translation-report.yaml in project root, or use --report <path>.",
);
}
function loadReport(reportPath) {
const ext = path.extname(reportPath).toLowerCase();
const raw = fs.readFileSync(reportPath, "utf8");
let parsed;
try {
if (ext === ".json") {
parsed = JSON.parse(raw);
} else if (ext === ".yaml" || ext === ".yml") {
parsed = yaml.load(raw);
} else {
fail(`Unsupported report file extension: ${ext}. Use .json, .yaml, or .yml.`);
}
} catch (error) {
fail(`Failed to parse report at ${reportPath}: ${error instanceof Error ? error.message : String(error)}`);
}
if (!isPlainObject(parsed)) {
fail("Invalid report format: expected a top-level object.");
}
if (!isPlainObject(parsed.locales)) {
fail("Invalid report format: expected report.locales to be an object.");
}
return parsed;
}
function getUnusedKeysForLocale(report, localeFileName) {
const localeReport = report.locales[localeFileName];
if (localeReport === undefined) return [];
if (!isPlainObject(localeReport)) {
fail(`Invalid report format for locales.${localeFileName}: expected an object.`);
}
const { unused } = localeReport;
if (unused === undefined) return [];
if (!Array.isArray(unused)) {
fail(`Invalid report format for locales.${localeFileName}.unused: expected an array.`);
}
const nonStrings = unused.filter((key) => typeof key !== "string");
if (nonStrings.length > 0) {
fail(`Invalid report format for locales.${localeFileName}.unused: all entries must be strings.`);
}
return unused;
}
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 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 cleanTranslations(report) {
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 totalRemoved = 0;
const perFile = [];
for (const localeFileName of localeFiles) {
const localePath = path.join(localesDir, localeFileName);
const localeData = loadLocaleJson(localePath, localeFileName);
const unusedKeys = getUnusedKeysForLocale(report, localeFileName);
const unusedSet = new Set(unusedKeys.filter((key) => !WHITELISTED_DYNAMIC_KEYS.has(key)));
const currentMappings = localeData.mappings;
const cleanedMappings = {};
let removedCount = 0;
for (const [key, value] of Object.entries(currentMappings)) {
if (unusedSet.has(key)) {
removedCount += 1;
} else {
cleanedMappings[key] = value;
}
}
const sortedMappings = sortObjectKeysAscending(cleanedMappings);
const nextLocaleData = replaceMappingsPreserveTopLevelOrder(localeData, sortedMappings);
fs.writeFileSync(localePath, `${JSON.stringify(nextLocaleData, null, 2)}\n`, "utf8");
totalRemoved += removedCount;
perFile.push({ file: localeFileName, removed: removedCount });
}
for (const item of perFile) {
console.log(`${item.file}: removed ${item.removed} key${item.removed === 1 ? "" : "s"}`);
}
console.log(`Total removed: ${totalRemoved}`);
}
function main() {
const { reportPath: reportArg } = parseArgs(process.argv.slice(2));
const reportPath = detectReportPath(reportArg);
const report = loadReport(reportPath);
console.log(`Using report: ${path.relative(projectRoot, reportPath)}`);
cleanTranslations(report);
}
try {
main();
} catch (error) {
console.error("Failed to clean translations.");
console.error(error instanceof Error ? error.message : String(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();
+9 -6
View File
@@ -47,7 +47,8 @@ interface DocsSidebarGroup {
interface DocsNavTab {
name: string;
sidebar: DocsSidebarGroup[];
url?: string;
sidebar?: DocsSidebarGroup[];
}
interface DocsVersion {
@@ -164,14 +165,16 @@ async function main(): Promise<void> {
process.exit(1);
}
const primaryTabSidebar = latestVersion.content.navigation?.tabs?.[0]?.sidebar ?? [];
const sidebar = normalizeSidebar(primaryTabSidebar);
const tabs = latestVersion.content.navigation?.tabs ?? [];
const documents: DocsSearchDocument[] = [];
// Collect all pages from sidebar
// Collect all pages from all tabs' sidebars
const allPages: Array<{ page: DocsPageSource; group: string }> = [];
for (const sidebarGroup of sidebar) {
collectPages(sidebarGroup.pages, sidebarGroup.group, allPages);
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}`);
+122 -62
View File
@@ -1,81 +1,141 @@
import { handler } from "../build/handler.js";
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 { redisConnection } from "../src/lib/server/redisConnector.ts";
import knex from "knex";
import knexOb from "../knexfile.js";
const PORT = process.env.PORT || 3000;
const base = process.env.KENER_BASE_PATH || "";
const app: any = express();
const db = knex(knexOb);
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");
app.get(base + "/healthcheck", (req: any, res: any) => {
res.end("ok");
});
const app: any = express();
const db = knex(knexOb);
app.use(handler);
// Caps a health probe at 2s so a wedged dependency can not hang the
// endpoint. A probe is healthy unless it throws, times out, or resolves false.
const probe = async (check: () => Promise<unknown>): Promise<boolean> => {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const result = await Promise.race([
check(),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("health probe timeout")), 2000);
}),
]);
return result !== false;
} catch {
return false;
} finally {
clearTimeout(timer);
}
};
//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);
// Reports component health. Always 200 so healthcheck-driven restarters do
// not bounce the app while a dependency is down (a restart can not fix a
// dead database); pass ?strict=1 to get 503 when any component is down.
app.get(base + "/healthcheck", async (req: any, res: any) => {
const [dbOk, redisOk] = await Promise.all([
probe(() => dbInstance.ping()),
// Guard on status before PING: the shared ioredis client has
// maxRetriesPerRequest null, so commands sent while disconnected would
// queue forever and accumulate across healthcheck polls
probe(async () => {
const redis = redisConnection();
if (redis.status !== "ready") return false;
return await redis.ping();
}),
]);
const healthy = dbOk && redisOk;
const strict = req.query.strict === "1";
res.status(strict && !healthy ? 503 : 200).json({
status: healthy ? "ok" : "degraded",
db: dbOk,
redis: redisOk,
});
});
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"));
}
//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();
+2
View File
@@ -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(),
});
+28
View File
@@ -0,0 +1,28 @@
import type { Knex } from "knex";
import { permissions } from "../src/lib/allPerms.ts";
export async function seed(knex: Knex): Promise<void> {
const permissionIds = new Set(permissions.map((p) => p.id));
// Get all existing permissions
const existing: Array<{ id: string }> = await knex("permissions").select("id");
const existingIds = new Set(existing.map((e) => e.id));
// Insert missing permissions
for (const perm of permissions) {
if (!existingIds.has(perm.id)) {
await knex("permissions").insert({
id: perm.id,
permission_name: perm.permission_name,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
}
// Delete permissions that are no longer in the seed list
const toDelete = existing.filter((e) => !permissionIds.has(e.id)).map((e) => e.id);
if (toDelete.length > 0) {
await knex("permissions").whereIn("id", toDelete).del();
}
}
+106
View File
@@ -0,0 +1,106 @@
import type { Knex } from "knex";
import { permissions } from "../src/lib/allPerms.ts";
/**
* Seeds the three readonly roles (admin, editor, member),
* assigns permissions to each role in roles_permissions,
* and migrates existing users.role → users_roles.
*
* Permission mapping derived from src/routes/(manage)/manage/api/+server.ts:
*
* admin → all permissions
* editor → all except api_keys.delete (AdminCan-only)
* member → all .read permissions only
*/
const readonlyRoles = [
{ id: "admin", role_name: "Administrator" },
{ id: "editor", role_name: "Editor" },
{ id: "member", role_name: "Member" },
];
const allPermissionIds = permissions.map((p) => p.id);
const readPermissionIds = allPermissionIds.filter((id) => id.endsWith(".read"));
const rolePermissions: Record<string, string[]> = {
admin: allPermissionIds,
editor: allPermissionIds.filter((id) => id !== "api_keys.delete"),
member: readPermissionIds,
};
export async function seed(knex: Knex): Promise<void> {
// 1. Ensure readonly roles exist
for (const role of readonlyRoles) {
const existing = await knex("roles").where("id", role.id).first();
if (!existing) {
await knex("roles").insert({
id: role.id,
role_name: role.role_name,
readonly: 1,
status: "ACTIVE",
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
}
// 2. Seed roles_permissions for readonly roles
// Only insert permissions that actually exist in the permissions table
// to avoid FK constraint errors if permissions seed hasn't run yet.
const existingPermRows: Array<{ id: string }> = await knex("permissions").select("id");
const existingPermIds = new Set(existingPermRows.map((p) => p.id));
for (const [roleId, permissionIds] of Object.entries(rolePermissions)) {
const validPermissionIds = permissionIds.filter((id) => existingPermIds.has(id));
const existingPerms: Array<{ permissions_id: string }> = await knex("roles_permissions")
.where("roles_id", roleId)
.select("permissions_id");
const existingSet = new Set(existingPerms.map((e) => e.permissions_id));
// Insert missing permissions
for (const permId of validPermissionIds) {
if (!existingSet.has(permId)) {
await knex("roles_permissions").insert({
roles_id: roleId,
permissions_id: permId,
status: "ACTIVE",
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
}
// Remove permissions no longer assigned to this role
const desiredSet = new Set(validPermissionIds);
const toRemove = existingPerms.filter((e) => !desiredSet.has(e.permissions_id)).map((e) => e.permissions_id);
if (toRemove.length > 0) {
await knex("roles_permissions").where("roles_id", roleId).whereIn("permissions_id", toRemove).del();
}
}
// 3. Migrate existing users: read users.role → insert into users_roles
const hasRoleColumn = await knex.schema.hasColumn("users", "role");
if (hasRoleColumn) {
const users: Array<{ id: number; role: string }> = await knex("users").select("id", "role");
for (const user of users) {
if (!user.role) continue;
// Only migrate if a matching role exists
const roleExists = await knex("roles").where("id", user.role).first();
if (!roleExists) continue;
// Skip if already assigned
const existing = await knex("users_roles").where({ roles_id: user.role, users_id: user.id }).first();
if (!existing) {
await knex("users_roles").insert({
roles_id: user.role,
users_id: user.id,
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">
+73
View File
@@ -0,0 +1,73 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="refresh" content="30" />
<title>%sveltekit.status% — Status page temporarily unavailable</title>
<style>
:root {
color-scheme: light dark;
--bg: #ffffff;
--fg: #09090b;
--muted: #71717a;
--border: #e4e4e7;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #09090b;
--fg: #fafafa;
--muted: #a1a1aa;
--border: #27272a;
}
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
color: var(--fg);
font-family:
ui-sans-serif,
system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
}
.card {
max-width: 28rem;
margin: 1rem;
padding: 2rem;
border: 1px solid var(--border);
border-radius: 1rem;
text-align: center;
}
h1 {
font-size: 1.25rem;
margin: 0 0 0.5rem;
}
p {
color: var(--muted);
font-size: 0.875rem;
line-height: 1.5;
margin: 0.25rem 0;
}
.code {
color: var(--muted);
font-size: 0.75rem;
margin-top: 1.25rem;
}
</style>
</head>
<body>
<div class="card">
<h1>This status page is temporarily unavailable</h1>
<p>We are having trouble serving this page right now. It usually resolves on its own.</p>
<p>This page will retry automatically in 30 seconds.</p>
<div class="code">%sveltekit.status%</div>
</div>
</body>
</html>
+52 -2
View File
@@ -1,8 +1,10 @@
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 { GetMonitorsParsed } from "$lib/server/controllers/monitorsController";
import GC from "$lib/global-constants";
const API_PATH_PREFIX = "/api/";
@@ -58,7 +60,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
@@ -87,6 +120,18 @@ export const handle: Handle = async ({ event, resolve }) => {
return json(errorResponse, { status: 401 });
}
// API consumers must always get JSON; without this, an /api/ path with no
// matching route falls through to SvelteKit's HTML error page
if (event.route.id === null) {
const errorResponse: NotFoundResponse = {
error: {
code: "NOT_FOUND",
message: `No API route matches '${pathname}'`,
},
};
return json(errorResponse, { status: 404 });
}
// Validate monitor tag exists for /api/(vX/)?monitors/:monitor_tag/* routes
const monitorTag = extractMonitorTag(pathname);
if (monitorTag) {
@@ -141,7 +186,10 @@ export const handle: Handle = async ({ event, resolve }) => {
// Validate page_path exists for /api/(vX/)?pages/:page_path/* routes
const pagePath = extractPagePath(pathname);
if (pagePath) {
const page = await db.getPageByPath(pagePath);
// The home page has an empty page_path, unreachable as a URL segment;
// the ~home token addresses it instead
const lookupPath = pagePath === GC.HOME_PAGE_TOKEN ? "" : pagePath;
const page = await db.getPageByPath(lookupPath);
if (!page) {
const errorResponse: NotFoundResponse = {
error: {
@@ -160,3 +208,5 @@ export const handle: Handle = async ({ event, resolve }) => {
response.headers.delete("Link");
return response;
};
export const handle = sequence(csrfHandle, apiAuthHandle);
+274
View File
@@ -0,0 +1,274 @@
/**
* Permissions derived from src/routes/(manage)/manage/api/+server.ts actions.
* Grouped by domain with read/write granularity.
*
* Mapping from actions → permissions:
*
* monitors.read → getMonitors, getMonitoringDataPaginated
* monitors.write → storeMonitorData, updateMonitoringData, deleteMonitor, deleteMonitorData, cloneMonitor, testMonitor
*
* incidents.read → getIncidents, getIncident, getComments
* incidents.write → createIncident, updateIncident, deleteIncident, addMonitor, removeMonitor, addComment, deleteComment, updateComment
*
* maintenances.read → getMaintenances, getMaintenance, getMaintenanceEvents, getMaintenanceEvent, getMaintenanceMonitors
* maintenances.write → createMaintenance, updateMaintenance, deleteMaintenance, createMaintenanceEvent, updateMaintenanceEvent, deleteMaintenanceEvent, addMonitorToMaintenance, removeMonitorFromMaintenance, updateMaintenanceMonitorImpact
*
* pages.read → getPages
* pages.write → createPage, updatePage, deletePage, addMonitorToPage, removeMonitorFromPage, reorderPageMonitors
*
* triggers.read → getTriggers
* triggers.write → createUpdateTrigger, updateMonitorTriggers, deleteTrigger, testTrigger
*
* alerts.read → getMonitorAlertConfig, getMonitorAlertConfigById, getMonitorAlertConfigsByMonitorTag, getAlertConfigsPaginated, getAllAlertsPaginated
* alerts.write → createMonitorAlertConfig, updateMonitorAlertConfig, deleteMonitorAlertConfig, toggleMonitorAlertConfigStatus, deleteMonitorAlertV2, updateMonitorAlertV2Status
*
* api_keys.read → getAPIKeys
* api_keys.write → createNewApiKey, updateApiKeyStatus
* api_keys.delete → deleteApiKey (admin-only today)
*
* users.read → getUsers
* users.write → manualUpdate, createNewUser, resendInvitation, sendVerificationEmail
*
* settings.read → getAllSiteData, getSiteDataByKey, getSubscriptionsConfig
* settings.write → storeSiteData, updateSubscriptionsConfig
*
* subscribers.read → getSubscribersByMethod, getSubscriberWithSubscriptions, getSubscriberCountsByMethod, getAdminSubscribers
* subscribers.write → deleteUserSubscription, updateUserSubscriptionStatus, adminUpdateSubscriptionStatus, adminDeleteSubscriber, adminAddSubscriber
*
* email_templates.read → getGeneralEmailTemplates, getGeneralEmailTemplateById
* email_templates.write → updateGeneralEmailTemplate
*
* images.write → uploadImage, deleteImage
*/
export const permissions: Array<{ id: string; permission_name: string }> = [
// Monitors
{ id: "monitors.read", permission_name: "View monitors and monitoring data" },
{ id: "monitors.write", permission_name: "Create, update, delete, and clone monitors" },
// Incidents
{ id: "incidents.read", permission_name: "View incidents and comments" },
{ id: "incidents.write", permission_name: "Create, update, and delete incidents and comments" },
// Maintenances
{ id: "maintenances.read", permission_name: "View maintenances and events" },
{ id: "maintenances.write", permission_name: "Create, update, and delete maintenances and events" },
// Pages
{ id: "pages.read", permission_name: "View pages" },
{ id: "pages.write", permission_name: "Create, update, and delete pages" },
// Triggers
{ id: "triggers.read", permission_name: "View triggers" },
{ id: "triggers.write", permission_name: "Create, update, delete, and test triggers" },
// Alerts
{ id: "alerts.read", permission_name: "View alert configurations and alert history" },
{ id: "alerts.write", permission_name: "Create, update, and delete alert configurations" },
// API Keys
{ id: "api_keys.read", permission_name: "View API keys" },
{ id: "api_keys.write", permission_name: "Create and update API keys" },
{ id: "api_keys.delete", permission_name: "Delete API keys" },
// Users
{ id: "users.read", permission_name: "View users" },
{ id: "users.write", permission_name: "Manage users, invitations, and verification" },
// Settings (site data + subscriptions config)
{ id: "settings.read", permission_name: "View site settings and subscriptions config" },
{ id: "settings.write", permission_name: "Update site settings and subscriptions config" },
// Subscribers
{ id: "subscribers.read", permission_name: "View subscribers" },
{ id: "subscribers.write", permission_name: "Manage subscribers and subscriptions" },
// Email Templates
{ id: "email_templates.read", permission_name: "View email templates" },
{ id: "email_templates.write", permission_name: "Update email templates" },
// Images
{ id: "images.write", permission_name: "Upload and delete images" },
// Roles
{ id: "roles.read", permission_name: "View roles, permissions, and user assignments" },
{ id: "roles.write", permission_name: "Create, update, and delete roles" },
{ id: "roles.assign_permissions", permission_name: "Add and remove permissions from roles" },
{ id: "roles.assign_users", permission_name: "Add and remove users to and from roles" },
];
export const ACTION_PERMISSION_MAP: Record<string, string | null> = {
// Self-actions — no permission needed beyond being logged in
updateUser: null,
updatePassword: null,
sendVerificationEmail: null, // controller has its own self-vs-other check
// Settings
getAllSiteData: "settings.read",
getSiteDataByKey: "settings.read",
getSubscriptionsConfig: "settings.read",
storeSiteData: "settings.write",
updateSubscriptionsConfig: "settings.write",
// Users
getUsers: "users.read",
manualUpdate: "users.write",
createNewUser: "users.write",
resendInvitation: "users.write",
// Monitors
getMonitors: "monitors.read",
getMonitoringDataPaginated: "monitors.read",
storeMonitorData: "monitors.write",
updateMonitoringData: "monitors.write",
deleteMonitor: "monitors.write",
deleteMonitorData: "monitors.write",
cloneMonitor: "monitors.write",
testMonitor: "monitors.write",
// Incidents
getIncidents: "incidents.read",
getIncident: "incidents.read",
getComments: "incidents.read",
createIncident: "incidents.write",
updateIncident: "incidents.write",
deleteIncident: "incidents.write",
addMonitor: "incidents.write",
removeMonitor: "incidents.write",
addComment: "incidents.write",
deleteComment: "incidents.write",
updateComment: "incidents.write",
// Maintenances
getMaintenances: "maintenances.read",
getMaintenance: "maintenances.read",
getMaintenanceEvents: "maintenances.read",
getMaintenanceEvent: "maintenances.read",
getMaintenanceMonitors: "maintenances.read",
createMaintenance: "maintenances.write",
updateMaintenance: "maintenances.write",
deleteMaintenance: "maintenances.write",
createMaintenanceEvent: "maintenances.write",
updateMaintenanceEvent: "maintenances.write",
deleteMaintenanceEvent: "maintenances.write",
addMonitorToMaintenance: "maintenances.write",
removeMonitorFromMaintenance: "maintenances.write",
updateMaintenanceMonitorImpact: "maintenances.write",
// Pages
getPages: "pages.read",
createPage: "pages.write",
updatePage: "pages.write",
deletePage: "pages.write",
addMonitorToPage: "pages.write",
removeMonitorFromPage: "pages.write",
reorderPageMonitors: "pages.write",
// Triggers
getTriggers: "triggers.read",
createUpdateTrigger: "triggers.write",
updateMonitorTriggers: "triggers.write",
deleteTrigger: "triggers.write",
testTrigger: "triggers.write",
// Alerts
getAllAlertsPaginated: "alerts.read",
getMonitorAlertConfig: "alerts.read",
getMonitorAlertConfigById: "alerts.read",
getMonitorAlertConfigsByMonitorTag: "alerts.read",
getAlertConfigsPaginated: "alerts.read",
createMonitorAlertConfig: "alerts.write",
updateMonitorAlertConfig: "alerts.write",
deleteMonitorAlertConfig: "alerts.write",
toggleMonitorAlertConfigStatus: "alerts.write",
deleteMonitorAlertV2: "alerts.write",
updateMonitorAlertV2Status: "alerts.write",
// API Keys
getAPIKeys: "api_keys.read",
createNewApiKey: "api_keys.write",
updateApiKeyStatus: "api_keys.write",
deleteApiKey: "api_keys.delete",
// Subscribers
getSubscribersByMethod: "subscribers.read",
getSubscriberWithSubscriptions: "subscribers.read",
getSubscriberCountsByMethod: "subscribers.read",
getAdminSubscribers: "subscribers.read",
deleteUserSubscription: "subscribers.write",
updateUserSubscriptionStatus: "subscribers.write",
adminUpdateSubscriptionStatus: "subscribers.write",
adminDeleteSubscriber: "subscribers.write",
adminAddSubscriber: "subscribers.write",
// Email Templates
getGeneralEmailTemplates: "email_templates.read",
getGeneralEmailTemplateById: "email_templates.read",
updateGeneralEmailTemplate: "email_templates.write",
// Images
uploadImage: "images.write",
deleteImage: "images.write",
// Roles
getRoles: "roles.read",
getAllPermissions: "roles.read",
getRolePermissions: "roles.read",
getRoleUsers: "roles.read",
createRole: "roles.write",
updateRole: "roles.write",
deleteRole: "roles.write",
updateRolePermissions: "roles.assign_permissions",
addUserToRole: "roles.assign_users",
removeUserFromRole: "roles.assign_users",
};
export const ROUTE_PERMISSION_MAP: Record<string, string | null> = {
// Monitors
"/(manage)/manage/app/monitors": "monitors.read",
"/(manage)/manage/app/monitors/[tag]": "monitors.read",
"/(manage)/manage/app/monitoring-data": "monitors.read",
// Incidents
"/(manage)/manage/app/incidents": "incidents.read",
"/(manage)/manage/app/incidents/[incident_id]": "incidents.read",
// Maintenances
"/(manage)/manage/app/maintenances": "maintenances.read",
"/(manage)/manage/app/maintenances/[id]": "maintenances.read",
// Pages
"/(manage)/manage/app/pages": "pages.read",
"/(manage)/manage/app/pages/[page_id]": "pages.read",
// Triggers
"/(manage)/manage/app/triggers": "triggers.read",
"/(manage)/manage/app/triggers/[trigger_id]": "triggers.read",
// Alerts
"/(manage)/manage/app/alerts": "alerts.read",
"/(manage)/manage/app/alerts/[alert_config_id]": "alerts.read",
"/(manage)/manage/app/alerts/logs/[alert_config_id]": "alerts.read",
// API Keys
"/(manage)/manage/app/api-keys": "api_keys.read",
// Users
"/(manage)/manage/app/users": "users.read",
// Settings
"/(manage)/manage/app/site-configurations": "settings.read",
"/(manage)/manage/app/customizations": "settings.read",
"/(manage)/manage/app/internationalization": "settings.read",
"/(manage)/manage/app/analytics-providers": "settings.read",
"/(manage)/manage/app/badges": "settings.read",
"/(manage)/manage/app/embed": "settings.read",
// Subscribers
"/(manage)/manage/app/subscriptions": "subscribers.read",
// Email Templates
"/(manage)/manage/app/templates": "email_templates.read",
// Roles
"/(manage)/manage/app/roles": "roles.read",
};
+36
View File
@@ -31,3 +31,39 @@ export default function urlResolve(resolve: ResolveFn, path: string, params?: Re
}
return resolve(path);
}
/**
* Resolves a path to an absolute URL by prefixing the site URL.
* Required for meta tags like og:image and twitter:image that need absolute URLs.
* @param resolve - The resolve function from $app/paths
* @param siteUrl - The site URL (e.g., "https://status.example.com")
* @param path - The route path or absolute URL
* @param params - Optional parameters for dynamic route segments
* @returns An absolute URL, or the resolved relative URL if siteUrl is empty
*
* @example
* ```ts
* absoluteResolve(resolve, "https://status.example.com", "/uploads/preview.png")
* // => "https://status.example.com/uploads/preview.png"
* ```
*/
export function absoluteResolve(
resolve: ResolveFn,
siteUrl: string,
path: string,
params?: Record<string, string>
): string {
// Normalize relative paths like "./assets/..." to "/assets/..." so the
// final URL doesn't contain "/./" segments (crawlers don't normalize these)
const normalizedPath = path.startsWith("./") ? path.slice(1) : path;
const resolved = urlResolve(resolve, normalizedPath, params);
// Already absolute, return as-is
if (resolved.startsWith("http://") || resolved.startsWith("https://")) {
return resolved;
}
if (!siteUrl) {
return resolved;
}
const trimmedSiteUrl = siteUrl.replace(/\/+$/, "");
return trimmedSiteUrl + (resolved.startsWith("/") ? resolved : "/" + resolved);
}
@@ -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-1 gap-0 lg:grid-cols-3">
<!-- Ongoing Maintenances -->
<div class="flex flex-col lg:border-r">
<div class="text-muted-foreground bg-secondary flex items-center justify-between gap-2 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 lg:border-r">
<div class="text-muted-foreground bg-secondary flex items-center justify-between gap-2 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">
<div class="text-muted-foreground bg-secondary flex items-center justify-between gap-2 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}
@@ -41,7 +41,7 @@
</Drawer.Trigger>
<Drawer.Content class="max-h-[80vh]">
<Drawer.Header>
<Drawer.Title>{$t("Included Monitors")}</Drawer.Title>
<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}
+8 -8
View File
@@ -43,7 +43,7 @@
<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()}">{incident.state}</span>
<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}
@@ -105,7 +105,7 @@
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, "PPp")}
{$formatDate(incident.start_date_time, page.data.dateAndTimeFormat.datePlusTime)}
</span>
<span class="relative w-full text-center sm:flex-1">
<span
@@ -117,7 +117,7 @@
</span>
{#if incident.end_date_time}
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
{$formatDate(incident.end_date_time, "PPp")}
{$formatDate(incident.end_date_time, page.data.dateAndTimeFormat.datePlusTime)}
</span>
{:else}
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
@@ -128,14 +128,14 @@
{#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>Last Updated</span>
<span>{$formatDate(incident.updated_at, "PPp")}</span>
<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>Status</span>
<span>{$t("Status")}</span>
<div class="flex items-center gap-2">
<span class="text-{incident.state.toLowerCase()}">
{incident.state}
{$t(incident.state)}
</span>
</div>
</div>
@@ -167,7 +167,7 @@
{$t(comment.state)}
</Badge>
<span class="text-muted-foreground text-xs">
{$formatDate(comment.commented_at, "PPp")}
{$formatDate(comment.commented_at, page.data.dateAndTimeFormat.datePlusTime)}
</span>
</div>
<div
+10 -2
View File
@@ -11,7 +11,15 @@
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 });
@@ -29,7 +37,7 @@
>
<!-- Brand -->
<a
href={clientResolver(resolve, siteUrl)}
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}
+4 -1
View File
@@ -6,6 +6,7 @@
import { t } from "$lib/stores/i18n";
import { ParseLatency } from "$lib/clientTools";
import { formatDate } from "$lib/stores/datetime";
import { page } from "$app/state";
interface ChartPoint {
date: Date;
@@ -82,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 ? $formatDate(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">
+3 -3
View File
@@ -33,7 +33,7 @@
<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()}">{maintenance.status}</span>
<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
@@ -97,7 +97,7 @@
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, "PPp")}
{$formatDate(maintenance.start_date_time, page.data.dateAndTimeFormat.datePlusTime)}
</span>
<span class="relative w-full text-center sm:flex-1">
<span
@@ -108,7 +108,7 @@
</span>
</span>
<span class="max-w-full rounded-full border px-3 py-2 wrap-break-word">
{$formatDate(maintenance.end_date_time, "PPp")}
{$formatDate(maintenance.end_date_time, page.data.dateAndTimeFormat.datePlusTime)}
</span>
</Item.Description>
</Item.Content>
+7 -2
View File
@@ -5,6 +5,8 @@
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 {
@@ -74,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)) {
@@ -319,7 +321,10 @@
style={tooltipStyle}
>
<span class="text-{hoveredMinute.data.status.toLowerCase()}">
{$t(hoveredMinute.data.status)} @ {$formatDate(hoveredMinute.data.timestamp, "HH:mm")}
{$t(hoveredMinute.data.status)} @ {$formatDate(
hoveredMinute.data.timestamp,
page.data.dateAndTimeFormat.timeOnly
)}
</span>
</div>
{/if}
+4 -5
View File
@@ -1,6 +1,5 @@
<script lang="ts">
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";
@@ -12,6 +11,7 @@
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;
@@ -136,11 +136,11 @@
<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), "MMM d, yyyy")}
{$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), "MMM d, yyyy")}
{$formatDate(new Date(data.toTimeStamp * 1000), page.data.dateAndTimeFormat.dateOnly)}
</p>
</div>
</div>
@@ -152,8 +152,7 @@
days={days as number}
endOfDayTodayAtTz={endOfDayTodayAtTz as number}
>
{groupChildTags.length}
{$t("Included Monitors")}
{$t("Included Monitors (%count)", { count: String(groupChildTags.length) })}
</GroupMonitorPopover>
</div>
{/if}
+8 -11
View File
@@ -3,24 +3,20 @@
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
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 * 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 { IncidentForMonitorListWithComments, MaintenanceEventsMonitorList } from "$lib/server/types/db";
@@ -213,9 +209,8 @@
<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 text-base sm:text-lg">
<Activity class="h-4 w-4 shrink-0 sm:h-5 sm:w-5" />
<span class="truncate">
{selectedDay ? $formatDate(new Date(selectedDay.timestamp * 1000), "EEEE, MMMM do, yyyy") : ""}
{selectedDay ? $formatDate(new Date(selectedDay.timestamp * 1000), page.data.dateAndTimeFormat.dateOnly) : ""}
</span>
</Dialog.Title>
<Dialog.Description class="text-xs sm:text-sm"
@@ -316,7 +311,7 @@
line: { class: "stroke-1" }
},
xAxis: {
format: (d: Date) => $formatDate(d, "HH:mm")
format: (d: Date) => $formatDate(d, page.data.dateAndTimeFormat.timeOnly)
}
}}
>
@@ -342,11 +337,13 @@
></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 ? $formatDate(item.payload.date, "HH:mm") : ""}
{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>
+4 -4
View File
@@ -2,7 +2,6 @@
import { onMount, untrack } from "svelte";
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";
@@ -18,6 +17,7 @@
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;
@@ -205,12 +205,12 @@
<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")}
{$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, "d MMM yyyy")}
{$formatDate(displayData[displayData.length - 1].ts, page.data.dateAndTimeFormat.dateOnly)}
{/if}
</p>
</div>
@@ -219,7 +219,7 @@
{#if groupTags.length > 0}
<div class="flex justify-center">
<GroupMonitorPopover tags={groupTags} days={selectedDays} {endOfDayTodayAtTz}>
{$t("Included Monitors")} ({groupTags.length})
{$t("Included Monitors (%count)", { count: String(groupTags.length) })}
<ArrowUp class="size-3" />
</GroupMonitorPopover>
</div>
+98
View File
@@ -0,0 +1,98 @@
<script lang="ts">
import * as Command from "$lib/components/ui/command/index.js";
import * as Popover from "$lib/components/ui/popover/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import type { MonitorRecord } from "$lib/server/types/db.js";
import CheckIcon from "@lucide/svelte/icons/check";
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
import ListPlusIcon from "@lucide/svelte/icons/list-plus";
import clientResolver from "$lib/client/resolver.js";
import { resolve } from "$app/paths";
let {
monitors = [],
selectedTags = [],
onToggle,
onAddMany,
placeholder = "Search monitors to add..."
}: {
monitors: MonitorRecord[];
selectedTags: string[];
onToggle: (tag: string) => void;
onAddMany?: (tags: string[]) => void;
placeholder?: string;
} = $props();
let open = $state(false);
let search = $state("");
// Own filtering (shouldFilter={false}) so "Add all matching" counts stay
// consistent with what the list shows. Case-insensitive over name + tag.
const filteredMonitors = $derived.by(() => {
const query = search.trim().toLowerCase();
if (!query) return monitors;
return monitors.filter((m) => m.name.toLowerCase().includes(query) || m.tag.toLowerCase().includes(query));
});
const unselectedMatches = $derived(filteredMonitors.filter((m) => !selectedTags.includes(m.tag)));
const showAddAll = $derived(!!search.trim() && unselectedMatches.length > 0 && !!onAddMany);
function addAllMatching() {
onAddMany?.(unselectedMatches.map((m) => m.tag));
}
</script>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
role="combobox"
aria-expanded={open}
class="w-full justify-between font-normal"
>
<span class="text-muted-foreground">{placeholder}</span>
<ChevronsUpDownIcon class="text-muted-foreground size-4 shrink-0" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[var(--bits-popover-trigger-width)] p-0" align="start">
<Command.Root shouldFilter={false}>
<Command.Input {placeholder} bind:value={search} />
<Command.List class="max-h-64">
<Command.Empty>No monitors found.</Command.Empty>
<Command.Group>
{#each filteredMonitors as monitor (monitor.tag)}
{@const selected = selectedTags.includes(monitor.tag)}
<Command.Item value={monitor.tag} onSelect={() => onToggle(monitor.tag)}>
<CheckIcon class="size-4 {selected ? 'opacity-100' : 'opacity-0'}" />
{#if monitor.image}
<img
src={clientResolver(resolve, monitor.image)}
alt={monitor.name}
class="size-5 rounded object-cover"
/>
{:else}
<div class="bg-muted flex size-5 items-center justify-center rounded text-[10px] font-medium">
{monitor.name.charAt(0).toUpperCase()}
</div>
{/if}
<span class="truncate">{monitor.name}</span>
<span class="text-muted-foreground ml-auto truncate text-xs">{monitor.tag}</span>
</Command.Item>
{/each}
</Command.Group>
{#if showAddAll}
<Command.Separator />
<Command.Group>
<Command.Item value="__add-all-matching__" onSelect={addAllMatching}>
<ListPlusIcon class="size-4" />
Add all {unselectedMatches.length} matching
</Command.Item>
</Command.Group>
{/if}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
+19 -6
View File
@@ -1,14 +1,16 @@
<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 } from "$lib/stores/datetime";
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 {
@@ -22,6 +24,17 @@
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 {
@@ -76,7 +89,7 @@
>
<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, eventsPath)} size="icon-sm" class="rounded-btn">
<Button variant="outline" href={clientResolver(resolve, resolvedEventsPath)} size="icon-sm" class="rounded-btn">
<Calendar class="size-4" />
</Button>
</div>
@@ -93,17 +106,17 @@
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">{item.eventType}</span>
<span class="text-{item.eventStatus.toLowerCase()}">{item.eventStatus}</span>
<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, "PPp")}</span>
<span>{$formatDate(item.eventDate, page.data.dateAndTimeFormat.datePlusTime)}</span>
<span></span>
<span>{item.eventDuration}</span>
<span>{$formatDuration(item.eventStartDateTime, item.eventEndDateTime, $t("Ongoing"))}</span>
</div>
</a>
{/each}
+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>
+2 -2
View File
@@ -13,8 +13,8 @@
let pages = $state<PageNavItem[]>([]);
let pagesLoading = $state(false);
const currentPage = $derived(pages.find((p) => p.page_path === currentPath) || pages[0]);
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;
+1 -1
View File
@@ -375,7 +375,7 @@
>
<span class={getStatusColor(hoveredBar.data)}>{$t(GetStatusSummary(hoveredBar.data))}</span>
<span class="text-muted-foreground">@</span>
{$formatDate(hoveredBar.data.ts, "d MMM yyyy")}
{$formatDate(hoveredBar.data.ts, page.data.dateAndTimeFormat.dateOnly)}
{#if hoveredBar.data.avgLatency > 0}
<span class="text-muted-foreground ml-1">|</span>
<span class="ml-1">{ParseLatency(hoveredBar.data.avgLatency)}</span>
+9 -6
View File
@@ -10,7 +10,6 @@
import Sun from "@lucide/svelte/icons/sun";
import Moon from "@lucide/svelte/icons/moon";
import Share from "@lucide/svelte/icons/share-2";
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
import { format } from "date-fns";
import SubscribeMenu from "$lib/components/SubscribeMenu.svelte";
import CopyButton from "$lib/components/CopyButton.svelte";
@@ -41,22 +40,22 @@
if (page.route.id === "/(kener)/monitors/[monitor_tag]") {
return {
label: "Edit Monitor",
label: $t("Edit Monitor"),
url: clientResolver(resolve, "/manage/app/monitors/" + page.params.monitor_tag)
};
} else if (page.route.id === "/(kener)/incidents/[incident_id]") {
return {
label: "Update Incident",
label: $t("Update Incident"),
url: clientResolver(resolve, "/manage/app/incidents/" + page.params.incident_id)
};
} else if (page.route.id === "/(kener)/maintenances/[maintenance_id]") {
return {
label: "Update Maintenance",
label: $t("Update Maintenance"),
url: clientResolver(resolve, "/manage/app/maintenances/" + page.data.maintenance.id)
};
} else {
return {
label: "Manage Site",
label: $t("Manage Site"),
url: clientResolver(resolve, "/manage/app/site-configurations")
};
}
@@ -79,7 +78,10 @@
</script>
<div class="theme-plus-bar scrollbar-hidden sticky top-18 z-20 flex w-full items-center gap-2 rounded py-2">
<PageSelector />
<!-- Show the switcher if not forced exclusivity and switcher is enabled -->
{#if !!!page.data.globalPageVisibilitySettings.forceExclusivity && page.data.globalPageVisibilitySettings.showSwitcher}
<PageSelector />
{/if}
<div class="ml-auto flex shrink-0 items-center gap-2">
{#if page.data.isSubsEnabled && page.data.canSendEmail}
<ButtonGroup.Root class="hidden shrink-0 sm:flex">
@@ -168,6 +170,7 @@
{/if}
</div>
</div>
{#if !!page.data.announcement && !!page.data.announcement.title && !!page.data.announcement.message}
<SiteBanner announcement={page.data.announcement} />
{/if}
+14
View File
@@ -41,6 +41,8 @@ export default {
MANUAL: "MANUAL",
WEBHOOK: "WEBHOOK",
DEFAULT_STATUS: "DEFAULT",
CARRIED: "CARRIED",
LAST_KNOWN: "LAST_KNOWN",
SIGNAL: "SIGNAL",
INVITE_VERIFY_EMAIL: "invite_verify_email",
ERROR_NO_SETUP: "Set up not done yet. Create a user first.",
@@ -69,6 +71,18 @@ export default {
STATUS: "STATUS",
LATENCY: "LATENCY",
UPTIME: "UPTIME",
// Special path segment addressing the home page in the v4 API; its stored
// page_path is an empty string. See docs/adr/0004-home-page-api-token.md.
HOME_PAGE_TOKEN: "~home",
// Status history window (days of per-day status shown), shared by pages and
// monitors, the manage UI, the public pages, and the v4 API
DEFAULT_STATUS_HISTORY_DAYS_DESKTOP: 90,
DEFAULT_STATUS_HISTORY_DAYS_MOBILE: 30,
STATUS_HISTORY_DAYS_MIN: 1,
STATUS_HISTORY_DAYS_MAX: 365,
// Monitor layout styles available on status pages
MONITOR_LAYOUT_STYLES: ["default-list", "default-grid", "compact-list", "compact-grid"],
DEFAULT_MONITOR_LAYOUT_STYLE: "default-list",
DOCS_URL: "https://kener.ing/docs",
MAX_UPLOAD_BYTES: 2 * 1024 * 1024, // 2MB
MAX_IMAGE_DIMENSION: 4096,
+79 -37
View File
@@ -2,114 +2,156 @@
"name": "Čeština",
"mappings": {
"%latency %metric latency": "%latency %metric latence",
"24 Hours": "24 hodin",
"30 Days": "30 dní",
"7 Days": "7 dní",
"90 Days": "90 dní",
"Affected Monitors (%count)": "Zasažené monitory (%count)",
"All Systems Operational": "Všechny systémy jsou v provozu",
"average": "průměrná",
"Average Latency": "Průměrná latence",
"Avg Latency": "Prům. latence",
"Back": "Zpět",
"Badges": "Odznaky",
"CANCELLED": "ZRUŠENO",
"COMPLETED": "DOKONČENO",
"Continue": "Pokračovat",
"Copied": "Zkopírováno",
"Current": "Aktuální",
"Dark": "Tmavý",
"Day": "Den",
"Day Uptime": "Denní dostupnost",
"Days": "Dny",
"Degraded Performance": "Zhoršený výkon",
"Didn't receive the code? Resend": "Nepřišel vám kód? Odeslat znovu",
"Days": "Dní",
"Degraded": "Omezený",
"DEGRADED": "OMEZENÝ",
"Degraded Performance": "Snížený výkon",
"Didn't receive the code? Resend": "Nepřišel kód? Odeslat znovu",
"Down": "Nedostupné",
"DOWN": "NEDOSTUPNÉ",
"Duration": "Trvání",
"Edit Monitor": "Upravit monitor",
"Email address": "E-mailová adresa",
"Embed Monitor": "Vložit monitor",
"Embed this monitor in your website or app": "Vložte tento monitor na svůj web nebo do aplikace",
"Embed this monitor in your website or app": "Vložte tento monitor na web nebo do aplikace",
"End Time": "Konec",
"Enter the verification code sent to your email.": "Zadejte ověřovací kód zaslaný na váš e-mail.",
"Enter the verification code sent to your email.": "Zadejte ověřovací kód zaslaný na e-mail",
"Events": "Události",
"Failed to load data": "Nepodařilo se načíst data",
"Failed to load latency data": "Nepodařilo se načíst data latence",
"Failed to load status data for this day": "Nepodařilo se načíst data stavu pro tento den",
"Failed to send verification code": "Nepodařilo se odeslat ověřovací kód",
"Failed to update preference": "Nepodařilo se aktualizovat nastavení",
"Failed to update preference": "Nepodařilo se uložit nastavení",
"Format": "Formát",
"Get badges for this monitor": "Získat odznaky pro tento monitor",
"Get notified about incidents and scheduled maintenance.": "Dostávejte upozornění na incidenty a plánované údržby.",
"Get notified about incidents and scheduled maintenance.": "Dostávejte upozornění na incidenty a plánovanou údržbu",
"Get notified about incidents updates": "Dostávejte upozornění na aktualizace incidentů",
"Get notified about scheduled maintenance": "Dostávejte upozornění na plánovanou údržbu",
"Home": "Domů",
"IDENTIFIED": "IDENTIFIKOVÁNO",
"iFrame": "iFrame",
"Impact": "Dopad",
"incident": "incident",
"Incident": "Incident",
"Incident Updates": "Aktualizace incidentů",
"Incidents": "Incidenty",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Zahrnuté monitory (%count)",
"INVESTIGATING": "VYŠETŘOVÁNÍ",
"Last Updated": "Naposledy aktualizováno",
"Latency": "Latence",
"Latency Embed": "Vložená latence",
"Latency Over Time": "Latence v čase",
"Latency Trend": "Trend latence",
"Latest Latency": "Poslední latence",
"Latest Status": "Posled stav",
"Latest Status": "Naposledy zjištěný stav",
"Light": "Světlý",
"Live Status": "Živý stav",
"Loading your preferences...": "Načítám vaše nastavení...",
"Live Status": "Aktuální stav",
"Loading your preferences...": "Načítá nastavení...",
"maintenance": "údržba",
"Maintenance": "Údržba",
"MAINTENANCE": "ÚDRŽBA",
"Maintenance Updates": "Aktualizace údržby",
"Maintenances": "Údržby",
"Major System Outage": "Závažný výpadek systému",
"Manage your notification preferences.": "Spravujte svá nastavení oznámení.",
"Manage Site": "Spravovat stránku",
"Manage your notification preferences.": "Spravujte nastavení oznámení",
"Max Latency": "Max. latence",
"Maximum Latency": "Max. latence",
"maximum": "maximální",
"Maximum Latency": "Maximální latence",
"Min Latency": "Min. latence",
"Minimum Latency": "Min. latence",
"minimum": "minimální",
"Minimum Latency": "Minimální latence",
"Minute-by-minute status data for this day": "Minutová data stavu pro tento den",
"Network error. Please try again.": "Chyba sítě. Zkuste to prosím znovu.",
"No Events in %currentMonth": "V měsíci %currentMonth nejsou žádné události",
"No events to show": "No events to show",
"No incidents for this day": "Pro tento den nejsou žádné incidenty",
"No latency data available for this day": "Pro tento den nejsou k dispozici data latence",
"No maintenances for this day": "Pro tento den nejsou naplánované žádné údržby",
"MONITORING": "MONITOROVÁNÍ",
"Network error. Please try again.": "Chyba sítě. Zkuste to znovu",
"No Events in %currentMonth": "V měsíci %currentMonth nejsou plánované žádné události",
"No events to show": "Žádné události k zobrazení",
"No incidents for this day": "Pro tento den nejsou evidovány žádné incidenty",
"No latency data available for this day": "Pro tento den nejsou dostupná data latence",
"No maintenances for this day": "Pro tento den není naplánovaná žádná údržba",
"No monitors affected": "Žádné zasažené monitory",
"No monitors available.": "No monitors available.",
"No ongoing maintenances": "Žádné probíhající údržby",
"No past maintenances": "Žádné minulé údržby",
"No monitors available.": "Žádné dostupné monitory",
"No ongoing maintenances": "Žádná probíhající údržba",
"No past maintenances": "Žádná minulá údržba",
"No Status Available": "Stav není k dispozici",
"No upcoming maintenances": "Žádné nadcházející údržby",
"No Updates": "No Updates",
"No updates yet": "Zatím bez aktualizací",
"Notifications": "Notifications",
"No upcoming maintenances": "Žádná nadcházející údržba",
"No Updates": "Žádné aktualizace",
"No updates yet": "Zatím žádné aktualizace",
"NO_DATA": "Žádná data",
"Notifications": "Oznámení",
"One-time": "Jednorázově",
"Ongoing": "Probíhající",
"Partial Degraded Performance": "Částečně zhoršený výkon",
"ONGOING": "PROBÍHAJÍCÍ",
"Ongoing Maintenances": "Probíhající údržby",
"Operational": "V provozu",
"Partial Degraded Performance": "Částečně omezený provoz",
"Partial System Outage": "Částečný výpadek systému",
"Past": "Minulé",
"Per-Minute Status": "Stav po minutách",
"Pinging": "Pingování",
"Please enter a valid email address": "Zadejte platnou e-mailovou adresu",
"Please enter the 6-digit verification code": "Zadejte prosím 6místný ověřovací kód",
"Please enter the 6-digit verification code": "Zadejte 6místný ověřovací kód",
"Read less": "Zobrazit méně",
"Read more": "Zobrazit více",
"Recurring": "Recurring",
"READY": "PŘIPRAVENO",
"Recent Incidents": "Nedávné incidenty",
"Recurring": "Opakující se",
"RESOLVED": "VYŘEŠENO",
"SCHEDULED": "NAPLÁNOVÁNO",
"Scheduled Events (%count)": "Plánované události (%count)",
"Scheduled Windows": "Naplánované úlohy",
"Script": "Skript",
"Select Language": "Vyberte jazyk",
"Select latency metric to display": "Select latency metric to display",
"Select latency metric to display": "Vyberte metriku latence",
"Select Range": "Vyberte rozsah",
"Sending...": "Odesílání...",
"Standard": "Standardní",
"Start Time": "Začátek",
"Status": "Stav",
"Status Badge": "Odznak stavu",
"Status Embed": "Vložený stav",
"Status Embed": "Stav pro vložení",
"Status history and latency trend": "Historie stavu a trend latence",
"Subscribe": "Odebírat",
"Subscribe": "Přihlásit se k odběru",
"Subscribe to Updates": "Odebírat aktualizace",
"There are no incidents or maintenances scheduled for this month.": "Na tento měsíc nejsou naplánované žádné incidenty ani údržby.",
"There are no ongoing incidents or maintenance events.": "There are no ongoing incidents or maintenance events.",
"Theme": "Motiv",
"There are no incidents or maintenances scheduled for this month.": "Na tento měsíc nejsou naplánované žádné incidenty ani údržby",
"There are no ongoing incidents or maintenance events.": "V tuto chvíli neprobíhají žádné incidenty ani údržba",
"Timeline": "Časová osa",
"Total Incidents": "Celkem incidentů",
"Total Maintenances": "Celkem údržeb",
"Under Maintenance": "Probíhá údržba",
"Unknown impact": "Neznámý dopad",
"UP": "V PROVOZU",
"Upcoming": "Nadcházející",
"Updates": "Updates",
"Upcoming Maintenances": "Nadcházející údržby",
"Update Incident": "Aktualizovat incident",
"Update Maintenance": "Aktualizovat údržbu",
"Updates": "Aktualizace",
"Updates (%count)": "Aktualizace (%count)",
"Uptime": "Dostupnost",
"Uptime Badge": "Odznak dostupnosti",
"Verification failed": "Ověření se nezdařilo",
"Verify": "Ověřit",
"Verifying": "Ověřování",
"We sent a 6-digit code to": "Poslali jsme 6místný kód na"
"We sent a 6-digit code to": "Odeslali jsme 6místný kód na"
}
}
+52 -30
View File
@@ -5,38 +5,48 @@
"Affected Monitors (%count)": "Betroffene Monitore (%count)",
"All Systems Operational": "Alle Systeme betriebsbereit",
"Average Latency": "Durchschnittliche Latenz",
"Avg Latency": "Durchschnittliche Latenz",
"Avg Latency": "Durchschn. Latenz",
"Back": "Zurück",
"Badges": "Abzeichen",
"Continue": "Weitermachen",
"Badges": "Anzeigen",
"CANCELLED": "ABGESAGT",
"COMPLETED": "ABGESCHLOSSEN",
"Continue": "Fortsetzen",
"Copied": "Kopiert",
"Current": "Aktuell",
"Dark": "Dunkel",
"Day": "Tag",
"Day Uptime": "Tagesverfügbarkeit",
"Days": "Tage",
"Degraded": "Beeinträchtigt",
"DEGRADED": "BEEINTRÄCHTIGT",
"Degraded Performance": "Beeinträchtigte Leistung",
"Didn't receive the code? Resend": "Sie haben den Code nicht erhalten? ",
"Didn't receive the code? Resend": "Sie haben den Code nicht erhalten? Erneut senden",
"Down": "Ausgefallen",
"DOWN": "AUSGEFALLEN",
"Duration": "Dauer",
"Email address": "E-Mail-Adresse",
"Embed Monitor": "Monitor einbetten",
"Embed this monitor in your website or app": "Betten Sie diesen Monitor in Ihre Website oder App ein",
"End Time": "Endzeit",
"Enter the verification code sent to your email.": "Geben Sie den Bestätigungscode ein, der an Ihre E-Mail-Adresse gesendet wurde.",
"Events": "Veranstaltungen",
"Events": "Ereignisse",
"Failed to load data": "Daten konnten nicht geladen werden",
"Failed to load latency data": "Latenzdaten konnten nicht geladen werden",
"Failed to load status data for this day": "Statusdaten für diesen Tag konnten nicht geladen werden",
"Failed to send verification code": "Der Bestätigungscode konnte nicht gesendet werden",
"Failed to update preference": "Die Präferenz konnte nicht aktualisiert werden",
"Get badges for this monitor": "Erhalten Sie Abzeichen für diesen Monitor",
"Get badges for this monitor": "Erhalten Sie Statusanzeigen für diesen Monitor",
"Get notified about incidents and scheduled maintenance.": "Lassen Sie sich über Vorfälle und geplante Wartungsarbeiten benachrichtigen.",
"Get notified about incidents updates": "Lassen Sie sich über Aktualisierungen von Vorfällen benachrichtigen",
"Get notified about scheduled maintenance": "Lassen Sie sich über geplante Wartungsarbeiten benachrichtigen",
"IDENTIFIED": "IDENTIFIZIERT",
"iFrame": "iFrame",
"Impact": "Auswirkungen",
"Incident Updates": "Vorfallaktualisierungen",
"incident": "Vorfall",
"Incident Updates": "Vorfallsaktualisierungen",
"Incidents": "Vorfälle",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Enthaltene Monitore (%count)",
"INVESTIGATING": "WIRD UNTERSUCHT",
"Last Updated": "Zuletzt aktualisiert",
"Latency": "Latenz",
"Latency Embed": "Latenz-Einbettung",
@@ -44,35 +54,40 @@
"Latency Trend": "Latenztrend",
"Latest Latency": "Neueste Latenz",
"Latest Status": "Neuester Stand",
"Light": "Licht",
"Light": "Hell",
"Live Status": "Live-Status",
"Loading your preferences...": "Deine Einstellungen werden geladen...",
"maintenance": "Wartung",
"MAINTENANCE": "WARTUNG",
"Maintenance Updates": "Wartungsaktualisierungen",
"Maintenances": "Wartungen",
"Major System Outage": "Schwerwiegender Systemausfall",
"Manage Site": "Seite verwalten",
"Manage your notification preferences.": "Verwalten Sie Ihre Benachrichtigungseinstellungen.",
"Max Latency": "Maximale Latenz",
"Max Latency": "Max. Latenz",
"Maximum Latency": "Maximale Latenz",
"Min Latency": "Min. Latenz",
"Minimum Latency": "Min. Latenz",
"Minimum Latency": "Minimale Latenz",
"Minute-by-minute status data for this day": "Minutenweise Statusdaten für diesen Tag",
"Network error. Please try again.": "Netzwerkfehler. ",
"MONITORING": "WIRD ÜBERWACHT",
"Network error. Please try again.": "Netzwerkfehler. Bitte erneut versuchen.",
"No Events in %currentMonth": "Keine Ereignisse in %currentMonth",
"No events to show": "No events to show",
"No events to show": "Keine Ereignisse zum Anzeigen",
"No incidents for this day": "Keine Vorfälle für diesen Tag",
"No latency data available for this day": "Für diesen Tag sind keine Latenzdaten verfügbar",
"No maintenances for this day": "An diesem Tag finden keine Wartungsarbeiten statt",
"No monitors affected": "Keine Monitore betroffen",
"No monitors available.": "No monitors available.",
"No monitors available.": "Keine Monitore verfügbar.",
"No ongoing maintenances": "Keine laufenden Wartungsarbeiten",
"No past maintenances": "Keine früheren Wartungsarbeiten",
"No Status Available": "Kein Status verfügbar",
"No upcoming maintenances": "Keine bevorstehenden Wartungsarbeiten",
"No Updates": "No Updates",
"No updates yet": "Noch keine Updates",
"Notifications": "Notifications",
"No Updates": "Keine Aktualisierungen",
"No updates yet": "Noch keine Aktualisierungen",
"Notifications": "Benachrichtigungen",
"One-time": "Einmalig",
"Ongoing": "Laufend",
"Operational": "Betriebsbereit",
"Partial Degraded Performance": "Teilweise beeinträchtigte Leistung",
"Partial System Outage": "Teilweiser Systemausfall",
"Past": "Vergangenheit",
@@ -82,34 +97,41 @@
"Please enter the 6-digit verification code": "Bitte geben Sie den 6-stelligen Bestätigungscode ein",
"Read less": "Weniger lesen",
"Read more": "Mehr lesen",
"Recurring": "Recurring",
"READY": "BEREIT",
"Recurring": "Wiederkehrend",
"RESOLVED": "BEHOBEN",
"SCHEDULED": "GEPLANT",
"Scheduled Events (%count)": "Geplante Ereignisse (%count)",
"Script": "Skript",
"Select Language": "Wählen Sie Sprache aus",
"Select latency metric to display": "Select latency metric to display",
"Select Range": "Wählen Sie Bereich aus",
"Select Language": "Sprache auswählen",
"Select latency metric to display": "Anzuzeigende Latenzmetrik auswählen",
"Select Range": "Bereich auswählen",
"Sending...": "Senden...",
"Standard": "Standard",
"Start Time": "Startzeit",
"Status": "Status",
"Status Badge": "Statusabzeichen",
"Status Badge": "Statusanzeige",
"Status Embed": "Status einbetten",
"Status history and latency trend": "Statusverlauf und Latenztrend",
"Subscribe": "Abonnieren",
"Subscribe to Updates": "Abonnieren Sie Updates",
"Subscribe to Updates": "Benachrichtigungen abonnieren",
"There are no incidents or maintenances scheduled for this month.": "Für diesen Monat sind keine Vorfälle oder Wartungsarbeiten geplant.",
"There are no ongoing incidents or maintenance events.": "There are no ongoing incidents or maintenance events.",
"There are no ongoing incidents or maintenance events.": "Es gibt keine laufenden Vorfälle oder Wartungsarbeiten.",
"Total Incidents": "Gesamtzahl der Vorfälle",
"Total Maintenances": "Gesamtwartungen",
"Total Maintenances": "Gesamtzahl der Wartungen",
"Under Maintenance": "Unter Wartung",
"Unknown impact": "Unbekannte Auswirkung",
"Upcoming": "Demnächst",
"Updates": "Updates",
"UP": "AKTIV",
"Upcoming": "Anstehend",
"Update Incident": "Vorfall aktualisieren",
"Update Maintenance": "Wartung aktualisieren",
"Updates": "Aktualisierungen",
"Updates (%count)": "Aktualisierungen (%count)",
"Uptime": "Betriebszeit",
"Uptime Badge": "Verfügbarkeitsabzeichen",
"Verification failed": "Die Überprüfung ist fehlgeschlagen",
"Uptime Badge": "Verfügbarkeitsanzeige",
"Verification failed": "Überprüfung fehlgeschlagen",
"Verify": "Verifizieren",
"Verifying": "Verifizieren",
"We sent a 6-digit code to": "Wir haben einen 6-stelligen Code an gesendet"
"We sent a 6-digit code to": "Wir haben einen 6-stelligen Code gesendet an"
}
}
+31 -9
View File
@@ -8,14 +8,21 @@
"Avg Latency": "Gns. latens",
"Back": "Tilbage",
"Badges": "Badges",
"CANCELLED": "ANNULLERET",
"COMPLETED": "AFSLUTTET",
"Continue": "Fortsætte",
"Copied": "Kopieret",
"Current": "Aktuel",
"Dark": "Mørk",
"Day": "Dag",
"Day Uptime": "Dag oppetid",
"Days": "dage",
"Degraded": "Forringet",
"DEGRADED": "FORRINGET",
"Degraded Performance": "Nedsat ydeevne",
"Didn't receive the code? Resend": "Modtog du ikke koden? ",
"Down": "Nede",
"DOWN": "NEDE",
"Duration": "Varighed",
"Email address": "E-mailadresse",
"Embed Monitor": "Integrer skærm",
@@ -32,11 +39,14 @@
"Get notified about incidents and scheduled maintenance.": "Få besked om hændelser og planlagt vedligeholdelse.",
"Get notified about incidents updates": "Få besked om opdateringer om hændelser",
"Get notified about scheduled maintenance": "Få besked om planlagt vedligeholdelse",
"IDENTIFIED": "IDENTIFIED",
"iFrame": "iFrame",
"Impact": "Indvirkning",
"incident": "Hændelse",
"Incident Updates": "Hændelsesopdateringer",
"Incidents": "Hændelser",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Sidst opdateret",
"Latency": "Latency",
"Latency Embed": "Latency Embed",
@@ -47,32 +57,37 @@
"Light": "Lys",
"Live Status": "Live status",
"Loading your preferences...": "Indlæser dine præferencer...",
"maintenance": "Vedligeholdelse",
"MAINTENANCE": "VEDLIGEHOLDELSE",
"Maintenance Updates": "Vedligeholdelsesopdateringer",
"Maintenances": "Vedligeholdelse",
"Major System Outage": "Større systemnedbrud",
"Manage Site": "Administrer side",
"Manage your notification preferences.": "Administrer dine meddelelsespræferencer.",
"Max Latency": "Max latens",
"Maximum Latency": "Max latens",
"Min Latency": "Min latens",
"Minimum Latency": "Min latens",
"Minute-by-minute status data for this day": "Minut for minut statusdata for denne dag",
"MONITORING": "MONITORING",
"Network error. Please try again.": "Netværksfejl. ",
"No Events in %currentMonth": "Ingen begivenheder i %currentMonth",
"No events to show": "No events to show",
"No events to show": "Ingen hændelser at vise",
"No incidents for this day": "Ingen hændelser denne dag",
"No latency data available for this day": "Ingen forsinkelsesdata tilgængelige for denne dag",
"No maintenances for this day": "Ingen vedligeholdelse denne dag",
"No monitors affected": "Ingen skærme påvirket",
"No monitors available.": "No monitors available.",
"No monitors available.": "Ingen monitorer tilgængelige.",
"No ongoing maintenances": "Ingen løbende vedligeholdelse",
"No past maintenances": "Ingen tidligere vedligeholdelse",
"No Status Available": "Ingen status tilgængelig",
"No upcoming maintenances": "Ingen kommende vedligeholdelse",
"No Updates": "No Updates",
"No Updates": "Ingen opdateringer",
"No updates yet": "Ingen opdateringer endnu",
"Notifications": "Notifications",
"Notifications": "Notifikationer",
"One-time": "Engangs",
"Ongoing": "Løbende",
"Operational": "Operationel",
"Partial Degraded Performance": "Delvist nedsat ydeevne",
"Partial System Outage": "Delvist systemnedbrud",
"Past": "Forbi",
@@ -82,10 +97,14 @@
"Please enter the 6-digit verification code": "Indtast venligst den 6-cifrede bekræftelseskode",
"Read less": "Læs mindre",
"Read more": "Læs mere",
"Recurring": "Recurring",
"READY": "KLAR",
"Recurring": "Tilbagevendende",
"RESOLVED": "RESOLVED",
"SCHEDULED": "PLANLAGT",
"Scheduled Events (%count)": "Planlagte begivenheder (%count)",
"Script": "Manuskript",
"Select Language": "Vælg sprog",
"Select latency metric to display": "Select latency metric to display",
"Select latency metric to display": "Vælg latenstidsmåling, der skal vises",
"Select Range": "Vælg Område",
"Sending...": "Sender...",
"Standard": "Standard",
@@ -97,13 +116,16 @@
"Subscribe": "Abonner",
"Subscribe to Updates": "Abonner på opdateringer",
"There are no incidents or maintenances scheduled for this month.": "Der er ingen hændelser eller vedligeholdelse planlagt i denne måned.",
"There are no ongoing incidents or maintenance events.": "There are no ongoing incidents or maintenance events.",
"There are no ongoing incidents or maintenance events.": "Der er ingen igangværende hændelser eller vedligeholdelsesaktiviteter.",
"Total Incidents": "Samlede hændelser",
"Total Maintenances": "Samlet vedligeholdelse",
"Under Maintenance": "Under Vedligeholdelse",
"Unknown impact": "Ukendt påvirkning",
"UP": "OPPE",
"Upcoming": "Kommende",
"Updates": "Updates",
"Update Incident": "Opdater hændelse",
"Update Maintenance": "Opdater vedligeholdelse",
"Updates": "Opdateringer",
"Updates (%count)": "Opdateringer (%count)",
"Uptime": "Oppetid",
"Uptime Badge": "Oppetidsmærke",
+23 -1
View File
@@ -8,14 +8,21 @@
"Avg Latency": "Avg Latency",
"Back": "Back",
"Badges": "Badges",
"CANCELLED": "CANCELLED",
"COMPLETED": "COMPLETED",
"Continue": "Continue",
"Copied": "Copied",
"Current": "Current",
"Dark": "Dark",
"Day": "Day",
"Day Uptime": "Day Uptime",
"Days": "Days",
"Degraded": "Degraded",
"DEGRADED": "DEGRADED",
"Degraded Performance": "Degraded Performance",
"Didn't receive the code? Resend": "Didn't receive the code? Resend",
"Down": "Down",
"DOWN": "DOWN",
"Duration": "Duration",
"Email address": "Email address",
"Embed Monitor": "Embed Monitor",
@@ -32,11 +39,14 @@
"Get notified about incidents and scheduled maintenance.": "Get notified about incidents and scheduled maintenance.",
"Get notified about incidents updates": "Get notified about incidents updates",
"Get notified about scheduled maintenance": "Get notified about scheduled maintenance",
"IDENTIFIED": "IDENTIFIED",
"iFrame": "iFrame",
"Impact": "Impact",
"incident": "Incident",
"Incident Updates": "Incident Updates",
"Incidents": "Incidents",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Last Updated",
"Latency": "Latency",
"Latency Embed": "Latency Embed",
@@ -47,15 +57,19 @@
"Light": "Light",
"Live Status": "Live Status",
"Loading your preferences...": "Loading your preferences...",
"maintenance": "Maintenance",
"MAINTENANCE": "MAINTENANCE",
"Maintenance Updates": "Maintenance Updates",
"Maintenances": "Maintenances",
"Major System Outage": "Major System Outage",
"Manage Site": "Manage Site",
"Manage your notification preferences.": "Manage your notification preferences.",
"Max Latency": "Max Latency",
"Maximum Latency": "Max Latency",
"Min Latency": "Min Latency",
"Minimum Latency": "Min Latency",
"Minute-by-minute status data for this day": "Minute-by-minute status data for this day",
"MONITORING": "MONITORING",
"Network error. Please try again.": "Network error. Please try again.",
"No Events in %currentMonth": "No Events in %currentMonth",
"No events to show": "No events to show",
@@ -73,6 +87,7 @@
"Notifications": "Notifications",
"One-time": "One-time",
"Ongoing": "Ongoing",
"Operational": "Operational",
"Partial Degraded Performance": "Partial Degraded Performance",
"Partial System Outage": "Partial System Outage",
"Past": "Past",
@@ -82,7 +97,11 @@
"Please enter the 6-digit verification code": "Please enter the 6-digit verification code",
"Read less": "Read less",
"Read more": "Read more",
"READY": "READY",
"Recurring": "Recurring",
"RESOLVED": "RESOLVED",
"SCHEDULED": "SCHEDULED",
"Scheduled Events (%count)": "Scheduled Events (%count)",
"Script": "Script",
"Select Language": "Select Language",
"Select latency metric to display": "Select latency metric to display",
@@ -102,7 +121,10 @@
"Total Maintenances": "Total Maintenances",
"Under Maintenance": "Under Maintenance",
"Unknown impact": "Unknown impact",
"UP": "UP",
"Upcoming": "Upcoming",
"Update Incident": "Update Incident",
"Update Maintenance": "Update Maintenance",
"Updates": "Updates",
"Updates (%count)": "Updates (%count)",
"Uptime": "Uptime",
+31 -9
View File
@@ -8,14 +8,21 @@
"Avg Latency": "Latencia promedio",
"Back": "Atrás",
"Badges": "Insignias",
"CANCELLED": "CANCELADO",
"COMPLETED": "COMPLETADO",
"Continue": "Continuar",
"Copied": "Copiado",
"Current": "Actual",
"Dark": "Oscuro",
"Day": "Día",
"Day Uptime": "Tiempo de actividad del día",
"Days": "Días",
"Degraded": "Degradado",
"DEGRADED": "DEGRADADO",
"Degraded Performance": "Rendimiento degradado",
"Didn't receive the code? Resend": "¿No recibiste el código? ",
"Down": "Caído",
"DOWN": "CAÍDO",
"Duration": "Duración",
"Email address": "Dirección de correo electrónico",
"Embed Monitor": "Monitor integrado",
@@ -32,11 +39,14 @@
"Get notified about incidents and scheduled maintenance.": "Recibe notificaciones sobre incidencias y mantenimientos programados.",
"Get notified about incidents updates": "Recibir notificaciones sobre actualizaciones de incidentes",
"Get notified about scheduled maintenance": "Recibir notificaciones sobre el mantenimiento programado",
"IDENTIFIED": "IDENTIFIED",
"iFrame": "marco flotante",
"Impact": "Impacto",
"incident": "Incidente",
"Incident Updates": "Actualizaciones de incidentes",
"Incidents": "Incidentes",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Última actualización",
"Latency": "Estado latente",
"Latency Embed": "Incrustación de latencia",
@@ -47,32 +57,37 @@
"Light": "Luz",
"Live Status": "Estado en vivo",
"Loading your preferences...": "Cargando tus preferencias...",
"maintenance": "Mantenimiento",
"MAINTENANCE": "MANTENIMIENTO",
"Maintenance Updates": "Actualizaciones de mantenimiento",
"Maintenances": "Mantenimientos",
"Major System Outage": "Interrupción importante del sistema",
"Manage Site": "Gestionar sitio",
"Manage your notification preferences.": "Administre sus preferencias de notificación.",
"Max Latency": "Latencia máxima",
"Maximum Latency": "Latencia máxima",
"Min Latency": "Latencia mínima",
"Minimum Latency": "Latencia mínima",
"Minute-by-minute status data for this day": "Datos del estado minuto a minuto de este día",
"MONITORING": "MONITORING",
"Network error. Please try again.": "Error de red. ",
"No Events in %currentMonth": "No hay eventos en %currentMonth",
"No events to show": "No events to show",
"No events to show": "No hay eventos para mostrar",
"No incidents for this day": "No hay incidencias para este día",
"No latency data available for this day": "No hay datos de latencia disponibles para este día",
"No maintenances for this day": "No hay mantenimientos para este día.",
"No monitors affected": "Ningún monitor afectado",
"No monitors available.": "No monitors available.",
"No monitors available.": "No hay monitores disponibles.",
"No ongoing maintenances": "Sin mantenimientos continuos",
"No past maintenances": "Sin mantenimientos pasados",
"No Status Available": "Estado no disponible",
"No upcoming maintenances": "No hay mantenimientos próximos",
"No Updates": "No Updates",
"No Updates": "Sin actualizaciones",
"No updates yet": "Aún no hay actualizaciones",
"Notifications": "Notifications",
"Notifications": "Notificaciones",
"One-time": "una sola vez",
"Ongoing": "En curso",
"Operational": "Operativo",
"Partial Degraded Performance": "Rendimiento parcialmente degradado",
"Partial System Outage": "Interrupción parcial del sistema",
"Past": "Pasado",
@@ -82,10 +97,14 @@
"Please enter the 6-digit verification code": "Por favor ingresa el código de verificación de 6 dígitos",
"Read less": "Leer menos",
"Read more": "Leer más",
"Recurring": "Recurring",
"READY": "LISTO",
"Recurring": "Recurrente",
"RESOLVED": "RESOLVED",
"SCHEDULED": "PROGRAMADO",
"Scheduled Events (%count)": "Eventos programados (%count)",
"Script": "Guion",
"Select Language": "Seleccionar idioma",
"Select latency metric to display": "Select latency metric to display",
"Select latency metric to display": "Selecciona la métrica de latencia para mostrar",
"Select Range": "Seleccionar rango",
"Sending...": "Envío...",
"Standard": "Estándar",
@@ -97,13 +116,16 @@
"Subscribe": "Suscribir",
"Subscribe to Updates": "Suscríbete a las actualizaciones",
"There are no incidents or maintenances scheduled for this month.": "No hay incidencias ni mantenimientos programados para este mes.",
"There are no ongoing incidents or maintenance events.": "There are no ongoing incidents or maintenance events.",
"There are no ongoing incidents or maintenance events.": "No hay incidentes ni mantenimientos en curso.",
"Total Incidents": "Incidentes totales",
"Total Maintenances": "Mantenimientos totales",
"Under Maintenance": "En mantenimiento",
"Unknown impact": "Impacto desconocido",
"UP": "ACTIVO",
"Upcoming": "Próximo",
"Updates": "Updates",
"Update Incident": "Actualizar incidente",
"Update Maintenance": "Actualizar mantenimiento",
"Updates": "Actualizaciones",
"Updates (%count)": "Actualizaciones (%count)",
"Uptime": "tiempo de actividad",
"Uptime Badge": "Insignia de tiempo de actividad",
+31 -9
View File
@@ -8,14 +8,21 @@
"Avg Latency": "تأخیر متوسط",
"Back": "برگشت",
"Badges": "نشان ها",
"CANCELLED": "لغو شده",
"COMPLETED": "تکمیل شده",
"Continue": "ادامه دهید",
"Copied": "کپی شده",
"Current": "فعلی",
"Dark": "تاریک",
"Day": "روز",
"Day Uptime": "تایم روز",
"Days": "روزها",
"Degraded": "کاهش یافته",
"DEGRADED": "کاهش یافته",
"Degraded Performance": "عملکرد کاهش‌یافته",
"Didn't receive the code? Resend": "کد را دریافت نکردید؟ ",
"Down": "از کار افتاده",
"DOWN": "قطع",
"Duration": "مدت",
"Email address": "آدرس ایمیل",
"Embed Monitor": "تعبیه مانیتور",
@@ -32,11 +39,14 @@
"Get notified about incidents and scheduled maintenance.": "در مورد حوادث و تعمیر و نگهداری برنامه ریزی شده مطلع شوید.",
"Get notified about incidents updates": "در مورد به روز رسانی حوادث مطلع شوید",
"Get notified about scheduled maintenance": "در مورد تعمیر و نگهداری برنامه ریزی شده مطلع شوید",
"IDENTIFIED": "IDENTIFIED",
"iFrame": "آی فریم",
"Impact": "تاثیر",
"incident": "رخداد",
"Incident Updates": "به روز رسانی حادثه",
"Incidents": "حوادث",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "آخرین به روز رسانی",
"Latency": "تأخیر",
"Latency Embed": "تعبیه تاخیر",
@@ -47,32 +57,37 @@
"Light": "نور",
"Live Status": "وضعیت زنده",
"Loading your preferences...": "در حال بارگیری تنظیمات برگزیده شما...",
"maintenance": "نگهداری",
"MAINTENANCE": "تعمیر و نگهداری",
"Maintenance Updates": "به روز رسانی های تعمیر و نگهداری",
"Maintenances": "تعمیر و نگهداری",
"Major System Outage": "قطعی عمده سیستم",
"Manage Site": "مدیریت سایت",
"Manage your notification preferences.": "تنظیمات برگزیده اعلان خود را مدیریت کنید.",
"Max Latency": "حداکثر تاخیر",
"Maximum Latency": "حداکثر تاخیر",
"Min Latency": "حداقل تاخیر",
"Minimum Latency": "حداقل تاخیر",
"Minute-by-minute status data for this day": "داده های وضعیت دقیقه به دقیقه برای این روز",
"MONITORING": "MONITORING",
"Network error. Please try again.": "خطای شبکه ",
"No Events in %currentMonth": "هیچ رویدادی در %currentMonth وجود ندارد",
"No events to show": "No events to show",
"No events to show": "رویدادی برای نمایش وجود ندارد",
"No incidents for this day": "هیچ حادثه ای برای این روز وجود ندارد",
"No latency data available for this day": "هیچ داده تاخیری برای این روز در دسترس نیست",
"No maintenances for this day": "بدون تعمیر و نگهداری برای این روز",
"No monitors affected": "هیچ مانیتوری تحت تأثیر قرار نگرفت",
"No monitors available.": "No monitors available.",
"No monitors available.": "هیچ مانیتوری در دسترس نیست.",
"No ongoing maintenances": "بدون تعمیر و نگهداری مداوم",
"No past maintenances": "بدون تعمیر و نگهداری قبلی",
"No Status Available": "وضعیتی در دسترس نیست",
"No upcoming maintenances": "بدون تعمیر و نگهداری آینده",
"No Updates": "No Updates",
"No Updates": "بدون به‌روزرسانی",
"No updates yet": "هنوز به روز رسانی نشده است",
"Notifications": "Notifications",
"Notifications": "اعلان‌ها",
"One-time": "یک بار",
"Ongoing": "در حال انجام است",
"Operational": "عملیاتی",
"Partial Degraded Performance": "کاهش عملکرد جزئی",
"Partial System Outage": "قطعی جزئی سیستم",
"Past": "گذشته",
@@ -82,10 +97,14 @@
"Please enter the 6-digit verification code": "لطفا کد تایید 6 رقمی را وارد کنید",
"Read less": "کمتر بخوانید",
"Read more": "ادامه مطلب",
"Recurring": "Recurring",
"READY": "آماده",
"Recurring": "دوره‌ای",
"RESOLVED": "RESOLVED",
"SCHEDULED": "زمان‌بندی شده",
"Scheduled Events (%count)": "رویدادهای زمان‌بندی شده (%count)",
"Script": "اسکریپت",
"Select Language": "زبان را انتخاب کنید",
"Select latency metric to display": "Select latency metric to display",
"Select latency metric to display": "معیار تأخیر برای نمایش را انتخاب کنید",
"Select Range": "Range را انتخاب کنید",
"Sending...": "ارسال...",
"Standard": "استاندارد",
@@ -97,13 +116,16 @@
"Subscribe": "مشترک شوید",
"Subscribe to Updates": "مشترک شدن در به روز رسانی",
"There are no incidents or maintenances scheduled for this month.": "هیچ حادثه یا تعمیراتی برای این ماه برنامه ریزی نشده است.",
"There are no ongoing incidents or maintenance events.": "There are no ongoing incidents or maintenance events.",
"There are no ongoing incidents or maintenance events.": "هیچ رخداد یا رویداد نگهداری فعالی وجود ندارد.",
"Total Incidents": "مجموع حوادث",
"Total Maintenances": "کل تعمیر و نگهداری",
"Under Maintenance": "تحت تعمیر و نگهداری",
"Unknown impact": "تاثیر نامعلوم",
"UP": "فعال",
"Upcoming": "آینده",
"Updates": "Updates",
"Update Incident": "به‌روزرسانی رویداد",
"Update Maintenance": "به‌روزرسانی نگهداری",
"Updates": "به‌روزرسانی‌ها",
"Updates (%count)": "به‌روزرسانی‌ها (%count)",
"Uptime": "آپتایم",
"Uptime Badge": "نشان Uptime",
+35 -13
View File
@@ -3,19 +3,26 @@
"mappings": {
"%latency %metric latency": "%latency %metric latence",
"Affected Monitors (%count)": "Moniteurs concernés (%count)",
"All Systems Operational": "Tous les systèmes opérationnels",
"All Systems Operational": "Tous les systèmes sont opérationnels",
"Average Latency": "Latence moyenne",
"Avg Latency": "Latence moyenne",
"Back": "Dos",
"Badges": "Insignes",
"Back": "Retour",
"Badges": "Badges",
"CANCELLED": "ANNULÉ",
"COMPLETED": "TERMINÉ",
"Continue": "Continuer",
"Copied": "Copié",
"Current": "En cours",
"Dark": "Sombre",
"Day": "Jour",
"Day Uptime": "Disponibilité journalière",
"Days": "Jours",
"Degraded": "Dégradé",
"DEGRADED": "DÉGRADÉ",
"Degraded Performance": "Performance dégradée",
"Didn't receive the code? Resend": "Vous n'avez pas reçu le code ? ",
"Down": "Hors service",
"DOWN": "EN PANNE",
"Duration": "Durée",
"Email address": "Adresse email",
"Embed Monitor": "Intégrer le moniteur",
@@ -32,11 +39,14 @@
"Get notified about incidents and scheduled maintenance.": "Soyez informé des incidents et des maintenances planifiées.",
"Get notified about incidents updates": "Soyez informé des mises à jour des incidents",
"Get notified about scheduled maintenance": "Soyez informé de la maintenance planifiée",
"IDENTIFIED": "IDENTIFIED",
"iFrame": "iFrame",
"Impact": "Impact",
"incident": "Incident",
"Incident Updates": "Mises à jour des incidents",
"Incidents": "Incidents",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Dernière mise à jour",
"Latency": "Latence",
"Latency Embed": "Latence intégrée",
@@ -47,45 +57,54 @@
"Light": "Lumière",
"Live Status": "Statut en direct",
"Loading your preferences...": "Chargement de vos préférences...",
"maintenance": "Maintenance",
"MAINTENANCE": "MAINTENANCE",
"Maintenance Updates": "Mises à jour de maintenance",
"Maintenances": "Entretiens",
"Major System Outage": "Panne majeure du système",
"Manage Site": "Gérer le site",
"Manage your notification preferences.": "Gérez vos préférences de notification.",
"Max Latency": "Latence maximale",
"Maximum Latency": "Latence maximale",
"Min Latency": "Latence minimale",
"Minimum Latency": "Latence minimale",
"Minute-by-minute status data for this day": "Données d'état minute par minute pour cette journée",
"MONITORING": "MONITORING",
"Network error. Please try again.": "Erreur réseau. ",
"No Events in %currentMonth": "Aucun événement dans %currentMonth",
"No events to show": "No events to show",
"No events to show": "Aucun événement à afficher",
"No incidents for this day": "Aucun incident pour cette journée",
"No latency data available for this day": "Aucune donnée de latence disponible pour ce jour",
"No maintenances for this day": "Aucune maintenance pour cette journée",
"No monitors affected": "Aucun moniteur affecté",
"No monitors available.": "No monitors available.",
"No monitors available.": "Aucun moniteur disponible.",
"No ongoing maintenances": "Aucune maintenance en cours",
"No past maintenances": "Aucune maintenance passée",
"No Status Available": "Aucun statut disponible",
"No upcoming maintenances": "Aucune maintenance à venir",
"No Updates": "No Updates",
"No Updates": "Aucune mise à jour",
"No updates yet": "Aucune mise à jour pour l'instant",
"Notifications": "Notifications",
"Notifications": "Alertes",
"One-time": "Une fois",
"Ongoing": "En cours",
"Operational": "Opérationnel",
"Partial Degraded Performance": "Performance partiellement dégradée",
"Partial System Outage": "Panne partielle du système",
"Past": "Passé",
"Per-Minute Status": "Statut par minute",
"Pinging": "Ping",
"Please enter a valid email address": "S'il vous plaît, mettez une adresse email valide",
"Please enter a valid email address": "Veuillez renseigner une adresse email valide",
"Please enter the 6-digit verification code": "Veuillez saisir le code de vérification à 6 chiffres",
"Read less": "Lire moins",
"Read more": "En savoir plus",
"Recurring": "Recurring",
"READY": "PRÊT",
"Recurring": "Récurrent",
"RESOLVED": "RESOLVED",
"SCHEDULED": "PLANIFIÉ",
"Scheduled Events (%count)": "Événements planifiés (%count)",
"Script": "Scénario",
"Select Language": "Sélectionnez la langue",
"Select latency metric to display": "Select latency metric to display",
"Select latency metric to display": "Sélectionnez la métrique de latence à afficher",
"Select Range": "Sélectionner une plage",
"Sending...": "Envoi...",
"Standard": "Standard",
@@ -97,13 +116,16 @@
"Subscribe": "S'abonner",
"Subscribe to Updates": "Abonnez-vous aux mises à jour",
"There are no incidents or maintenances scheduled for this month.": "Il ny a aucun incident ou maintenance prévu pour ce mois.",
"There are no ongoing incidents or maintenance events.": "There are no ongoing incidents or maintenance events.",
"There are no ongoing incidents or maintenance events.": "Il ny a aucun incident ni aucune maintenance en cours.",
"Total Incidents": "Nombre total d'incidents",
"Total Maintenances": "Entretiens totaux",
"Under Maintenance": "En maintenance",
"Unknown impact": "Impact inconnu",
"UP": "OPÉRATIONNEL",
"Upcoming": "Prochain",
"Updates": "Updates",
"Update Incident": "Mettre à jour l'incident",
"Update Maintenance": "Mettre à jour la maintenance",
"Updates": "Mises à jour",
"Updates (%count)": "Mises à jour (%count)",
"Uptime": "Temps de disponibilité",
"Uptime Badge": "Badge de disponibilité",
+39 -17
View File
@@ -8,14 +8,21 @@
"Avg Latency": "औसत लेटेंसी",
"Back": "वापस",
"Badges": "बैज",
"CANCELLED": "रद्द",
"COMPLETED": "पूर्ण",
"Continue": "जारी रखें",
"Copied": "कॉपी किया गया",
"Current": "वर्तमान",
"Dark": "डार्क",
"Day": "दिन",
"Day Uptime": "दिन का अपटाइम",
"Days": "दिन",
"Degraded": "अवनत",
"DEGRADED": "अवक्रमित",
"Degraded Performance": "प्रदर्शन में गिरावट",
"Didn't receive the code? Resend": "कोड नहीं मिला? फिर से भेजें",
"Down": "बंद",
"DOWN": "बंद",
"Duration": "अवधि",
"Email address": "ईमेल पता",
"Embed Monitor": "मॉनिटर एम्बेड करें",
@@ -32,11 +39,14 @@
"Get notified about incidents and scheduled maintenance.": "घटनाओं और निर्धारित रखरखाव के बारे में सूचित हों।",
"Get notified about incidents updates": "घटना अपडेट के बारे में सूचित हों",
"Get notified about scheduled maintenance": "निर्धारित रखरखाव के बारे में सूचित हों",
"IDENTIFIED": "पहचाना गया",
"iFrame": "iFrame",
"Impact": "प्रभाव",
"incident": "घटना",
"Incident Updates": "घटना अपडेट",
"Incidents": "घटनाएँ",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "शामिल मॉनिटर (%count)",
"INVESTIGATING": "जाँच कर रहे हैं",
"Last Updated": "अंतिम अपडेट",
"Latency": "लेटेंसी",
"Latency Embed": "लेटेंसी एम्बेड",
@@ -47,32 +57,37 @@
"Light": "लाइट",
"Live Status": "लाइव स्थिति",
"Loading your preferences...": "आपकी प्राथमिकताएँ लोड हो रही हैं...",
"maintenance": "रखरखाव",
"MAINTENANCE": "रखरखाव",
"Maintenance Updates": "रखरखाव अपडेट",
"Maintenances": "रखरखाव",
"Major System Outage": "सिस्टम का बड़ा आउटेज",
"Manage Site": "साइट प्रबंधित करें",
"Manage your notification preferences.": "अपनी सूचना प्राथमिकताएँ प्रबंधित करें।",
"Max Latency": "Max Latency",
"Maximum Latency": "Max Latency",
"Min Latency": "Min Latency",
"Minimum Latency": "Min Latency",
"Max Latency": "अधिकतम लेटेंसी",
"Maximum Latency": "अधिकतम लेटेंसी",
"Min Latency": "न्यूनतम लेटेंसी",
"Minimum Latency": "न्यूनतम लेटेंसी",
"Minute-by-minute status data for this day": "इस दिन के लिए मिनट-दर-मिनट स्थिति डेटा",
"MONITORING": "मॉनिटरिंग",
"Network error. Please try again.": "नेटवर्क त्रुटि। कृपया पुनः प्रयास करें।",
"No Events in %currentMonth": "%currentMonth में कोई घटना नहीं",
"No events to show": "No events to show",
"No events to show": "दिखाने के लिए कोई इवेंट नहीं",
"No incidents for this day": "इस दिन के लिए कोई घटना नहीं",
"No latency data available for this day": "इस दिन के लिए कोई लेटेंसी डेटा उपलब्ध नहीं",
"No maintenances for this day": "इस दिन के लिए कोई रखरखाव नहीं",
"No monitors affected": "कोई मॉनिटर प्रभावित नहीं",
"No monitors available.": "No monitors available.",
"No monitors available.": "कोई मॉनिटर उपलब्ध नहीं है।",
"No ongoing maintenances": "कोई चल रहा रखरखाव नहीं",
"No past maintenances": "कोई बीता हुआ रखरखाव नहीं",
"No Status Available": "स्थिति उपलब्ध नहीं है",
"No upcoming maintenances": "कोई आने वाला रखरखाव नहीं",
"No Updates": "No Updates",
"No Updates": "कोई अपडेट नहीं",
"No updates yet": "अभी तक कोई अपडेट नहीं",
"Notifications": "Notifications",
"Notifications": "सूचनाएँ",
"One-time": "एक बार",
"Ongoing": "चल रहा है",
"Operational": "चालू",
"Partial Degraded Performance": "आंशिक प्रदर्शन गिरावट",
"Partial System Outage": "सिस्टम का आंशिक आउटेज.",
"Past": "बीता हुआ",
@@ -82,10 +97,14 @@
"Please enter the 6-digit verification code": "कृपया 6 अंकों का सत्यापन कोड दर्ज करें",
"Read less": "कम पढ़ें",
"Read more": "और पढ़ें",
"Recurring": "Recurring",
"READY": "तैयार",
"Recurring": "आवर्ती",
"RESOLVED": "सुलझा हुआ",
"SCHEDULED": "अनुसूचित",
"Scheduled Events (%count)": "अनुसूचित कार्यक्रम (%count)",
"Script": "स्क्रिप्ट",
"Select Language": "भाषा चुनें",
"Select latency metric to display": "Select latency metric to display",
"Select latency metric to display": "दिखाने के लिए विलंबता मेट्रिक चुनें",
"Select Range": "रेंज चुनें",
"Sending...": "भेजा जा रहा है...",
"Standard": "मानक",
@@ -97,19 +116,22 @@
"Subscribe": "सब्सक्राइब करें",
"Subscribe to Updates": "अपडेट के लिए सब्सक्राइब करें",
"There are no incidents or maintenances scheduled for this month.": "इस महीने के लिए कोई घटना या रखरखाव निर्धारित नहीं है।",
"There are no ongoing incidents or maintenance events.": "There are no ongoing incidents or maintenance events.",
"Total Incidents": "Total Incidents",
"Total Maintenances": "Total Maintenances",
"There are no ongoing incidents or maintenance events.": "कोई चल रही घटना या रखरखाव इवेंट नहीं है।",
"Total Incidents": "कुल घटनाएँ",
"Total Maintenances": "कुल रखरखाव",
"Under Maintenance": "रखरखाव जारी है",
"Unknown impact": "अज्ञात प्रभाव",
"UP": "चालू",
"Upcoming": "आने वाला",
"Updates": "Updates",
"Update Incident": "घटना अपडेट करें",
"Update Maintenance": "रखरखाव अपडेट करें",
"Updates": "अपडेट्स",
"Updates (%count)": "अपडेट (%count)",
"Uptime": "अपटाइम",
"Uptime Badge": "अपटाइम बैज",
"Verification failed": "सत्यापन विफल रहा",
"Verify": "Verify",
"Verifying": "Verifying",
"Verify": "सत्यापित करें",
"Verifying": "सत्यापित हो रहा है",
"We sent a 6-digit code to": "हमने 6 अंकों का कोड भेजा है"
}
}
+31 -9
View File
@@ -8,14 +8,21 @@
"Avg Latency": "Latenza media",
"Back": "Indietro",
"Badges": "Distintivi",
"CANCELLED": "ANNULLATO",
"COMPLETED": "COMPLETATO",
"Continue": "Continuare",
"Copied": "Copiato",
"Current": "Corrente",
"Dark": "Buio",
"Day": "Giorno",
"Day Uptime": "Tempo di attività giornaliero",
"Days": "Giorni",
"Degraded": "Degradato",
"DEGRADED": "DEGRADATO",
"Degraded Performance": "Prestazioni degradate",
"Didn't receive the code? Resend": "Non hai ricevuto il codice? ",
"Down": "Non disponibile",
"DOWN": "NON DISPONIBILE",
"Duration": "Durata",
"Email address": "Indirizzo e-mail",
"Embed Monitor": "Incorpora monitoraggio",
@@ -32,11 +39,14 @@
"Get notified about incidents and scheduled maintenance.": "Ricevi notifiche sugli incidenti e sulla manutenzione programmata.",
"Get notified about incidents updates": "Ricevi notifiche sugli aggiornamenti degli incidenti",
"Get notified about scheduled maintenance": "Ricevi notifiche sulla manutenzione programmata",
"IDENTIFIED": "IDENTIFIED",
"iFrame": "iFrame",
"Impact": "Impatto",
"incident": "Incidente",
"Incident Updates": "Aggiornamenti sugli incidenti",
"Incidents": "Incidenti",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Ultimo aggiornamento",
"Latency": "Latenza",
"Latency Embed": "Incorporamento latenza",
@@ -47,32 +57,37 @@
"Light": "Leggero",
"Live Status": "Stato in tempo reale",
"Loading your preferences...": "Caricamento delle tue preferenze...",
"maintenance": "Manutenzione",
"MAINTENANCE": "MANUTENZIONE",
"Maintenance Updates": "Aggiornamenti sulla manutenzione",
"Maintenances": "Manutenzioni",
"Major System Outage": "Interruzione grave del sistema",
"Manage Site": "Gestisci sito",
"Manage your notification preferences.": "Gestisci le tue preferenze di notifica.",
"Max Latency": "Latenza massima",
"Maximum Latency": "Latenza massima",
"Min Latency": "Latenza minima",
"Minimum Latency": "Latenza minima",
"Minute-by-minute status data for this day": "Dati sullo stato minuto per minuto per questo giorno",
"MONITORING": "MONITORING",
"Network error. Please try again.": "Errore di rete. ",
"No Events in %currentMonth": "Nessun evento in %currentMonth",
"No events to show": "No events to show",
"No events to show": "Nessun evento da mostrare",
"No incidents for this day": "Nessun incidente per questa giornata",
"No latency data available for this day": "Nessun dato sulla latenza disponibile per questo giorno",
"No maintenances for this day": "Nessuna manutenzione per oggi",
"No monitors affected": "Nessun monitor interessato",
"No monitors available.": "No monitors available.",
"No monitors available.": "Nessun monitor disponibile.",
"No ongoing maintenances": "Nessuna manutenzione continua",
"No past maintenances": "Nessuna manutenzione passata",
"No Status Available": "Nessuno stato disponibile",
"No upcoming maintenances": "Nessuna manutenzione imminente",
"No Updates": "No Updates",
"No Updates": "Nessun aggiornamento",
"No updates yet": "Nessun aggiornamento ancora",
"Notifications": "Notifications",
"Notifications": "Notifiche",
"One-time": "Una volta",
"Ongoing": "In corso",
"Operational": "Operativo",
"Partial Degraded Performance": "Prestazioni parzialmente degradate",
"Partial System Outage": "Interruzione parziale del sistema",
"Past": "Passato",
@@ -82,10 +97,14 @@
"Please enter the 6-digit verification code": "Inserisci il codice di verifica di 6 cifre",
"Read less": "Leggi di meno",
"Read more": "Per saperne di più",
"Recurring": "Recurring",
"READY": "PRONTO",
"Recurring": "Ricorrente",
"RESOLVED": "RESOLVED",
"SCHEDULED": "PROGRAMMATO",
"Scheduled Events (%count)": "Eventi programmati (%count)",
"Script": "Copione",
"Select Language": "Seleziona lingua",
"Select latency metric to display": "Select latency metric to display",
"Select latency metric to display": "Seleziona la metrica di latenza da visualizzare",
"Select Range": "Seleziona Intervallo",
"Sending...": "Invio...",
"Standard": "Standard",
@@ -97,13 +116,16 @@
"Subscribe": "Iscriviti",
"Subscribe to Updates": "Iscriviti agli aggiornamenti",
"There are no incidents or maintenances scheduled for this month.": "Non ci sono incidenti o manutenzioni programmate per questo mese.",
"There are no ongoing incidents or maintenance events.": "There are no ongoing incidents or maintenance events.",
"There are no ongoing incidents or maintenance events.": "Non ci sono incidenti o manutenzioni in corso.",
"Total Incidents": "Incidenti totali",
"Total Maintenances": "Manutenzioni totali",
"Under Maintenance": "In manutenzione",
"Unknown impact": "Impatto sconosciuto",
"UP": "ATTIVO",
"Upcoming": "Prossimamente",
"Updates": "Updates",
"Update Incident": "Aggiorna incidente",
"Update Maintenance": "Aggiorna manutenzione",
"Updates": "Aggiornamenti",
"Updates (%count)": "Aggiornamenti (%count)",
"Uptime": "Tempo di attività",
"Uptime Badge": "Badge di operatività",
+31 -9
View File
@@ -8,14 +8,21 @@
"Avg Latency": "平均レイテンシー",
"Back": "戻る",
"Badges": "バッジ",
"CANCELLED": "キャンセル",
"COMPLETED": "完了",
"Continue": "続く",
"Copied": "コピーされました",
"Current": "現在",
"Dark": "暗い",
"Day": "日",
"Day Uptime": "一日の稼働時間",
"Days": "日数",
"Degraded": "低下",
"DEGRADED": "劣化",
"Degraded Performance": "パフォーマンスの低下",
"Didn't receive the code? Resend": "コードを受け取っていませんか?",
"Down": "停止",
"DOWN": "障害",
"Duration": "間隔",
"Email address": "電子メールアドレス",
"Embed Monitor": "埋め込みモニター",
@@ -32,11 +39,14 @@
"Get notified about incidents and scheduled maintenance.": "インシデントや定期メンテナンスに関する通知を受け取ります。",
"Get notified about incidents updates": "インシデントの更新に関する通知を受け取る",
"Get notified about scheduled maintenance": "定期メンテナンスに関する通知を受け取る",
"IDENTIFIED": "IDENTIFIED",
"iFrame": "iFrame",
"Impact": "インパクト",
"incident": "インシデント",
"Incident Updates": "インシデントの最新情報",
"Incidents": "事件",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "最終更新日",
"Latency": "レイテンシー",
"Latency Embed": "レイテンシの埋め込み",
@@ -47,32 +57,37 @@
"Light": "ライト",
"Live Status": "ライブステータス",
"Loading your preferences...": "設定を読み込んでいます...",
"maintenance": "メンテナンス",
"MAINTENANCE": "メンテナンス",
"Maintenance Updates": "メンテナンスアップデート",
"Maintenances": "メンテナンス",
"Major System Outage": "大規模なシステム障害",
"Manage Site": "サイト管理",
"Manage your notification preferences.": "通知設定を管理します。",
"Max Latency": "最大レイテンシー",
"Maximum Latency": "最大レイテンシー",
"Min Latency": "最小レイテンシ",
"Minimum Latency": "最小レイテンシ",
"Minute-by-minute status data for this day": "この日の分単位のステータスデータ",
"MONITORING": "MONITORING",
"Network error. Please try again.": "ネットワークエラー。",
"No Events in %currentMonth": "%currentMonth にはイベントがありません",
"No events to show": "No events to show",
"No events to show": "表示するイベントはありません",
"No incidents for this day": "この日は事件はありませんでした",
"No latency data available for this day": "この日のレイテンシ データはありません",
"No maintenances for this day": "この日はメンテナンスはありません",
"No monitors affected": "影響を受けるモニターはありません",
"No monitors available.": "No monitors available.",
"No monitors available.": "利用可能なモニターはありません。",
"No ongoing maintenances": "継続的なメンテナンスはありません",
"No past maintenances": "過去のメンテナンスはありません",
"No Status Available": "ステータス情報はありません",
"No upcoming maintenances": "今後のメンテナンスはありません",
"No Updates": "No Updates",
"No Updates": "更新はありません",
"No updates yet": "まだ更新はありません",
"Notifications": "Notifications",
"Notifications": "通知",
"One-time": "一度",
"Ongoing": "進行中",
"Operational": "稼働中",
"Partial Degraded Performance": "部分的なパフォーマンス低下",
"Partial System Outage": "部分的なシステム障害。",
"Past": "過去",
@@ -82,10 +97,14 @@
"Please enter the 6-digit verification code": "6桁の認証コードを入力してください",
"Read less": "読む量を減らす",
"Read more": "続きを読む",
"Recurring": "Recurring",
"READY": "準備完了",
"Recurring": "繰り返し",
"RESOLVED": "RESOLVED",
"SCHEDULED": "予定済み",
"Scheduled Events (%count)": "予定イベント (%count)",
"Script": "スクリプト",
"Select Language": "言語の選択",
"Select latency metric to display": "Select latency metric to display",
"Select latency metric to display": "表示するレイテンシ指標を選択",
"Select Range": "範囲の選択",
"Sending...": "送信中...",
"Standard": "標準",
@@ -97,13 +116,16 @@
"Subscribe": "購読する",
"Subscribe to Updates": "アップデートを購読する",
"There are no incidents or maintenances scheduled for this month.": "今月は予定されているインシデントやメンテナンスはありません。",
"There are no ongoing incidents or maintenance events.": "There are no ongoing incidents or maintenance events.",
"There are no ongoing incidents or maintenance events.": "進行中のインシデントまたはメンテナンスイベントはありません。",
"Total Incidents": "総インシデント数",
"Total Maintenances": "トータルメンテナンス",
"Under Maintenance": "メンテナンス中",
"Unknown impact": "未知の影響",
"UP": "正常",
"Upcoming": "今後の予定",
"Updates": "Updates",
"Update Incident": "インシデントを更新",
"Update Maintenance": "メンテナンスを更新",
"Updates": "更新",
"Updates (%count)": "アップデート (%count)",
"Uptime": "稼働時間",
"Uptime Badge": "稼働時間バッジ",
+31 -9
View File
@@ -8,14 +8,21 @@
"Avg Latency": "평균 지연 시간",
"Back": "뒤쪽에",
"Badges": "배지",
"CANCELLED": "취소됨",
"COMPLETED": "완료됨",
"Continue": "계속하다",
"Copied": "복사됨",
"Current": "현재",
"Dark": "어두운",
"Day": "낮",
"Day Uptime": "일일 가동 시간",
"Days": "날",
"Degraded": "저하",
"DEGRADED": "성능 저하",
"Degraded Performance": "성능 저하",
"Didn't receive the code? Resend": "코드를 받지 못하셨나요? 재전송",
"Down": "중단",
"DOWN": "장애",
"Duration": "지속",
"Email address": "이메일 주소",
"Embed Monitor": "모니터 내장",
@@ -32,11 +39,14 @@
"Get notified about incidents and scheduled maintenance.": "사고 및 예정된 유지 관리에 대한 알림을 받으세요.",
"Get notified about incidents updates": "사건 업데이트에 대한 알림 받기",
"Get notified about scheduled maintenance": "예정된 유지 관리에 대한 알림 받기",
"IDENTIFIED": "IDENTIFIED",
"iFrame": "아이프레임",
"Impact": "영향",
"incident": "인시던트",
"Incident Updates": "사고 업데이트",
"Incidents": "사건",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "마지막 업데이트",
"Latency": "숨어 있음",
"Latency Embed": "지연 시간 포함",
@@ -47,32 +57,37 @@
"Light": "빛",
"Live Status": "실시간 현황",
"Loading your preferences...": "환경설정 로드 중...",
"maintenance": "유지보수",
"MAINTENANCE": "점검 중",
"Maintenance Updates": "유지보수 업데이트",
"Maintenances": "유지보수",
"Major System Outage": "대규모 시스템 장애",
"Manage Site": "사이트 관리",
"Manage your notification preferences.": "알림 기본 설정을 관리하세요.",
"Max Latency": "최대 대기 시간",
"Maximum Latency": "최대 대기 시간",
"Min Latency": "최소 지연 시간",
"Minimum Latency": "최소 지연 시간",
"Minute-by-minute status data for this day": "오늘의 분별 상태 데이터",
"MONITORING": "MONITORING",
"Network error. Please try again.": "네트워크 오류입니다. 다시 시도해 주세요.",
"No Events in %currentMonth": "%currentMonth에 이벤트가 없습니다.",
"No events to show": "No events to show",
"No events to show": "표시할 이벤트가 없습니다",
"No incidents for this day": "오늘은 사건이 없습니다.",
"No latency data available for this day": "이 날에는 지연 시간 데이터가 없습니다.",
"No maintenances for this day": "이날은 유지보수가 없습니다",
"No monitors affected": "영향을 받는 모니터 없음",
"No monitors available.": "No monitors available.",
"No monitors available.": "사용 가능한 모니터가 없습니다.",
"No ongoing maintenances": "지속적인 유지 관리가 필요하지 않습니다.",
"No past maintenances": "과거 유지보수 없음",
"No Status Available": "사용 가능한 상태 정보 없음",
"No upcoming maintenances": "예정된 유지 관리가 없습니다.",
"No Updates": "No Updates",
"No Updates": "업데이트 없음",
"No updates yet": "아직 업데이트가 없습니다",
"Notifications": "Notifications",
"Notifications": "알림",
"One-time": "일회성",
"Ongoing": "전진",
"Operational": "정상",
"Partial Degraded Performance": "부분적 성능 저하",
"Partial System Outage": "부분적 시스템 장애.",
"Past": "과거",
@@ -82,10 +97,14 @@
"Please enter the 6-digit verification code": "인증번호 6자리를 입력해주세요",
"Read less": "덜 읽으세요",
"Read more": "더 읽어보세요",
"Recurring": "Recurring",
"READY": "준비됨",
"Recurring": "반복",
"RESOLVED": "RESOLVED",
"SCHEDULED": "예정됨",
"Scheduled Events (%count)": "예정된 이벤트 (%count)",
"Script": "스크립트",
"Select Language": "언어 선택",
"Select latency metric to display": "Select latency metric to display",
"Select latency metric to display": "표시할 지연 시간 지표를 선택하세요",
"Select Range": "범위 선택",
"Sending...": "배상...",
"Standard": "기준",
@@ -97,13 +116,16 @@
"Subscribe": "구독하다",
"Subscribe to Updates": "업데이트 구독",
"There are no incidents or maintenances scheduled for this month.": "이번 달에는 예정된 사고나 점검이 없습니다.",
"There are no ongoing incidents or maintenance events.": "There are no ongoing incidents or maintenance events.",
"There are no ongoing incidents or maintenance events.": "진행 중인 인시던트 또는 유지보수 이벤트가 없습니다.",
"Total Incidents": "총 사고",
"Total Maintenances": "총 유지보수",
"Under Maintenance": "유지보수 중",
"Unknown impact": "알 수 없는 영향",
"UP": "정상",
"Upcoming": "예정",
"Updates": "Updates",
"Update Incident": "인시던트 업데이트",
"Update Maintenance": "유지보수 업데이트",
"Updates": "업데이트",
"Updates (%count)": "업데이트(%count)",
"Uptime": "가동 시간",
"Uptime Badge": "가동 시간 배지",

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