Compare commits

...

128 Commits

Author SHA1 Message Date
Raj Nandan Sharma 6764b470a9 Merge pull request #766 from CrazyComputer2008/Fix-Chinese-Translation
Fix Chinese Translation
2026-06-21 19:02:53 +05:30
Crazy Computer 9fc16187ca Add Traditional Chinese (Macau) locale file 2026-06-21 12:22:34 +08:00
Crazy Computer 32c36bfc52 Add Traditional Chinese (Hong Kong) localization 2026-06-21 12:20:58 +08:00
Crazy Computer 9ad4ca50fb Update Traditional Chinese translations in zh-TW.json 2026-06-21 12:17:54 +08:00
Crazy Computer 2aa83b01c2 Update Chinese translations in zh-CN.json 2026-06-21 12:14:00 +08:00
github-actions[bot] a25d823db8 chore(release): bump version to 4.1.1 2026-06-19 17:54:04 +00:00
Raj Nandan Sharma c43b4ef863 chore(docs): include v4.1.1 changelog with new features and improvements 2026-06-19 23:23:22 +05:30
Raj Nandan Sharma a9e06ae9f7 Merge pull request #765 from rajnandan1/implement/665
Implement/665
2026-06-19 22:39:38 +05:30
Raj Nandan Sharma a925791671 refactor(layout): update main class for embed and status pages 2026-06-19 22:34:03 +05:30
Raj Nandan Sharma ba1d0079de address PR #765 review comments
- layoutController/site-configurations: use strict boolean check instead of
  Boolean() coercion for showInlineEvents so persisted "false" strings don't
  flip the toggle
- (kener)/+page.svelte: collapse confusing triple negation !!! to single !
- NotificationsList: add aria-label/title to the icon-only events button
  (+ "Open events page" en locale key)
- move NotificationEvent into shared $lib/types/notifications so client code
  no longer imports from the server dashboardController; controller re-exports
  it for backwards compatibility
- [page_path] and monitor pages: pass hideNotificationsPopover={showInlineEvents}
  to ThemePlus so inline and popover event surfaces stay mutually exclusive

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:28:22 +05:30
Raj Nandan Sharma 3503e73f56 ensure newline at end of file in notifications-client.ts 2026-06-19 11:24:09 +05:30
Raj Nandan Sharma 5021e01018 implement event display settings and notifications list component
implements #665
2026-06-19 11:23:57 +05:30
Raj Nandan Sharma 95c341fc35 Merge pull request #764 from rajnandan1/fix/db-pool-web-worker-split
fix(database): isolate web and worker connection pools
2026-06-18 17:00:15 +05:30
Raj Nandan Sharma e69fcdfd71 fix(database): isolate web and worker connection pools
GET / was throwing KnexTimeoutError ("Timeout acquiring a connection")
in production. Root cause was the connection pool, not the database:
the single process (SvelteKit + cron scheduler + BullMQ workers) shared
one pool capped at 10, while one GET / fans out ~6 queries. A couple of
concurrent page loads, or a per-minute monitor burst overlapping a load,
exceeded 10 and queued acquires blew past the 15s timeout. Postgres
itself had 97 free slots the whole time and no leak.

Split into two pools so background work can't starve page loads:
- web pool (DATABASE_POOL_MAX, default 10) serves HTTP requests
- worker pool (DATABASE_WORKER_POOL_MAX, default 5) serves background jobs

Routing is by execution context via AsyncLocalStorage: q.createWorker
(the single chokepoint all workers/schedulers flow through) runs each
processor inside a worker-pool context, and BaseRepository.knex resolves
the pool from that context, defaulting to the web pool. This keeps shared
controllers correct whether they run in a request or a job. SQLite has no
real pool and reuses a single connection, so the split is a no-op there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:39:10 +05:30
Raj Nandan Sharma acf11459ed Merge pull request #763 from kensac/perf/monitoring-data-covering-index
perf: add covering index for monitor-bars status aggregation
2026-06-17 20:53:42 +05:30
Kanishk Sachdev 9ad315c3c7 perf: add covering index for monitor-bars status aggregation 2026-06-17 03:15:57 -04:00
Raj Nandan Sharma caf7427b04 Merge pull request #761 from rajnandan1/do/incident-start
refactor: update incident table to include start date and improve dat…
2026-06-16 09:17:01 +05:30
Raj Nandan Sharma 8833b7e410 refactor: update incident table to include start date and improve date formatting 2026-06-16 09:16:21 +05:30
Raj Nandan Sharma e43186d121 Merge pull request #760 from rajnandan1/fix/759
Fix/759
2026-06-15 23:24:12 +05:30
Raj Nandan Sharma 4536bceef6 refactor: improve incident management page structure and enhance data fetching logic 2026-06-15 23:22:25 +05:30
Raj Nandan Sharma 734b062626 refactor: update heartbeat URL format to use path segments and implement legacy URL rewriting 2026-06-15 23:19:24 +05:30
Raj Nandan Sharma f257cdc2c4 create heartbeat route handler for GET and POST requests 2026-06-15 23:19:17 +05:30
Raj Nandan Sharma 1362d06b20 Merge pull request #731 from Kukks/feat/rss-feed
Add public RSS 2.0 feed for incidents and scheduled maintenance
2026-06-15 10:30:59 +05:30
Raj Nandan Sharma 663ce52c4e Merge pull request #711 from phatlet/fix/add-redis-reconnect
Fix Redis writes after replica failover by reconnecting on `READONLY`
2026-06-15 09:48:37 +05:30
Raj Nandan Sharma 3f4df5faa7 fix(redis): use named RedisOptions type so npm run check passes
ioredis v5 dropped the namespace merge on the default export, so
`Redis.RedisOptions` resolves to TS2702 (type used as a namespace).
Import the `RedisOptions` type by name instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:43:12 +05:30
Raj Nandan Sharma 2501875406 refactor: update link to include rel="external" for better SEO 2026-06-14 11:55:30 +05:30
Raj Nandan Sharma 10586108c5 changes 2026-06-13 17:03:50 +00:00
Raj Nandan Sharma 87c69201ab Merge pull request #758 from rajnandan1/changelog/4.1.0
chore(docs): include v4.1.0 changelog and update navigation
2026-06-13 22:18:33 +05:30
Raj Nandan Sharma a3ec81af20 chore(docs): include v4.1.0 changelog and update navigation 2026-06-13 22:18:07 +05:30
Raj Nandan Sharma 61acf53c10 Merge pull request #757 from rajnandan1/implement/712
feat: Confirmation Threshold (grace period) — write-time damping with backfill + edge-case hardening (#755, #756)
2026-06-13 22:08:19 +05:30
Raj Nandan Sharma 8bbafe4c8a fix(confirmation): make unhealthy backfill atomic via a transaction (#757 review)
The per-row status+note backfill is one logical confirmation flip; wrap the
read+updates in a knex transaction so a mid-loop failure can't leave the window
half-confirmed/half-held (coderabbit out-of-diff finding).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 21:00:16 +05:30
Raj Nandan Sharma 4589568405 fix(confirmation): address PR #757 review comments
- PATCH: confirmation_threshold:null resets to 1 (off); undefined keeps existing (Copilot)
- backfill note is per-row severity-aware: 'Down'/'Degraded confirmed after N…' (Copilot)
- enforce 1–60 at the data layer via clampConfirmationThreshold on insert/update,
  covering all app write paths incl. the manage API (coderabbit)
- anchor via dedicated getLastObservedStatus query so a long incident/maintenance
  window can no longer push the anchor out of the lookback and bypass damping (coderabbit)
- overlays fetched AFTER execute() and keyed by job ts, making the freeze gate
  timestamp-safe and catching mid-check overlays (coderabbit + greptile)
- use Array.includes over indexOf!==-1 (greptile); refresh pendingHold doc (coderabbit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 20:57:18 +05:30
Raj Nandan Sharma e8fb4126a8 docs(v4): add Grace Period (Confirmation Threshold) monitor docs (#756)
New v4/monitors/grace-period.md covering behavior, config, API, interactions
(alerts/maintenance/NO_DATA/groups/heartbeat), and verification; linked from the
Monitors sidebar and the Monitors Overview related-docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 20:40:02 +05:30
Raj Nandan Sharma e9d3281067 chore: gitignore and untrack the whole docs/adr/ folder
Broaden the ignore from the single 0009 ADR to the entire docs/adr/ directory and
untrack the existing ADRs (0001-0008); files are kept on disk and remain in history.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 20:29:46 +05:30
Raj Nandan Sharma 7439632eff chore: stop tracking AI workflow docs; drop ADR references from code comments
gitignore + untrack CONTEXT.md, docs/adr/0009-*, and docs/superpowers/ (files kept
on disk). Remove the 'ADR 0009' citations from code comments; issue references and the
pre-existing ADR 0005 citations are retained.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 20:25:13 +05:30
Raj Nandan Sharma f0362fd919 feat(confirmation): preserve observed error on held rows; append confirmation note on backfill (#756)
Held (pending) rows now keep the real error text tagged '| Status held during
grace period' instead of dropping it, so no diagnostic info is lost. On confirmation
the backfill appends '| Down confirmed after N consecutive checks' to the existing
text (pipe-separated) rather than overwriting it; recovery clears the error. Append
is per-row for cross-DB safety and idempotent on replay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 20:20:40 +05:30
Raj Nandan Sharma e61873164b fix(confirmation): preserve real latency on grace-pending rows instead of zeroing (#756)
A pending (held) row was written with latency 0, losing the measured latency and
denting the latency chart during every grace window (and discarding a recovering
check's real latency). Keep the observed latency; only drop the error text so a
held row never shows a status-contradicting failure message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 20:14:30 +05:30
Raj Nandan Sharma 6d7b56a0ac feat(queues): freeze Confirmation Threshold during active overlays (#756)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 19:50:11 +05:30
Raj Nandan Sharma 0940c8d01e fix(confirmation): exclude NO_DATA from lookback to prevent stuck confirmation under neutral-dense history (#756)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 19:48:35 +05:30
Raj Nandan Sharma 17e3fa6d77 feat(services): NO_DATA-neutral counting and overlay freeze in resolver (#756)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 19:40:25 +05:30
Raj Nandan Sharma a363079695 feat(monitoring): widen confirmation lookback to include overlays + type (#756)
Replaces getRecentObservedSamples with getRecentSamplesForConfirmation, which adds
INCIDENT/MAINTENANCE overlay rows and the `type` column to the result set so the
Confirmation Threshold resolver can detect freeze boundaries. MANUAL and DEFAULT
rows remain excluded (transparent). Adds OVERLAY_TYPES constant alongside the
existing OBSERVED_CHECK_TYPES. Updates dbimpl.ts declaration and binding to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 19:37:27 +05:30
Raj Nandan Sharma 87fc3081df docs(confirmation-threshold): add #756 hardening implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 19:32:59 +05:30
Raj Nandan Sharma 350e291db0 feat(ui): add Grace period input to monitor general settings (#755)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 19:04:13 +05:30
Raj Nandan Sharma 9a545dbf48 feat(api): accept confirmation_threshold on v4 monitor create/update (#755)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 18:58:45 +05:30
Raj Nandan Sharma ab527ff7d8 feat(queues): apply Confirmation Threshold in write path and persist raw_status (#755)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 18:53:11 +05:30
Raj Nandan Sharma 850ebae11a feat(services): add Confirmation Threshold resolver with retroactive backfill (#755)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 18:50:31 +05:30
Raj Nandan Sharma a6948f087c feat(monitoring): persist raw_status; add observed-sample lookback and confirmed-status backfill (#755)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 18:42:17 +05:30
Raj Nandan Sharma aaa7c2a46d feat(monitors): persist confirmation_threshold on create/update/duplicate/group (#755)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 18:33:00 +05:30
Raj Nandan Sharma 8edf92ea02 feat(types): add confirmation_threshold and raw_status to monitor/monitoring types (#755)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 18:30:34 +05:30
Raj Nandan Sharma e5e7e44471 feat(db): add confirmation_threshold and raw_status columns (#755)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 18:29:06 +05:30
Raj Nandan Sharma f5ab338e2b docs(confirmation-threshold): add glossary term, ADR 0009, and implementation plan (#712)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 18:23:16 +05:30
Raj Nandan Sharma 5012ff1421 Merge pull request #754 from rajnandan1/fix/716
feat(api): DELETE /api/v4/monitors/{monitor_tag} + alert-config orphan fix
2026-06-12 15:25:51 +05:30
Raj Nandan Sharma 122ca71b8e fix(alerts): delete per-monitor v2 alert rows when detaching a monitor from shared configs
Review follow-up on #754: deleteMonitorAlertConfigsByMonitorTag removed
v2 alert rows only when the whole config died, so a shared config that
survived the detach kept monitor_alerts_v2 rows pointing at the deleted
monitor's tag. Verified red/green with an in-memory SQLite script:
the deleted tag's v2 rows now go with the detach while the surviving
monitors' rows are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:22:47 +05:30
Raj Nandan Sharma e27ab6ff7d feat(api): add DELETE /api/v4/monitors/{monitor_tag} and fix alert-config orphans on delete, fixes #716
Monitor deletion is now available via the v4 API, reusing the same
DeleteMonitorCompletelyUsingTag path as the manage UI. While wiring it
in, monitor deletion was found to orphan alert configs on SQLite:
the code relied on FK cascades that SQLite never enforces (the
foreign_keys pragma is off). Delete paths now remove child rows
explicitly — v2 alerts, trigger junctions, monitor junctions — in both
the by-id and by-tag config deletes; see ADR 0008 for why explicit
deletes were chosen over enabling the pragma.

Also corrects the CONTEXT.md Stale Member entry (deletion strips group
membership and rebalances weights; only pausing produces a stale
member), documents the DELETE endpoint in the OpenAPI spec, points the
pages doc at the ~home token, and removes an orphaned fictional
api-reference markdown page superseded by the spec tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:42:08 +05:30
Raj Nandan Sharma c301aaab90 Merge pull request #753 from rajnandan1/fix/717
refactor: unify overall status handling across components and improve…
2026-06-12 12:31:06 +05:30
Raj Nandan Sharma 4ed40a0b08 refactor: unify overall status handling across components and improve documentation, fixes #722 2026-06-12 12:21:43 +05:30
Raj Nandan Sharma 1ac0f2259f Merge pull request #752 from rajnandan1/fix/713
Render maintenance descriptions as sanitized HTML on list views
2026-06-12 00:27:07 +05:30
Raj Nandan Sharma 544bdc9dcb fix(maintenance): render description as sanitized markdown HTML on list views
Maintenance descriptions rendered as plain text in MaintenanceItem, so
HTML tags and line breaks showed literally on the home page while the
maintenance detail page rendered them correctly. Use the same
SveltePurify + mdToHTML prose pattern as the detail page and incident
comments.

Fixes #713

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:24:32 +05:30
Raj Nandan Sharma a843ac2926 Merge pull request #751 from rajnandan1/implement/722-1
Implement manual transitions for maintenance events to reflect actual…
2026-06-11 23:53:06 +05:30
Raj Nandan Sharma ccceeb38bd Implement manual transitions for maintenance events to reflect actual occurrences
- Introduce functionality to manually complete or cancel ongoing maintenance events.
- Update event status to COMPLETED or CANCELLED, adjusting end_date_time accordingly.
- Ensure terminal statuses prevent further modifications and notify subscribers of changes.
- Revise API to support status transitions alongside window edits, enforcing mutual exclusivity.
- Document behavior and consequences of manual transitions in ADR.
2026-06-11 22:52:09 +05:30
Raj Nandan Sharma b01560c29b Merge pull request #749 from rajnandan1/implement/721-lastknow
implement last known status retrieval in monitoring system
2026-06-11 07:47:52 +05:30
Raj Nandan Sharma 80c5e298d7 refactor(noneCall): ensure latency defaults to 0 when not provided 2026-06-11 07:41:37 +05:30
Raj Nandan Sharma 3d1335bf40 implement last known status retrieval in monitoring system 2026-06-10 23:34:50 +05:30
Andrew Camilleri 25c893ad86 Show global maintenances in RSS feed
A maintenance with is_global=YES (the default) has no per-monitor rows, so
the feed builder dropped it via the monitors.length===0 guard intended to
hide events whose monitors are all hidden. Global maintenances therefore
never appeared in /rss.xml, even though they show on the public events page.

Carry is_global through getMaintenanceEventsForEventsByDateRange and the
shared monitor-list grouping, then keep global events in the feed while still
dropping non-global ones whose monitors are all hidden. Global items render
"Affected: All monitors".
2026-06-09 21:47:57 +02:00
Andrew Camilleri a9f0437d17 Include scheduled maintenances in RSS feed + mirror toggle on Subscriptions page
Two fixes verified E2E with playwright before pushing:

1. The feed window bounded maintenances to (now - 90d, now), excluding
   any SCHEDULED maintenance with a future start_date_time. Split the
   range so incidents keep their past-only window but maintenances span
   (now - 90d, now + 90d), so subscribers learn about upcoming windows
   alongside historical ones. Incidents are still past-bounded.

2. Mirror the subMenuOptions.showRssFeed toggle on the Subscriptions
   admin page. RSS is a notification channel alongside email subs, so
   admins thinking about subscriber-facing surface area shouldn't have
   to bounce to Site Configurations to manage it. Same backing
   site_data key — toggling either place updates the other on reload.
   The feed routes (/rss.xml etc) stay reachable when the toggle is
   off; the toggle only controls the in-page icon button.
2026-06-09 17:28:09 +02:00
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
Andrew Camilleri 6e585f4608 Add visible RSS icon button with toggle in Sub Menu Options
ThemePlus now renders a small Rss icon-button next to Subscribe / share
controls, scoped to the current view (monitor / page / default) and
opening the feed in a new tab. Gated on subMenuOptions.showRssFeed
(defaults true) so admins can hide it via Manage → Site Configurations →
Monitor Sub Menu Options without losing the underlying feed routes.

Adds RSS feed key to all 22 locale files (untranslated; RSS is a tech term).
2026-05-28 16:10:03 +02:00
Andrew Camilleri f4d01c7c37 Add per-monitor RSS feed and head autodiscovery link
New route GET /monitors/{monitor_tag}/rss.xml serves a feed scoped to a
single monitor (404s for hidden, inactive, or unknown monitors). Existing
page-scoped routes are unchanged in URL; renderRssFeedResponse now takes
a discriminated scope arg so all three routes share one handler.

The (kener) layout emits a <link rel="alternate" type="application/rss+xml">
in <head>, scoped to the current view: monitor page -> monitor feed,
named status page -> page feed, otherwise default. Lets feed readers and
browsers discover the feed automatically. Title attribute is hardcoded
(machine-facing) so no locale files are touched.
2026-05-28 15:47:48 +02:00
Andrew Camilleri 546118a725 Add public RSS 2.0 feed for incidents and scheduled maintenance
Exposes /rss.xml (default page) and /{page_path}/rss.xml (named pages).
Items inherit visibility from the existing events-by-month data path:
hidden monitors are stripped, KENER_BASE_PATH is honored in absolute
links, and forceExclusivity is respected on the default route.

Feed window: last 90 days, 50 most-recent items, sorted desc.
Response: application/rss+xml; charset=utf-8, Cache-Control max-age=300.
404 on unknown page_path or when siteURL is not configured.

Adds a Core Concepts doc page covering URLs, item shape, and verification.
No user-facing UI text; i18n locales untouched.
2026-05-28 14:15:46 +02:00
phatlet e53a577174 Adding TCP keepalive and expanding reconnectOnError 2026-05-06 18:16:58 +07:00
phatle-qualgo 45c0e9a1e6 feat: add redis reconnect 2026-04-14 01:23:05 +07: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
174 changed files with 8068 additions and 2898 deletions
+6 -1
View File
@@ -34,4 +34,9 @@ temp.js
.DS_Store
knip-output.txt
check-output.txt
translation-report.json
translation-report.json
# AI workflow docs (not version-controlled)
CONTEXT.md
docs/adr/
docs/superpowers/
+14
View File
@@ -121,3 +121,17 @@ 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`.
+1
View File
@@ -163,6 +163,7 @@ 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)
+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.
+74 -3
View File
@@ -7,13 +7,63 @@ 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 .../setup/database-setup.md.
const keepAliveEnabled = process.env.DATABASE_KEEPALIVE !== "false";
interface PoolConfig {
min: number;
max: number;
idleTimeoutMillis: number;
createTimeoutMillis: number;
}
// Two pools share one process (Postgres/MySQL only): the WEB pool serves
// SvelteKit requests; the WORKER pool serves background jobs (BullMQ workers +
// schedulers, routed via src/lib/server/db/poolContext.ts). Isolating them
// stops a burst of background jobs from exhausting the connections that serve
// page loads. Budget across both pools: replicas * (web + worker) must stay
// under the database's max_connections. SQLite has no real pool and reuses a
// single connection, so the split does not apply there.
//
// 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 idleTimeoutMillis = intFromEnv("DATABASE_IDLE_TIMEOUT_MS", 30000);
const createTimeoutMillis = intFromEnv("DATABASE_CREATE_TIMEOUT_MS", 15000);
const poolMin = intFromEnv("DATABASE_POOL_MIN", 0);
const buildPool = (max: number): PoolConfig => ({
min: Math.min(poolMin, max),
max,
idleTimeoutMillis,
createTimeoutMillis,
});
const webPool = buildPool(Math.max(1, intFromEnv("DATABASE_POOL_MAX", 10)));
const workerPool = buildPool(Math.max(1, intFromEnv("DATABASE_WORKER_POOL_MAX", 5)));
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?: PoolConfig;
acquireConnectionTimeout?: number;
}
const knexOb: KnexConfig = {
@@ -25,6 +75,13 @@ const knexOb: KnexConfig = {
},
databaseType,
};
// Worker pool config for Postgres/MySQL — same connection as the web config,
// but with the worker pool. Stays null for SQLite (single shared connection),
// in which case the app reuses the web instance for background work too.
let workerKnexOb: KnexConfig | null = null;
console.log(`Configuring database with type ${databaseType}`);
if (databaseType === "sqlite") {
knexOb.client = "better-sqlite3";
knexOb.connection = {
@@ -33,13 +90,27 @@ if (databaseType === "sqlite") {
knexOb.useNullAsDefault = true;
} else if (databaseType === "postgresql") {
knexOb.client = "pg";
knexOb.connection = databaseURL;
knexOb.connection = {
connectionString: databaseURL,
keepAlive: keepAliveEnabled,
};
knexOb.pool = webPool;
knexOb.acquireConnectionTimeout = acquireConnectionTimeout;
workerKnexOb = { ...knexOb, pool: workerPool };
} else if (databaseType === "mysql") {
knexOb.client = "mysql2";
knexOb.connection = databaseURL;
knexOb.connection = {
uri: databaseURL,
enableKeepAlive: keepAliveEnabled,
keepAliveInitialDelay: 10000,
};
knexOb.pool = webPool;
knexOb.acquireConnectionTimeout = acquireConnectionTimeout;
workerKnexOb = { ...knexOb, pool: workerPool };
} else {
console.error("Invalid database type");
process.exit(1);
}
export { workerKnexOb };
export default knexOb;
@@ -97,7 +97,51 @@ export async function up(knex: Knex): Promise<void> {
const dbClient = knex.client.config.client;
if (dbClient === "sqlite3" || dbClient === "better-sqlite3") {
await knex("monitor_alerts_config").update({ monitor_tag: null });
// 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) => {
@@ -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,27 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn("monitors", "confirmation_threshold"))) {
await knex.schema.alterTable("monitors", (table) => {
table.integer("confirmation_threshold").unsigned().notNullable().defaultTo(1);
});
}
if (!(await knex.schema.hasColumn("monitoring_data", "raw_status"))) {
await knex.schema.alterTable("monitoring_data", (table) => {
table.text("raw_status").nullable();
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn("monitors", "confirmation_threshold")) {
await knex.schema.alterTable("monitors", (table) => {
table.dropColumn("confirmation_threshold");
});
}
if (await knex.schema.hasColumn("monitoring_data", "raw_status")) {
await knex.schema.alterTable("monitoring_data", (table) => {
table.dropColumn("raw_status");
});
}
}
@@ -0,0 +1,28 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Covering index for the grouped status-count aggregation used by the
// monitor-bars dashboard endpoint. The query filters by
// (monitor_tag, timestamp range) and reads status + latency for every row.
// Including status and latency in the index lets the database satisfy the
// query from the index alone, avoiding a heap lookup per matched row.
try {
await knex.schema.alterTable("monitoring_data", (table) => {
table.index(
["monitor_tag", "timestamp", "status", "latency"],
"idx_monitoring_data_monitor_tag_timestamp_status_latency",
);
});
} catch (_e) {
/* index already exists */
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable("monitoring_data", (table) => {
table.dropIndex(
["monitor_tag", "timestamp", "status", "latency"],
"idx_monitoring_data_monitor_tag_timestamp_status_latency",
);
});
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "kener",
"version": "4.0.21",
"version": "4.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kener",
"version": "4.0.21",
"version": "4.1.1",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kener",
"version": "4.0.21",
"version": "4.1.1",
"type": "module",
"private": false,
"license": "MIT",
+110 -70
View File
@@ -6,6 +6,7 @@ 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";
@@ -13,89 +14,128 @@ const PORT = process.env.PORT || 3000;
const base = process.env.KENER_BASE_PATH || "";
async function start() {
// Dynamic import so BODY_SIZE_LIMIT from .env is available
// before the handler reads it at module top-level
const { handler } = await import("../build/handler.js");
// Dynamic import so BODY_SIZE_LIMIT from .env is available
// before the handler reads it at module top-level
const { handler } = await import("../build/handler.js");
const app: any = express();
const db = knex(knexOb);
const app: any = express();
const db = knex(knexOb);
app.get(base + "/healthcheck", (req: any, res: any) => {
res.end("ok");
});
// 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);
}
};
app.use(handler);
// 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,
});
});
//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}`);
}
}
app.use(handler);
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);
}
}
//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}`);
}
}
//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);
}
}
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);
}
}
app.listen(PORT, async () => {
await runMigrations();
await runSeed();
await db.destroy();
Startup();
console.log("Kener is running on port " + PORT + "!");
});
//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);
}
}
// Graceful shutdown handler
async function gracefulShutdown(signal: string) {
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
app.listen(PORT, async () => {
await runMigrations();
await runSeed();
await db.destroy();
Startup();
console.log("Kener is running on port " + PORT + "!");
});
try {
console.log("Shutting down schedulers...");
await shutdownSchedulers();
console.log("Schedulers shut down successfully.");
// Graceful shutdown handler
async function gracefulShutdown(signal: string) {
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
console.log("Shutting down queues...");
await shutdownQueues();
console.log("Queues shut down successfully.");
try {
console.log("Shutting down schedulers...");
await shutdownSchedulers();
console.log("Schedulers shut down successfully.");
console.log("Closing database connection...");
await dbInstance.close();
console.log("Database connection closed successfully.");
console.log("Shutting down queues...");
await shutdownQueues();
console.log("Queues shut down successfully.");
console.log("Graceful shutdown completed.");
process.exit(0);
} catch (err) {
console.error("Error during graceful shutdown:", err);
process.exit(1);
}
}
console.log("Closing database connection...");
await dbInstance.close();
console.log("Database connection closed successfully.");
// Handle termination signals
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
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();
+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(),
});
}
}
}
}
+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>
+17 -1
View File
@@ -4,6 +4,7 @@ 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/";
@@ -119,6 +120,18 @@ const apiAuthHandle: 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) {
@@ -173,7 +186,10 @@ const apiAuthHandle: 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: {
+22
View File
@@ -0,0 +1,22 @@
import type { Reroute } from "@sveltejs/kit";
// Back-compat for issue #759: heartbeat URLs used to be `/ext/heartbeat/<tag>:<secret>`,
// one path segment joined by a colon. A `:` is illegal in Windows file paths, so the
// route is now `/ext/heartbeat/<tag>/<secret>` (two segments). Legacy colon-form URLs
// live forever in external cron jobs / uptime pingers, so rewrite them internally to
// the new path. Returns a 200 (no redirect) — heartbeat clients often don't follow 3xx.
//
// `reroute` is a *universal* hook: it MUST be in src/hooks.ts. A `reroute` exported from
// src/hooks.server.ts is silently ignored by SvelteKit. Keep this file free of
// server-only imports — it is bundled for the client too. Must stay pure/side-effect-free.
//
// The transform is in-place (no path reconstruction), so any KENER_BASE_PATH prefix is
// preserved automatically. `[^/:]+` matches the validated tag charset; only the first
// colon after `/ext/heartbeat/<tag>` is rewritten.
const LEGACY_HEARTBEAT = /(\/ext\/heartbeat\/[^/:]+):/;
export const reroute: Reroute = ({ url }) => {
if (LEGACY_HEARTBEAT.test(url.pathname)) {
return url.pathname.replace(LEGACY_HEARTBEAT, "$1/");
}
};
+275
View File
@@ -0,0 +1,275 @@
/**
* 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, updateMaintenanceEventStatus, 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",
updateMaintenanceEventStatus: "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",
};
+19
View File
@@ -0,0 +1,19 @@
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
import type { NotificationEvent } from "$lib/types/notifications.js";
interface NotificationsResponse {
notifications?: NotificationEvent[];
}
export async function requestNotifications(monitorTags: string[] = []): Promise<NotificationEvent[]> {
const query = monitorTags.length > 0 ? `?tags=${encodeURIComponent(monitorTags.join(","))}` : "";
const response = await fetch(clientResolver(resolve, "/dashboard-apis/notifications") + query);
if (!response.ok) {
throw new Error("Failed to fetch notifications");
}
const payload = (await response.json()) as NotificationsResponse;
return payload.notifications || [];
}
+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);
}
+42 -34
View File
@@ -1,5 +1,5 @@
import type { TimestampStatusCount } from "$lib/server/types/db";
import { PAGE_STATUS_MESSAGES } from "$lib/global-constants";
import GC, { PAGE_STATUS_MESSAGES, type StatusType } from "$lib/global-constants";
function ParseLatency(latencyMs: number): string {
if (!!!latencyMs) {
@@ -316,46 +316,53 @@ interface GameItem {
function GetGameFromId(list: GameItem[], id: string): GameItem | undefined {
return list.find((game: GameItem) => game.id === id);
}
type StatusCounts = Pick<TimestampStatusCount, "countOfUp" | "countOfDown" | "countOfDegraded" | "countOfMaintenance">;
// Canonical Overall Status collapse: the worst state wins, and maintenance
// never masks an active problem. See docs/adr/0007-problem-first-overall-status.md.
function CollapseStatusCounts(counts: StatusCounts): StatusType {
const total = counts.countOfUp + counts.countOfDown + counts.countOfDegraded + counts.countOfMaintenance;
if (total === 0) return GC.NO_DATA;
if (counts.countOfDown > 0) return GC.DOWN;
if (counts.countOfDegraded > 0) return GC.DEGRADED;
if (counts.countOfMaintenance > 0) return GC.MAINTENANCE;
return GC.UP;
}
function GetStatusSummary(item: TimestampStatusCount): string {
const total = item.countOfUp + item.countOfDown + item.countOfDegraded + item.countOfMaintenance;
if (total === 0) return PAGE_STATUS_MESSAGES.NO_DATA;
const maintenancePercent = (item.countOfMaintenance / total) * 100;
const downPercent = (item.countOfDown / total) * 100;
const degradedPercent = (item.countOfDegraded / total) * 100;
if (maintenancePercent > 0) {
return PAGE_STATUS_MESSAGES.UNDER_MAINTENANCE;
} else if (downPercent >= 75) {
return PAGE_STATUS_MESSAGES.MAJOR_OUTAGE;
} else if (downPercent >= 50) {
return PAGE_STATUS_MESSAGES.PARTIAL_OUTAGE;
} else if (item.countOfDown > 0) {
return PAGE_STATUS_MESSAGES.PARTIAL_OUTAGE;
} else if (degradedPercent >= 75) {
return PAGE_STATUS_MESSAGES.DEGRADED_PERFORMANCE;
} else if (degradedPercent >= 50) {
return PAGE_STATUS_MESSAGES.PARTIAL_DEGRADED;
} else if (item.countOfDegraded > 0) {
return PAGE_STATUS_MESSAGES.PARTIAL_DEGRADED;
} else if (item.countOfUp === total) {
return PAGE_STATUS_MESSAGES.ALL_OPERATIONAL;
switch (CollapseStatusCounts(item)) {
case GC.DOWN:
return (item.countOfDown / total) * 100 >= 75
? PAGE_STATUS_MESSAGES.MAJOR_OUTAGE
: PAGE_STATUS_MESSAGES.PARTIAL_OUTAGE;
case GC.DEGRADED:
return (item.countOfDegraded / total) * 100 >= 75
? PAGE_STATUS_MESSAGES.DEGRADED_PERFORMANCE
: PAGE_STATUS_MESSAGES.PARTIAL_DEGRADED;
case GC.MAINTENANCE:
return PAGE_STATUS_MESSAGES.UNDER_MAINTENANCE;
case GC.UP:
return PAGE_STATUS_MESSAGES.ALL_OPERATIONAL;
default:
return PAGE_STATUS_MESSAGES.NO_DATA;
}
return PAGE_STATUS_MESSAGES.NO_DATA;
}
function GetStatusColor(item: TimestampStatusCount): string {
const total = item.countOfUp + item.countOfDown + item.countOfDegraded + item.countOfMaintenance;
if (total === 0) return "text-muted-foreground";
const maintenancePercent = (item.countOfMaintenance / total) * 100;
const downPercent = (item.countOfDown / total) * 100;
if (maintenancePercent > 0) return "text-maintenance";
if (downPercent > 0) return "text-down";
if (item.countOfDegraded > 0) return "text-degraded";
return "text-up";
switch (CollapseStatusCounts(item)) {
case GC.DOWN:
return "text-down";
case GC.DEGRADED:
return "text-degraded";
case GC.MAINTENANCE:
return "text-maintenance";
case GC.UP:
return "text-up";
default:
return "text-muted-foreground";
}
}
function GetStatusBgColor(item: TimestampStatusCount): string {
@@ -378,6 +385,7 @@ export {
IsValidNameServer,
IsValidURL,
IsValidPort,
CollapseStatusCounts,
GetStatusSummary,
GetStatusColor,
GetStatusBgColor,
+7 -3
View File
@@ -12,6 +12,8 @@
import clientResolver from "$lib/client/resolver.js";
import { GetInitials } from "$lib/clientTools.js";
import type { MaintenanceEventsMonitorList } from "$lib/server/types/db";
import { SveltePurify } from "@humanspeak/svelte-purify";
import mdToHTML from "$lib/marked";
import { page } from "$app/state";
interface Props {
@@ -42,9 +44,11 @@
</div>
{#if maintenance.description}
<p class="text-muted-foreground mt-1 text-sm">
{maintenance.description}
</p>
<div
class="prose prose-sm dark:prose-invert text-muted-foreground mt-1 max-w-none min-w-0 overflow-x-auto text-sm wrap-break-word"
>
<SveltePurify html={mdToHTML(maintenance.description)} />
</div>
{/if}
{#if maintenance.monitors && maintenance.monitors.length > 0 && !hideMonitors}
+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>
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { page } from "$app/state";
import { requestNotifications } from "$lib/client/notifications-client.js";
import clientResolver from "$lib/client/resolver.js";
import { Button } from "$lib/components/ui/button/index.js";
import { formatDate, formatDuration } from "$lib/stores/datetime";
import { t } from "$lib/stores/i18n";
import type { NotificationEvent } from "$lib/types/notifications.js";
import Calendar from "@lucide/svelte/icons/calendar-1";
import { format } from "date-fns";
import { onMount } from "svelte";
interface Props {
monitorTags?: string[];
eventsPath?: string;
notifications?: NotificationEvent[];
loading?: boolean;
fetchOnMount?: boolean;
}
let {
monitorTags = [],
eventsPath = "",
notifications = $bindable<NotificationEvent[]>([]),
loading = $bindable(false),
fetchOnMount = true
}: Props = $props();
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 {
notifications = await requestNotifications(monitorTags);
} catch {
// silently fail
} finally {
loading = false;
}
}
onMount(() => {
if (fetchOnMount) {
void fetchNotifications();
}
});
function getEventId(eventURL: string) {
return eventURL.split("/").filter(Boolean).at(-1) || "";
}
</script>
{#snippet notificationItem(item: NotificationEvent)}
<div class="my-0.5 flex items-center justify-between gap-2 text-xs">
<span class="text-muted-foreground text-[11px] uppercase">{$t(item.eventType)}</span>
<span class="text-{item.eventStatus.toLowerCase()}">{$t(item.eventStatus)}</span>
</div>
<div class="flex items-start justify-between gap-2">
<p class="line-clamp-2 text-sm">{item.eventTitle}</p>
</div>
<div class="text-muted-foreground mt-1 flex flex-wrap items-center gap-2 text-xs">
<span>{$formatDate(item.eventDate, page.data.dateAndTimeFormat.datePlusTime)}</span>
<span></span>
<span>{$formatDuration(item.eventStartDateTime, item.eventEndDateTime, $t("Ongoing"))}</span>
</div>
{/snippet}
<div class="flex items-center justify-between border-b px-4 py-3">
<h4 class="text-sm font-semibold">{$t("Events")}</h4>
<Button
variant="outline"
href={clientResolver(resolve, resolvedEventsPath)}
size="icon-sm"
class="rounded-btn"
aria-label={$t("Open events page")}
title={$t("Open events page")}
>
<Calendar class="size-4" />
</Button>
</div>
{#if notifications.length === 0}
<div class="text-muted-foreground px-4 py-6 text-center text-sm">
{$t("No events to show")}
</div>
{:else}
<div class="scrollbar-hidden max-h-96 overflow-y-auto">
{#each notifications as item, i (`${item.eventType}-${item.eventURL}-${item.eventDate}-${i}`)}
{@const eventId = getEventId(item.eventURL)}
{#if item.eventURL.startsWith("/incidents/")}
<a
href={resolve("/(kener)/incidents/[incident_id]", { incident_id: eventId })}
class="hover:bg-muted/60 block border-b px-4 py-3 last:border-b-0"
>
{@render notificationItem(item)}
</a>
{:else}
<a
href={resolve("/(kener)/maintenances/[maintenance_id]", { maintenance_id: eventId })}
class="hover:bg-muted/60 block border-b px-4 py-3 last:border-b-0"
>
{@render notificationItem(item)}
</a>
{/if}
{/each}
</div>
{/if}
+7 -61
View File
@@ -1,22 +1,18 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { page } from "$app/state";
import NotificationsList from "$lib/components/NotificationsList.svelte";
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 { requestNotifications } from "$lib/client/notifications-client.js";
import ICONS from "$lib/icons";
import clientResolver from "$lib/client/resolver.js";
import { formatDate, formatDuration } from "$lib/stores/datetime";
import { t } from "$lib/stores/i18n";
import type { NotificationEvent } from "$lib/server/controllers/dashboardController.js";
import Calendar from "@lucide/svelte/icons/calendar-1";
import { format } from "date-fns";
import type { NotificationEvent } from "$lib/types/notifications.js";
import { onMount } from "svelte";
interface Props {
monitorTags?: string[];
compact?: boolean;
eventsPath: string;
eventsPath?: string;
}
let { monitorTags = [], compact = true, eventsPath = "" }: Props = $props();
@@ -24,26 +20,10 @@
let notifications = $state<NotificationEvent[]>([]);
let loading = $state(false);
const defaultEventsPath = $derived(`/events/${format(new Date(), "MMMM-yyyy")}`);
const resolvedEventsPath = $derived.by(() => {
const finalEventsPath = eventsPath || defaultEventsPath;
if (page.data?.globalPageVisibilitySettings?.forceExclusivity) {
const currentPagePath = page.params?.page_path?.trim();
return currentPagePath ? `/${currentPagePath}${finalEventsPath}` : finalEventsPath;
}
return finalEventsPath;
});
async function fetchNotifications() {
loading = true;
try {
const query = monitorTags.length > 0 ? `?tags=${encodeURIComponent(monitorTags.join(","))}` : "";
const response = await fetch(clientResolver(resolve, "/dashboard-apis/notifications") + query);
if (response.ok) {
const payload = await response.json();
notifications = payload.notifications || [];
}
notifications = await requestNotifications(monitorTags);
} catch {
// silently fail
} finally {
@@ -52,7 +32,7 @@
}
onMount(() => {
fetchNotifications();
void fetchNotifications();
});
</script>
@@ -87,40 +67,6 @@
class="bg-background/30 supports-backdrop-filter:bg-background/20 w-96 rounded-3xl border p-0 shadow-2xl backdrop-blur-2xl"
sideOffset={8}
>
<div class="flex items-center justify-between border-b px-4 py-3">
<h4 class="text-sm font-semibold">{$t("Events")}</h4>
<Button variant="outline" href={clientResolver(resolve, resolvedEventsPath)} size="icon-sm" class="rounded-btn">
<Calendar class="size-4" />
</Button>
</div>
{#if notifications.length === 0}
<div class="text-muted-foreground px-4 py-6 text-sm">
{$t("No events to show")}
</div>
{:else}
<div class="scrollbar-hidden max-h-96 overflow-y-auto">
{#each notifications as item, i (`${item.eventType}-${item.eventURL}-${item.eventDate}-${i}`)}
<a
href={clientResolver(resolve, item.eventURL)}
class="hover:bg-muted/60 block border-b px-4 py-3 last:border-b-0"
>
<div class="my-0.5 flex items-center justify-between gap-2 text-xs">
<span class="text-muted-foreground text-[11px] uppercase">{$t(item.eventType)}</span>
<span class="text-{item.eventStatus.toLowerCase()}">{$t(item.eventStatus)}</span>
</div>
<div class="flex items-start justify-between gap-2">
<p class="line-clamp-2 text-sm">{item.eventTitle}</p>
</div>
<div class="text-muted-foreground mt-1 flex flex-wrap items-center gap-2 text-xs">
<span>{$formatDate(item.eventDate, page.data.dateAndTimeFormat.datePlusTime)}</span>
<span></span>
<span>{$formatDuration(item.eventStartDateTime, item.eventEndDateTime, $t("Ongoing"))}</span>
</div>
</a>
{/each}
</div>
{/if}
<NotificationsList {monitorTags} {eventsPath} fetchOnMount={false} bind:notifications bind:loading />
</Popover.Content>
</Popover.Root>
+32 -2
View File
@@ -10,6 +10,7 @@
import Sun from "@lucide/svelte/icons/sun";
import Moon from "@lucide/svelte/icons/moon";
import Share from "@lucide/svelte/icons/share-2";
import Rss from "@lucide/svelte/icons/rss";
import { format } from "date-fns";
import SubscribeMenu from "$lib/components/SubscribeMenu.svelte";
import CopyButton from "$lib/components/CopyButton.svelte";
@@ -26,14 +27,23 @@
interface Props {
monitor_tags?: string[];
embedMonitorTag?: string;
hideNotificationsPopover?: boolean;
}
let { monitor_tags = [], embedMonitorTag = "" }: Props = $props();
let { monitor_tags = [], embedMonitorTag = "", hideNotificationsPopover = false }: Props = $props();
let protocol = $state("");
let domain = $state("");
let shareLink = $state("");
const eventsPath = $derived(`/events/${format(new Date(), "MMMM-yyyy")}`);
const showNotificationsPopover = $derived(!hideNotificationsPopover);
const rssHref = $derived.by(() => {
const params = page.params;
if (params.monitor_tag) return clientResolver(resolve, `/monitors/${params.monitor_tag}/rss.xml`);
if (params.page_path) return clientResolver(resolve, `/${params.page_path}/rss.xml`);
return clientResolver(resolve, "/rss.xml");
});
const loginDetails = $derived.by((): { label: string; url: string } | null => {
if (!page.data?.loggedInUser) return null;
@@ -95,6 +105,24 @@
</ButtonGroup.Root>
{/if}
{#if page.data.subMenuOptions?.showRssFeed !== false}
<ButtonGroup.Root class="rounded-btn-grp shrink-0">
<Button
variant="outline"
size="icon-sm"
href={rssHref}
target="_blank"
rel="alternate"
aria-label={$t("RSS feed")}
title={$t("RSS feed")}
class="bg-background/80 dark:bg-background/70 border-foreground/10 cursor-pointer rounded-full border shadow-none backdrop-blur-md"
onclick={() => trackEvent("rss_opened", { source: "theme_plus" })}
>
<Rss />
</Button>
</ButtonGroup.Root>
{/if}
<ButtonGroup.Root class="rounded-btn-grp shrink-0">
<CopyButton
variant="outline"
@@ -157,7 +185,9 @@
<TimezoneSelector />
{/if}
</ButtonGroup.Root>
<NotificationsPopover {eventsPath} monitorTags={monitor_tags} compact={true} />
{#if showNotificationsPopover}
<NotificationsPopover {eventsPath} monitorTags={monitor_tags} compact={true} />
{/if}
{#if loginDetails}
<Button
size="sm"
+12
View File
@@ -69,6 +69,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,
+1
View File
@@ -116,6 +116,7 @@
"Recent Incidents": "Nedávné incidenty",
"Recurring": "Opakující se",
"RESOLVED": "VYŘEŠENO",
"RSS feed": "RSS feed",
"SCHEDULED": "NAPLÁNOVÁNO",
"Scheduled Events (%count)": "Plánované události (%count)",
"Scheduled Windows": "Naplánované úlohy",
+23 -22
View File
@@ -5,12 +5,12 @@
"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",
"Badges": "Anzeigen",
"CANCELLED": "ABGESAGT",
"COMPLETED": "ABGESCHLOSSEN",
"Continue": "Weitermachen",
"Continue": "Fortsetzen",
"Copied": "Kopiert",
"Current": "Aktuell",
"Dark": "Dunkel",
@@ -20,7 +20,7 @@
"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",
@@ -29,13 +29,13 @@
"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",
@@ -43,7 +43,7 @@
"iFrame": "iFrame",
"Impact": "Auswirkungen",
"incident": "Vorfall",
"Incident Updates": "Vorfallaktualisierungen",
"Incident Updates": "Vorfallsaktualisierungen",
"Incidents": "Vorfälle",
"Included Monitors (%count)": "Enthaltene Monitore (%count)",
"INVESTIGATING": "WIRD UNTERSUCHT",
@@ -64,13 +64,13 @@
"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",
"MONITORING": "WIRD ÜBERWACHT",
"Network error. Please try again.": "Netzwerkfehler. ",
"Network error. Please try again.": "Netzwerkfehler. Bitte erneut versuchen.",
"No Events in %currentMonth": "Keine Ereignisse in %currentMonth",
"No events to show": "Keine Ereignisse zum Anzeigen",
"No incidents for this day": "Keine Vorfälle für diesen Tag",
@@ -83,7 +83,7 @@
"No Status Available": "Kein Status verfügbar",
"No upcoming maintenances": "Keine bevorstehenden Wartungsarbeiten",
"No Updates": "Keine Aktualisierungen",
"No updates yet": "Noch keine Updates",
"No updates yet": "Noch keine Aktualisierungen",
"Notifications": "Benachrichtigungen",
"One-time": "Einmalig",
"Ongoing": "Laufend",
@@ -100,38 +100,39 @@
"READY": "BEREIT",
"Recurring": "Wiederkehrend",
"RESOLVED": "BEHOBEN",
"RSS feed": "RSS feed",
"SCHEDULED": "GEPLANT",
"Scheduled Events (%count)": "Geplante Ereignisse (%count)",
"Script": "Skript",
"Select Language": "Wählen Sie Sprache aus",
"Select latency metric to display": "Latenzmetrik zur Anzeige auswählen",
"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": "Benachrichtigungen erhalten",
"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.": "Es gibt keine laufenden Vorfälle oder Wartungsereignisse.",
"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",
"UP": "AKTIV",
"Upcoming": "Demnächst",
"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"
}
}
+1
View File
@@ -100,6 +100,7 @@
"READY": "KLAR",
"Recurring": "Tilbagevendende",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "PLANLAGT",
"Scheduled Events (%count)": "Planlagte begivenheder (%count)",
"Script": "Manuskript",
+2
View File
@@ -87,6 +87,7 @@
"Notifications": "Notifications",
"One-time": "One-time",
"Ongoing": "Ongoing",
"Open events page": "Open events page",
"Operational": "Operational",
"Partial Degraded Performance": "Partial Degraded Performance",
"Partial System Outage": "Partial System Outage",
@@ -100,6 +101,7 @@
"READY": "READY",
"Recurring": "Recurring",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "SCHEDULED",
"Scheduled Events (%count)": "Scheduled Events (%count)",
"Script": "Script",
+1
View File
@@ -100,6 +100,7 @@
"READY": "LISTO",
"Recurring": "Recurrente",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "PROGRAMADO",
"Scheduled Events (%count)": "Eventos programados (%count)",
"Script": "Guion",
+1
View File
@@ -100,6 +100,7 @@
"READY": "آماده",
"Recurring": "دوره‌ای",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "زمان‌بندی شده",
"Scheduled Events (%count)": "رویدادهای زمان‌بندی شده (%count)",
"Script": "اسکریپت",
+5 -4
View File
@@ -3,11 +3,11 @@
"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",
@@ -93,13 +93,14 @@
"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",
"READY": "PRÊT",
"Recurring": "Récurrent",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "PLANIFIÉ",
"Scheduled Events (%count)": "Événements planifiés (%count)",
"Script": "Scénario",
+1
View File
@@ -100,6 +100,7 @@
"READY": "तैयार",
"Recurring": "आवर्ती",
"RESOLVED": "सुलझा हुआ",
"RSS feed": "RSS feed",
"SCHEDULED": "अनुसूचित",
"Scheduled Events (%count)": "अनुसूचित कार्यक्रम (%count)",
"Script": "स्क्रिप्ट",
+1
View File
@@ -100,6 +100,7 @@
"READY": "PRONTO",
"Recurring": "Ricorrente",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "PROGRAMMATO",
"Scheduled Events (%count)": "Eventi programmati (%count)",
"Script": "Copione",
+1
View File
@@ -100,6 +100,7 @@
"READY": "準備完了",
"Recurring": "繰り返し",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "予定済み",
"Scheduled Events (%count)": "予定イベント (%count)",
"Script": "スクリプト",
+1
View File
@@ -100,6 +100,7 @@
"READY": "준비됨",
"Recurring": "반복",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "예정됨",
"Scheduled Events (%count)": "예정된 이벤트 (%count)",
"Script": "스크립트",
+1
View File
@@ -100,6 +100,7 @@
"READY": "KLAR",
"Recurring": "Gjentakende",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "PLANLAGT",
"Scheduled Events (%count)": "Planlagte hendelser (%count)",
"Script": "Manus",
+1
View File
@@ -100,6 +100,7 @@
"READY": "GEREED",
"Recurring": "Terugkerend",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "GEPLAND",
"Scheduled Events (%count)": "Geplande evenementen (%count)",
"Script": "Script",
+1
View File
@@ -100,6 +100,7 @@
"READY": "GOTOWE",
"Recurring": "Cykliczne",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "ZAPLANOWANE",
"Scheduled Events (%count)": "Zaplanowane wydarzenia (%count)",
"Script": "Scenariusz",
+1
View File
@@ -100,6 +100,7 @@
"READY": "PRONTO",
"Recurring": "Recorrente",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "AGENDADO",
"Scheduled Events (%count)": "Eventos agendados (%count)",
"Script": "Roteiro",
+1
View File
@@ -101,6 +101,7 @@
"READY": "ГОТОВО",
"Recurring": "Повторяющийся",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "ЗАПЛАНИРОВАНО",
"Scheduled Events (%count)": "Запланированные события (%count)",
"Script": "Скрипт",
+1
View File
@@ -116,6 +116,7 @@
"Recent Incidents": "Nedávne incidenty",
"Recurring": "Opakujúce sa",
"RESOLVED": "VYRIEŠENÉ",
"RSS feed": "RSS feed",
"SCHEDULED": "NAPLÁNOVANÉ",
"Scheduled Events (%count)": "Plánované udalosti (%count)",
"Scheduled Windows": "Naplánované úlohy",
+1
View File
@@ -100,6 +100,7 @@
"READY": "HAZIR",
"Recurring": "Tekrarlayan",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "PLANLANMIŞ",
"Scheduled Events (%count)": "Planlanan etkinlikler (%count)",
"Script": "Senaryo",
+139
View File
@@ -0,0 +1,139 @@
{
"name": "Українська",
"code": "uk",
"mappings": {
"%latency %metric latency": "%latency %metric latency",
"Affected Monitors (%count)": "Затронуті монітори (%count)",
"All Systems Operational": "Усі системи працюють",
"Average Latency": "Середня затримка",
"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": "Вбудувати монітор",
"Embed this monitor in your website or app": "Вбудуйте цей монітор у свій сайт або застосунок",
"End Time": "Час завершення",
"Enter the verification code sent to your email.": "Введіть код підтвердження, надісланий на вашу пошту.",
"Events": "Події",
"Failed to load data": "Не вдалося завантажити дані",
"Failed to load latency data": "Не вдалося завантажити дані затримки",
"Failed to load status data for this day": "Не вдалося завантажити дані статусу за цей день",
"Failed to send verification code": "Не вдалося надіслати код підтвердження",
"Failed to update preference": "Не вдалося оновити налаштування",
"Get badges for this monitor": "Отримати бейджі для цього монітора",
"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 (%count)": "Включені монітори (%count)",
"INVESTIGATING": "ДОСЛІДЖЕННЯ",
"Last Updated": "Останнє оновлення",
"Latency": "Затримка",
"Latency Embed": "Вбудована затримка",
"Latency Over Time": "Затримка з часом",
"Latency Trend": "Тренд затримки",
"Latest Latency": "Остання затримка",
"Latest Status": "Останній статус",
"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": "МОНІТОРИНГ",
"Network error. Please try again.": "Помилка мережі. Спробуйте ще раз",
"No Events in %currentMonth": "Немає подій у %currentMonth",
"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 ongoing maintenances": "Немає поточного обслуговування",
"No past maintenances": "Немає минулого обслуговування",
"No Status Available": "Статус недоступний",
"No upcoming maintenances": "Немає запланованого обслуговування",
"No Updates": "Немає оновлень",
"No updates yet": "Оновлень поки немає",
"Notifications": "Сповіщення",
"One-time": "Одноразове",
"Ongoing": "Поточні",
"Operational": "Працює",
"Partial Degraded Performance": "Часткове зниження продуктивності",
"Partial System Outage": "Частковий збій системи",
"Past": "Минулі",
"Per-Minute Status": "Похвилинний статус",
"Pinging": "Перевірка доступності",
"Please enter a valid email address": "Будь ласка, введіть дійсну електронну адресу",
"Please enter the 6-digit verification code": "Будь ласка, введіть 6-значний код підтвердження",
"Read less": "Згорнути",
"Read more": "Читати більше",
"READY": "ГОТОВО",
"Recurring": "Повторюване",
"RESOLVED": "ВИРІШЕНО",
"RSS feed": "RSS feed",
"SCHEDULED": "ЗАПЛАНОВАНО",
"Scheduled Events (%count)": "Заплановані події (%count)",
"Script": "Скрипт",
"Select Language": "Оберіть мову",
"Select latency metric to display": "Оберіть метрику затримки для відображення",
"Select Range": "Оберіть діапазон",
"Sending...": "Надсилання...",
"Standard": "Стандартний",
"Start Time": "Час початку",
"Status": "Статус",
"Status Badge": "Бейдж статусу",
"Status Embed": "Вбудований статус",
"Status history and latency trend": "Історія статусів та тренд затримки",
"Subscribe": "Підписатися",
"Subscribe to Updates": "Підписатися на оновлення",
"There are no incidents or maintenances scheduled for this month.": "На цей місяць не заплановано інцидентів або обслуговування",
"There are no ongoing incidents or maintenance events.": "Наразі немає активних інцидентів або обслуговування",
"Total Incidents": "Загальна кількість інцидентів",
"Total Maintenances": "Загальна кількість обслуговувань",
"Under Maintenance": "На обслуговуванні",
"Unknown impact": "Невідомий вплив",
"UP": "ПРАЦЮЄ",
"Upcoming": "Майбутні",
"Update Incident": "Оновити інцидент",
"Update Maintenance": "Оновити обслуговування",
"Updates": "Оновлення",
"Updates (%count)": "Оновлення (%count)",
"Uptime": "Час роботи",
"Uptime Badge": "Бейдж часу роботи",
"Verification failed": "Перевірка не вдалася",
"Verify": "Підтвердити",
"Verifying": "Перевірка",
"We sent a 6-digit code to": "Ми надіслали 6-значний код на"
}
}
+1
View File
@@ -100,6 +100,7 @@
"READY": "SẴN SÀNG",
"Recurring": "Định kỳ",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "ĐÃ LÊN LỊCH",
"Scheduled Events (%count)": "Sự kiện đã lên lịch (%count)",
"Script": "Kịch bản",
+40 -39
View File
@@ -2,65 +2,65 @@
"name": "简体中文",
"mappings": {
"%latency %metric latency": "%latency %metric 延迟",
"Affected Monitors (%count)": "受影响的监视器 (%count)",
"Affected Monitors (%count)": "受影响的监控项 (%count)",
"All Systems Operational": "所有系统运行正常",
"Average Latency": "平均延迟",
"Avg Latency": "平均延迟",
"Back": "后退",
"Back": "返回",
"Badges": "徽章",
"CANCELLED": "已取消",
"COMPLETED": "已完成",
"Continue": "继续",
"Copied": "已复制",
"Current": "当前",
"Dark": "黑暗的",
"Dark": "深色模式",
"Day": "天",
"Day Uptime": "日正常运行时",
"Day Uptime": "日正常运行时",
"Days": "天",
"Degraded": "降级",
"DEGRADED": "降级",
"Degraded": "系统降级",
"DEGRADED": "系统降级",
"Degraded Performance": "性能下降",
"Didn't receive the code? Resend": "没有收到代码?",
"Didn't receive the code? Resend": "没有收到代码?重新发送",
"Down": "宕机",
"DOWN": "故障",
"Duration": "间",
"Duration": "持续时间",
"Email address": "电子邮件",
"Embed Monitor": "嵌入监视器",
"Embed this monitor in your website or app": "将此监视器嵌入您的网站或应用程序中",
"Embed Monitor": "嵌入监控项",
"Embed this monitor in your website or app": "将此监控项嵌入您的网站或应用程序中",
"End Time": "结束时间",
"Enter the verification code sent to your email.": "输入发送到您的电子邮件的验证码。",
"Events": "动",
"Events": "动",
"Failed to load data": "加载数据失败",
"Failed to load latency data": "加载延迟数据失败",
"Failed to load status data for this day": "无法加载当天的状态数据",
"Failed to send verification code": "发送验证码失败",
"Failed to update preference": "无法更新偏好设置",
"Get badges for this monitor": "获取此显示器的徽章",
"Get badges for this monitor": "获取此监控项的徽章",
"Get notified about incidents and scheduled maintenance.": "获取有关事件和定期维护的通知。",
"Get notified about incidents updates": "获取有关事件更新的通知",
"Get notified about scheduled maintenance": "获取有关定期维护的通知",
"IDENTIFIED": "IDENTIFIED",
"IDENTIFIED": "已确认",
"iFrame": "框架",
"Impact": "影响",
"incident": "事件",
"Incident Updates": "事件更新",
"Incidents": "事件",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Included Monitors (%count)": "包括的监控项 (%count)",
"INVESTIGATING": "调查中",
"Last Updated": "最后更新",
"Latency": "延迟",
"Latency Embed": "延迟嵌入",
"Latency Over Time": "随着时间的推移延迟",
"Latency Embed": "嵌入延迟",
"Latency Over Time": "历史延迟",
"Latency Trend": "延迟趋势",
"Latest Latency": "最新延迟时间",
"Latest Status": "最新状态",
"Light": "",
"Light": "浅色模式",
"Live Status": "实时状态",
"Loading your preferences...": "正在加载您的偏好设置...",
"maintenance": "维护",
"MAINTENANCE": "维护中",
"Maintenance Updates": "维护更新",
"Maintenances": "维护保养",
"Maintenances": "例行维护",
"Major System Outage": "重大系统故障",
"Manage Site": "管理站点",
"Manage your notification preferences.": "管理您的通知首选项。",
@@ -69,38 +69,39 @@
"Min Latency": "最短延迟",
"Minimum Latency": "最短延迟",
"Minute-by-minute status data for this day": "当日每分钟的状态数据",
"MONITORING": "MONITORING",
"Network error. Please try again.": "网络错误。",
"MONITORING": "监视中",
"Network error. Please try again.": "网络错误。请稍后再试。",
"No Events in %currentMonth": "%currentMonth 没有活动",
"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 maintenances for this day": "这一天无维护",
"No monitors affected": "没有监控项受到影响",
"No monitors available.": "没有可用的监控项。",
"No ongoing maintenances": "无需持续维护",
"No ongoing maintenances": "没有需要持续维护",
"No past maintenances": "过去没有维护过",
"No Status Available": "无可用状态",
"No upcoming maintenances": "没有即将进行的维护",
"No Updates": "没有更新",
"No updates yet": "没有更新",
"No updates yet": "暂时没有更新",
"Notifications": "通知",
"One-time": "一度",
"One-time": "单次",
"Ongoing": "进行中",
"Operational": "正常运行",
"Partial Degraded Performance": "部分性能下降",
"Partial System Outage": "部分系统故障.",
"Partial System Outage": "部分系统故障",
"Past": "过去的",
"Per-Minute Status": "每分钟状态",
"Pinging": "pinging",
"Pinging": "检测中",
"Please enter a valid email address": "请输入有效的电子邮件地址",
"Please enter the 6-digit verification code": "请输入6位验证码",
"Read less": "少读书",
"Read more": "阅读更多",
"Read less": "收起",
"Read more": "更多",
"READY": "就绪",
"Recurring": "周期性",
"RESOLVED": "RESOLVED",
"SCHEDULED": "已安排",
"RESOLVED": "已解决",
"RSS feed": "RSS 订阅源",
"SCHEDULED": "已计划",
"Scheduled Events (%count)": "计划事件 (%count)",
"Script": "脚本",
"Select Language": "选择语言",
@@ -109,20 +110,20 @@
"Sending...": "正在发送...",
"Standard": "标准",
"Start Time": "开始时间",
"Status": "地位",
"Status": "状态",
"Status Badge": "状态徽章",
"Status Embed": "状态嵌入",
"Status history and latency trend": "状态历史和延迟趋势",
"Subscribe": "订阅",
"Subscribe to Updates": "订阅更新",
"There are no incidents or maintenances scheduled for this month.": "本月没有安排任何事故或维护。",
"There are no ongoing incidents or maintenance events.": "当前没有正在进行的事件或维护活动。",
"Total Incidents": "事总数",
"Total Maintenances": "全面维护",
"There are no incidents or maintenances scheduled for this month.": "本月没有任何事故或安排的维护。",
"There are no ongoing incidents or maintenance events.": "当前没有正在进行的事件或维护。",
"Total Incidents": "事总数",
"Total Maintenances": "维护总数",
"Under Maintenance": "维护中",
"Unknown impact": "未知影响",
"UP": "正常",
"Upcoming": "即将推出",
"Upcoming": "即将进行",
"Update Incident": "更新事件",
"Update Maintenance": "更新维护",
"Updates": "更新",
@@ -130,7 +131,7 @@
"Uptime": "正常运行时间",
"Uptime Badge": "正常运行时间徽章",
"Verification failed": "验证失败",
"Verify": "核实",
"Verify": "验证",
"Verifying": "正在验证",
"We sent a 6-digit code to": "我们发送了一个 6 位代码至"
}
+138
View File
@@ -0,0 +1,138 @@
{
"name": "繁體中文(香港)",
"mappings": {
"%latency %metric latency": "%latency %metric 延遲",
"Affected Monitors (%count)": "受影響的監控項 (%count)",
"All Systems Operational": "所有系統運行正常",
"Average Latency": "平均延遲",
"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": "嵌入監控項",
"Embed this monitor in your website or app": "將此監控項嵌入您的網站或應用程式中",
"End Time": "結束時間",
"Enter the verification code sent to your email.": "輸入發送到您電郵的驗證碼。",
"Events": "動態",
"Failed to load data": "載入數據失敗",
"Failed to load latency data": "載入延遲數據失敗",
"Failed to load status data for this day": "無法載入當天的狀態數據",
"Failed to send verification code": "發送驗證碼失敗",
"Failed to update preference": "無法更新偏好設定",
"Get badges for this monitor": "獲取此監控項的徽章",
"Get notified about incidents and scheduled maintenance.": "獲取有關事件和定期維護的通知。",
"Get notified about incidents updates": "獲取有關事件更新的通知",
"Get notified about scheduled maintenance": "獲取有關定期維護的通知",
"IDENTIFIED": "已確認",
"iFrame": "框架",
"Impact": "影響",
"incident": "事件",
"Incident Updates": "事件更新",
"Incidents": "事件",
"Included Monitors (%count)": "包括的監控項 (%count)",
"INVESTIGATING": "調查中",
"Last Updated": "最後更新",
"Latency": "延遲",
"Latency Embed": "嵌入延遲",
"Latency Over Time": "歷史延遲",
"Latency Trend": "延遲趨勢",
"Latest Latency": "最新延遲時間",
"Latest Status": "最新狀態",
"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": "監察中",
"Network error. Please try again.": "網絡錯誤。請稍後再試。",
"No Events in %currentMonth": "%currentMonth 沒有活動",
"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 ongoing maintenances": "沒有需要持續的維護",
"No past maintenances": "過去沒有維護過",
"No Status Available": "無可用狀態",
"No upcoming maintenances": "沒有即將進行的維護",
"No Updates": "沒有更新",
"No updates yet": "暫時沒有更新",
"Notifications": "通知",
"One-time": "單次",
"Ongoing": "進行中",
"Operational": "正常運行",
"Partial Degraded Performance": "部分效能下降",
"Partial System Outage": "部分系統故障",
"Past": "過去的",
"Per-Minute Status": "每分鐘狀態",
"Pinging": "檢測中",
"Please enter a valid email address": "請輸入有效的電郵地址",
"Please enter the 6-digit verification code": "請輸入6位驗證碼",
"Read less": "收起",
"Read more": "更多",
"READY": "就緒",
"Recurring": "週期性",
"RESOLVED": "已解決",
"RSS feed": "RSS 訂閱源",
"SCHEDULED": "已計劃",
"Scheduled Events (%count)": "計劃事件 (%count)",
"Script": "腳本",
"Select Language": "選擇語言",
"Select latency metric to display": "選擇要顯示的延遲指標",
"Select Range": "選擇範圍",
"Sending...": "正在發送...",
"Standard": "標準",
"Start Time": "開始時間",
"Status": "狀態",
"Status Badge": "狀態徽章",
"Status Embed": "狀態嵌入",
"Status history and latency trend": "狀態歷史和延遲趨勢",
"Subscribe": "訂閱",
"Subscribe to Updates": "訂閱更新",
"There are no incidents or maintenances scheduled for this month.": "本月沒有任何事故或安排的維護。",
"There are no ongoing incidents or maintenance events.": "當前沒有正在進行的事件或維護。",
"Total Incidents": "事件總數",
"Total Maintenances": "維護總數",
"Under Maintenance": "維護中",
"Unknown impact": "未知影響",
"UP": "正常",
"Upcoming": "即將進行",
"Update Incident": "更新事件",
"Update Maintenance": "更新維護",
"Updates": "更新",
"Updates (%count)": "更新 (%count)",
"Uptime": "正常運行時間",
"Uptime Badge": "正常運行時間徽章",
"Verification failed": "驗證失敗",
"Verify": "驗證",
"Verifying": "正在驗證",
"We sent a 6-digit code to": "我們已發送一個6位驗證碼至"
}
}
+138
View File
@@ -0,0 +1,138 @@
{
"name": "繁體中文(澳門)",
"mappings": {
"%latency %metric latency": "%latency %metric 延遲",
"Affected Monitors (%count)": "受影響的監控項 (%count)",
"All Systems Operational": "所有系統運行正常",
"Average Latency": "平均延遲",
"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": "嵌入監控項",
"Embed this monitor in your website or app": "將此監控項嵌入您的網站或應用程式中",
"End Time": "結束時間",
"Enter the verification code sent to your email.": "輸入發送到您電郵的驗證碼。",
"Events": "動態",
"Failed to load data": "載入數據失敗",
"Failed to load latency data": "載入延遲數據失敗",
"Failed to load status data for this day": "無法載入當天的狀態數據",
"Failed to send verification code": "發送驗證碼失敗",
"Failed to update preference": "無法更新偏好設定",
"Get badges for this monitor": "獲取此監控項的徽章",
"Get notified about incidents and scheduled maintenance.": "獲取有關事件和定期維護的通知。",
"Get notified about incidents updates": "獲取有關事件更新的通知",
"Get notified about scheduled maintenance": "獲取有關定期維護的通知",
"IDENTIFIED": "已確認",
"iFrame": "框架",
"Impact": "影響",
"incident": "事件",
"Incident Updates": "事件更新",
"Incidents": "事件",
"Included Monitors (%count)": "包括的監控項 (%count)",
"INVESTIGATING": "調查中",
"Last Updated": "最後更新",
"Latency": "延遲",
"Latency Embed": "嵌入延遲",
"Latency Over Time": "歷史延遲",
"Latency Trend": "延遲趨勢",
"Latest Latency": "最新延遲時間",
"Latest Status": "最新狀態",
"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": "監察中",
"Network error. Please try again.": "網絡錯誤。請稍後再試。",
"No Events in %currentMonth": "%currentMonth 沒有活動",
"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 ongoing maintenances": "沒有需要持續的維護",
"No past maintenances": "過去沒有維護過",
"No Status Available": "無可用狀態",
"No upcoming maintenances": "沒有即將進行的維護",
"No Updates": "沒有更新",
"No updates yet": "暫時沒有更新",
"Notifications": "通知",
"One-time": "單次",
"Ongoing": "進行中",
"Operational": "正常運行",
"Partial Degraded Performance": "部分效能下降",
"Partial System Outage": "部分系統故障",
"Past": "過去的",
"Per-Minute Status": "每分鐘狀態",
"Pinging": "檢測中",
"Please enter a valid email address": "請輸入有效的電郵地址",
"Please enter the 6-digit verification code": "請輸入6位驗證碼",
"Read less": "收起",
"Read more": "更多",
"READY": "就緒",
"Recurring": "週期性",
"RESOLVED": "已解決",
"RSS feed": "RSS 訂閱源",
"SCHEDULED": "已計劃",
"Scheduled Events (%count)": "計劃事件 (%count)",
"Script": "腳本",
"Select Language": "選擇語言",
"Select latency metric to display": "選擇要顯示的延遲指標",
"Select Range": "選擇範圍",
"Sending...": "正在發送...",
"Standard": "標準",
"Start Time": "開始時間",
"Status": "狀態",
"Status Badge": "狀態徽章",
"Status Embed": "狀態嵌入",
"Status history and latency trend": "狀態歷史和延遲趨勢",
"Subscribe": "訂閱",
"Subscribe to Updates": "訂閱更新",
"There are no incidents or maintenances scheduled for this month.": "本月沒有任何事故或安排的維護。",
"There are no ongoing incidents or maintenance events.": "當前沒有正在進行的事件或維護。",
"Total Incidents": "事件總數",
"Total Maintenances": "維護總數",
"Under Maintenance": "維護中",
"Unknown impact": "未知影響",
"UP": "正常",
"Upcoming": "即將進行",
"Update Incident": "更新事件",
"Update Maintenance": "更新維護",
"Updates": "更新",
"Updates (%count)": "更新 (%count)",
"Uptime": "正常運行時間",
"Uptime Badge": "正常運行時間徽章",
"Verification failed": "驗證失敗",
"Verify": "驗證",
"Verifying": "正在驗證",
"We sent a 6-digit code to": "我們已發送一個6位驗證碼至"
}
}
+74 -69
View File
@@ -2,8 +2,8 @@
"name": "繁體中文(台灣)",
"mappings": {
"%latency %metric latency": "%latency %metric 延遲",
"Affected Monitors (%count)": "受影響的監控項 (%count)",
"All Systems Operational": "所有系統正常運作",
"Affected Monitors (%count)": "受影響的監控項 (%count)",
"All Systems Operational": "所有系統運行正常",
"Average Latency": "平均延遲",
"Avg Latency": "平均延遲",
"Back": "返回",
@@ -12,122 +12,127 @@
"COMPLETED": "已完成",
"Continue": "繼續",
"Copied": "已複製",
"Current": "前",
"Dark": "深色",
"Current": "前",
"Dark": "深色模式",
"Day": "天",
"Day Uptime": "日正常運作時間",
"Day Uptime": "日正常運行時長",
"Days": "天",
"DEGRADED": "降級",
"Degraded Performance": "效能降低",
"Didn't receive the code? Resend": "沒有收到驗證碼?重新傳送",
"Degraded": "系統降級",
"DEGRADED": "系統降級",
"Degraded Performance": "效能下降",
"Didn't receive the code? Resend": "沒有收到代碼?重新發送",
"Down": "當機",
"DOWN": "故障",
"Duration": "持續時間",
"Edit Monitor": "編輯監控",
"Email address": "電子郵件地址",
"Embed Monitor": "嵌入監控",
"Embed this monitor in your website or app": "將此監控嵌入您的網站或應用程式",
"Email address": "電子郵件",
"Embed Monitor": "嵌入監控項",
"Embed this monitor in your website or app": "將此監控項嵌入您的網站或應用程式中",
"End Time": "結束時間",
"Enter the verification code sent to your email.": "輸入傳送至您電子郵件的驗證碼。",
"Events": "事件",
"Failed to load data": "載入資料失敗",
"Failed to load latency data": "載入延遲資料失敗",
"Failed to load status data for this day": "無法載入當的狀態資料",
"Failed to send verification code": "送驗證碼失敗",
"Failed to update preference": "更新偏好設定失敗",
"Get badges for this monitor": "取此監控的徽章",
"Get notified about incidents and scheduled maintenance.": "接收事件與排程維護的通知。",
"Get notified about incidents updates": "接收事件更新通知",
"Get notified about scheduled maintenance": "接收排程維護通知",
"Enter the verification code sent to your email.": "輸入發送到您的電子郵件的驗證碼。",
"Events": "動態",
"Failed to load data": "載入數據失敗",
"Failed to load latency data": "載入延遲數據失敗",
"Failed to load status data for this day": "無法載入當的狀態數據",
"Failed to send verification code": "送驗證碼失敗",
"Failed to update preference": "無法更新偏好設定",
"Get badges for this monitor": "取此監控的徽章",
"Get notified about incidents and scheduled maintenance.": "獲取有關事件和定期維護的通知。",
"Get notified about incidents updates": "獲取有關事件更新通知",
"Get notified about scheduled maintenance": "獲取有關定期維護通知",
"IDENTIFIED": "已確認",
"iFrame": "iFrame",
"iFrame": "框架",
"Impact": "影響",
"incident": "事件",
"Incident Updates": "事件更新",
"Incidents": "事件",
"Included Monitors (%count)": "包的監控項 (%count)",
"Included Monitors (%count)": "包的監控項 (%count)",
"INVESTIGATING": "調查中",
"Last Updated": "最後更新",
"Latency": "延遲",
"Latency Embed": "延遲嵌入",
"Latency Over Time": "延遲趨勢圖",
"Latency Embed": "嵌入延遲",
"Latency Over Time": "歷史延遲",
"Latency Trend": "延遲趨勢",
"Latest Latency": "最新延遲",
"Latest Latency": "最新延遲時間",
"Latest Status": "最新狀態",
"Light": "淺色",
"Light": "淺色模式",
"Live Status": "即時狀態",
"Loading your preferences...": "正在載入您的偏好設定……",
"Loading your preferences...": "正在載入您的偏好設定...",
"maintenance": "維護",
"MAINTENANCE": "維護中",
"Maintenance Updates": "維護更新",
"Maintenances": "維護作業",
"Major System Outage": "重大系統中斷",
"Manage Site": "管理站",
"Manage your notification preferences.": "管理您的通知偏好設定。",
"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": "監中",
"Network error. Please try again.": "網路錯誤,請重試。",
"No Events in %currentMonth": "%currentMonth 沒有事件",
"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 incidents for this day": "當日無事件",
"No latency data available for this day": "當日無延遲資料",
"No maintenances for this day": "當日無維護作業",
"No monitors affected": "沒有受影響的監控項目",
"No monitors available.": "沒有可用的監控項。",
"No ongoing maintenances": "沒有進行中的維護作業",
"No past maintenances": "沒有過去的維護作業",
"No incidents for this day": "這一天沒有發生任何事件",
"No latency data available for this day": "當天沒有可用的延遲數據",
"No maintenances for this day": "這一天無維護",
"No monitors affected": "沒有監控項受到影響",
"No monitors available.": "沒有可用的監控項。",
"No ongoing maintenances": "沒有需要持續的維護",
"No past maintenances": "過去沒有維護過",
"No Status Available": "無可用狀態",
"No upcoming maintenances": "沒有即將進行的維護作業",
"No upcoming maintenances": "沒有即將進行的維護",
"No Updates": "沒有更新",
"No updates yet": "尚無更新",
"No updates yet": "暫時沒有更新",
"Notifications": "通知",
"One-time": "單次",
"Ongoing": "進行中",
"Partial Degraded Performance": "部分效能降低",
"Partial System Outage": "部分系統中斷",
"Past": "過去",
"Per-Minute Status": "逐分鐘狀態",
"Pinging": "偵測中",
"Operational": "正常運行",
"Partial Degraded Performance": "部分效能下降",
"Partial System Outage": "部分系統故障",
"Past": "過去的",
"Per-Minute Status": "每分鐘狀態",
"Pinging": "檢測中",
"Please enter a valid email address": "請輸入有效的電子郵件地址",
"Please enter the 6-digit verification code": "請輸入 6 位數驗證碼",
"Read less": "收",
"Read more": "展開",
"Please enter the 6-digit verification code": "請輸入6位驗證碼",
"Read less": "收",
"Read more": "更多",
"READY": "就緒",
"Recurring": "週期性",
"RESOLVED": "已解決",
"SCHEDULED": "已排程",
"Scheduled Events (%count)": "排程事件 (%count)",
"Script": "程式碼",
"RSS feed": "RSS 訂閱源",
"SCHEDULED": "已計劃",
"Scheduled Events (%count)": "計劃事件 (%count)",
"Script": "腳本",
"Select Language": "選擇語言",
"Select latency metric to display": "選擇要顯示的延遲指標",
"Select Range": "選擇範圍",
"Sending...": "傳送中……",
"Sending...": "正在發送...",
"Standard": "標準",
"Start Time": "開始時間",
"Status": "狀態",
"Status Badge": "狀態徽章",
"Status Embed": "狀態嵌入",
"Status history and latency trend": "狀態歷程與延遲趨勢",
"Status history and latency trend": "狀態歷史和延遲趨勢",
"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 incidents or maintenances scheduled for this month.": "本月沒有任何事故或安排的維護。",
"There are no ongoing incidents or maintenance events.": "前沒有正在進行的事件或維護。",
"Total Incidents": "事件總數",
"Total Maintenances": "維護總數",
"Under Maintenance": "維護中",
"Unknown impact": "影響不明",
"Unknown impact": "未知影響",
"UP": "正常",
"Upcoming": "即將進行",
"Update Incident": "更新事件",
"Update Maintenance": "更新維護",
"Updates": "更新",
"Updates (%count)": "更新 (%count)",
"Uptime": "正常運時間",
"Uptime Badge": "正常運時間徽章",
"Uptime": "正常運時間",
"Uptime Badge": "正常運時間徽章",
"Verification failed": "驗證失敗",
"Verify": "驗證",
"Verifying": "驗證",
"We sent a 6-digit code to": "我們已傳送 6 位數驗證碼至"
"Verifying": "正在驗證",
"We sent a 6-digit code to": "我們發送了一個 6 位碼至"
}
}
@@ -17,26 +17,19 @@ import type {
import type { GroupMonitorTypeData } from "../types/monitor.js";
import GC from "../../global-constants.js";
import type { LayoutServerData } from "./layoutController.js";
import type { NotificationEvent } from "../../types/notifications.js";
export type { NotificationEvent };
// Default page settings
const defaultPageSettings: PageSettingsType = {
monitor_status_history_days: {
desktop: 90,
mobile: 30,
desktop: GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP,
mobile: GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE,
},
monitor_layout_style: "default-list",
monitor_layout_style: GC.DEFAULT_MONITOR_LAYOUT_STYLE,
};
export interface NotificationEvent {
eventURL: string;
eventTitle: string;
eventDate: string;
eventType: string;
eventStartDateTime: number;
eventEndDateTime: number | null;
eventStatus: string;
}
export interface NotificationPayload {
notifications: NotificationEvent[];
}
@@ -377,17 +370,18 @@ export const GetPageDashboardData = async (
};
}
const eventSettings = layoutData.eventDisplaySettings;
const showInlineEvents = eventSettings.showInlineEvents === true;
// Fetch all dashboard data in parallel (respecting feature toggles)
const [latestData, parsedMonitors, ongoingIncidents, ongoingMaintenances, upcomingMaintenances] = await Promise.all([
GetLatestMonitoringDataAllActive(monitorTags),
GetMonitorsParsed({ tags: monitorTags, status: "ACTIVE", is_hidden: "NO" }),
eventSettings.incidents.enabled && eventSettings.incidents.ongoing.show
showInlineEvents && eventSettings.incidents.enabled && eventSettings.incidents.ongoing.show
? GetOngoingIncidentsForMonitorList(monitorTags)
: Promise.resolve([] as IncidentForMonitorListWithComments[]),
eventSettings.maintenances.enabled && eventSettings.maintenances.ongoing.show
showInlineEvents && eventSettings.maintenances.enabled && eventSettings.maintenances.ongoing.show
? GetOngoingMaintenances(monitorTags, nowTs)
: Promise.resolve([] as MaintenanceEventsMonitorList[]),
eventSettings.maintenances.enabled && eventSettings.maintenances.upcoming.show
showInlineEvents && eventSettings.maintenances.enabled && eventSettings.maintenances.upcoming.show
? GetUpcomingMaintenanceEventsForMonitorList(
monitorTags,
eventSettings.maintenances.upcoming.maxCount,
+43 -3
View File
@@ -7,8 +7,8 @@ import {
GetLoggedInSession,
GetLocaleFromCookie,
GetUsersCount,
HasRequiredEnv,
IsEmailSetup,
IsSetupComplete,
} from "./controller.js";
import type { EventDisplaySettings, GlobalPageVisibilitySettings, SiteDateTimeFormat } from "$lib/types/site.js";
@@ -48,6 +48,7 @@ export interface LayoutServerData {
subMenuOptions: {
showShareBadgeMonitor: boolean;
showShareEmbedMonitor: boolean;
showRssFeed: boolean;
};
isTimezoneEnabled: boolean;
isThemeToggleEnabled: boolean;
@@ -75,6 +76,43 @@ export interface LayoutServerData {
metaSiteDescription?: string;
}
function NormalizeEventDisplaySettings(settings?: Partial<EventDisplaySettings>): EventDisplaySettings {
const defaults = structuredClone(seedSiteData.eventDisplaySettings);
return {
showInlineEvents:
typeof settings?.showInlineEvents === "boolean" ? settings.showInlineEvents : defaults.showInlineEvents,
incidents: {
...defaults.incidents,
...settings?.incidents,
ongoing: {
...defaults.incidents.ongoing,
...settings?.incidents?.ongoing,
},
resolved: {
...defaults.incidents.resolved,
...settings?.incidents?.resolved,
},
},
maintenances: {
...defaults.maintenances,
...settings?.maintenances,
ongoing: {
...defaults.maintenances.ongoing,
...settings?.maintenances?.ongoing,
},
past: {
...defaults.maintenances.past,
...settings?.maintenances?.past,
},
upcoming: {
...defaults.maintenances.upcoming,
...settings?.maintenances?.upcoming,
},
},
};
}
export async function GetLayoutServerData(cookies: Cookies, request: Request): Promise<LayoutServerData> {
const userAgent = request.headers.get("user-agent") ?? "";
const md = new MobileDetect(userAgent);
@@ -86,7 +124,9 @@ export async function GetLayoutServerData(cookies: Cookies, request: Request): P
GetUsersCount(),
]);
const isSetupComplete = await IsSetupComplete();
// Same check as IsSetupComplete, but reuses the site data fetched above
// instead of querying it a second time on every request
const isSetupComplete = HasRequiredEnv() && Object.keys(siteData).length > 0;
const selectedLang = GetLocaleFromCookie(siteData, cookies);
const siteStatusColors = siteData.colors;
@@ -136,7 +176,7 @@ export async function GetLayoutServerData(cookies: Cookies, request: Request): P
font,
canSendEmail,
announcement: siteData.announcement,
eventDisplaySettings: siteData.eventDisplaySettings || seedSiteData.eventDisplaySettings,
eventDisplaySettings: NormalizeEventDisplaySettings(siteData.eventDisplaySettings),
socialPreviewImage: siteData.socialPreviewImage,
customCSS: siteData.customCSS,
globalPageVisibilitySettings: siteData.globalPageVisibilitySettings || seedSiteData.globalPageVisibilitySettings,
@@ -486,18 +486,75 @@ export const UpdateMaintenanceEvent = async (
return await db.updateMaintenanceEvent(id, data);
};
export const UpdateMaintenanceEventStatus = async (id: number, status: string): Promise<number> => {
const validStatuses = ["SCHEDULED", "IN_PROGRESS", "COMPLETED", "CANCELLED"];
if (!validStatuses.includes(status)) {
throw new Error(`Invalid status: ${status}`);
/**
* Manually transition a maintenance event to a terminal status.
* Allowed transitions: ONGOING → COMPLETED, SCHEDULED/READY/ONGOING → CANCELLED.
* An event that already started has its end_date_time moved to the moment it was
* ended (the record reflects what actually happened); an event that never started
* keeps its planned window. See docs/adr/0006-manual-maintenance-event-transitions.md
*/
export const UpdateMaintenanceEventStatus = async (id: number, status: string): Promise<MaintenanceEventRecord> => {
if (status !== GC.COMPLETED && status !== GC.CANCELLED) {
throw new Error(`Invalid status: ${status}. Allowed values are ${GC.COMPLETED} and ${GC.CANCELLED}`);
}
const targetStatus = status as "COMPLETED" | "CANCELLED";
const existing = await db.getMaintenanceEventById(id);
if (!existing) {
throw new Error(`Maintenance event with id ${id} does not exist`);
}
return await db.updateMaintenanceEventStatus(id, status);
const allowedFrom: string[] = targetStatus === GC.COMPLETED ? [GC.ONGOING] : [GC.SCHEDULED, GC.READY, GC.ONGOING];
if (!allowedFrom.includes(existing.status)) {
throw new Error(`Cannot transition event from ${existing.status} to ${targetStatus}`);
}
if (existing.status === GC.ONGOING) {
// Ended now, but never before its first minute nor after its planned end
const endDateTime = Math.min(
existing.end_date_time,
Math.max(GetMinuteStartNowTimestampUTC(), existing.start_date_time + 60),
);
await db.updateMaintenanceEvent(id, { status: targetStatus, end_date_time: endDateTime });
} else {
await db.updateMaintenanceEventStatus(id, targetStatus);
}
const updated = await db.getMaintenanceEventById(id);
if (!updated) {
throw new Error(`Maintenance event with id ${id} does not exist`);
}
try {
const siteData = await GetAllSiteData();
const notificationSettings =
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
if (notificationSettings.event_types.ended) {
const siteVars = siteDataToVariables(siteData);
const siteUrl = siteVars.site_url;
const maintenance = await db.getMaintenanceById(updated.maintenance_id);
const monitors = await db.getMonitorsByMaintenanceId(updated.maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const eventDetailed: MaintenanceEventRecordDetailed = {
...updated,
title: maintenance?.title || "",
description: maintenance?.description || null,
};
const update = maintenanceToVariables(
eventDetailed,
monitorNames,
targetStatus === GC.COMPLETED ? "**has been completed**" : "**has been cancelled**",
targetStatus === GC.COMPLETED ? "completed" : "cancelled",
targetStatus === GC.COMPLETED ? "Maintenance Completed" : "Maintenance Cancelled",
siteUrl,
);
await subscriberQueue.push(update);
}
} catch (err) {
console.error(`Error sending ${targetStatus} notification for maintenance event ${id}:`, err);
}
return updated;
};
export const DeleteMaintenanceEvent = async (id: number): Promise<number> => {
@@ -328,7 +328,8 @@ export async function DeleteMonitorAlertConfig(id: number): Promise<boolean> {
throw new Error(`Monitor alert config with id '${id}' not found`);
}
// Triggers will be deleted automatically due to CASCADE
// The repository deletes trigger/monitor junctions and v2 alerts explicitly;
// FK cascades are not enforced on SQLite
const deleted = await db.deleteMonitorAlertConfig(id);
return deleted > 0;
}
@@ -21,11 +21,11 @@ import type {
import type { MonitorFilter } from "../db/repositories/base.js";
import db from "../db/db.js";
import type { PaginationInput } from "../../types/common.js";
import type { DayWiseStatus, NumberWithChange } from "../../types/monitor.js";
import GC, { getBadgeStyle, type BadgeStyle } from "../../global-constants.js";
import { makeBadge } from "badge-maker";
import { ErrorSvg } from "../../anywhere.js";
import { GetLastMonitoringValue, SetLastHeartbeat, DeleteMonitorCaches } from "../cache/setGet.js";
import { CollapseStatusCounts } from "../../clientTools.js";
import { translate, isLocaleAvailable } from "../i18n.js";
import type { HeartbeatMonitor, GroupMonitorTypeData } from "../types/monitor.js";
@@ -92,6 +92,7 @@ interface MonitoringDataInput {
latency?: number;
type: string;
error_message?: string | null;
raw_status?: string | null;
}
interface InterpolatedDataEntry {
@@ -112,6 +113,7 @@ export const InsertMonitoringData = async (data: MonitoringDataInput): Promise<M
latency: data.latency || 0,
type: data.type,
error_message: data.error_message,
raw_status: data.raw_status,
});
};
@@ -264,6 +266,7 @@ export const CloneMonitor = async ({ sourceTag, newTag, newName }: CloneMonitorI
type_data: source.type_data,
day_degraded_minimum_count: source.day_degraded_minimum_count,
day_down_minimum_count: source.day_down_minimum_count,
confirmation_threshold: source.confirmation_threshold,
include_degraded_in_downtime: source.include_degraded_in_downtime,
is_hidden: source.is_hidden,
monitor_settings_json: source.monitor_settings_json,
@@ -290,7 +293,7 @@ export const GetLatestMonitoringData = async (monitor_tag: string): Promise<Moni
};
export const GetLatestStatusActiveAll = async (): Promise<{ status: string }> => {
//get all the active not hidden monitor tags
const monitors = await db.getMonitors({ status: "ACTIVE", is_hidden: "NO" });
const monitors = await db.getMonitors({ status: GC.ACTIVE, is_hidden: GC.NO });
const monitor_tags = monitors.map((m) => m.tag);
const latestData: MonitoringData[] = [];
@@ -302,19 +305,20 @@ export const GetLatestStatusActiveAll = async (): Promise<{ status: string }> =>
}
}
let status: string = GC.NO_DATA;
for (let i = 0; i < latestData.length; i++) {
//if any status is down then status = down, if any is degraded then status = degraded, down > degraded > up
if (latestData[i].status === GC.DOWN) {
status = GC.DOWN;
} else if (latestData[i].status === GC.DEGRADED && status !== GC.DOWN) {
status = GC.DEGRADED;
} else if (latestData[i].status === GC.UP && status !== GC.DOWN && status !== GC.DEGRADED) {
status = GC.UP;
const counts = { countOfUp: 0, countOfDown: 0, countOfDegraded: 0, countOfMaintenance: 0 };
for (const data of latestData) {
if (data.status === GC.UP) {
counts.countOfUp++;
} else if (data.status === GC.DOWN) {
counts.countOfDown++;
} else if (data.status === GC.DEGRADED) {
counts.countOfDegraded++;
} else if (data.status === GC.MAINTENANCE) {
counts.countOfMaintenance++;
}
}
return {
status: status,
status: CollapseStatusCounts(counts),
};
};
@@ -419,6 +423,7 @@ async function removeTagFromGroupMonitors(tag: string): Promise<void> {
type_data: JSON.stringify(typeData),
day_degraded_minimum_count: group.day_degraded_minimum_count,
day_down_minimum_count: group.day_down_minimum_count,
confirmation_threshold: group.confirmation_threshold,
include_degraded_in_downtime: group.include_degraded_in_downtime,
is_hidden: group.is_hidden,
monitor_settings_json:
@@ -436,6 +441,7 @@ export const DeleteMonitorCompletelyUsingTag = async (tag: string): Promise<numb
await db.deleteMonitorDataByTag(tag);
await db.deleteIncidentMonitorsByTag(tag);
await db.deleteMonitorAlertsByTag(tag);
await db.deleteMonitorAlertConfigsByMonitorTag(tag);
await db.deletePageMonitorsByTag(tag);
await db.deleteMaintenanceMonitorsByTag(tag);
await removeTagFromGroupMonitors(tag);
@@ -461,9 +467,6 @@ export const GetAllAlertsPaginated = async (
export const GetMonitoringData = async (tag: string, since: number, now: number): Promise<MonitoringData[]> => {
return await db.getMonitoringData(tag, since, now);
};
export const GetMonitoringDataAll = async (tags: string[], since: number, now: number): Promise<MonitoringData[]> => {
return await db.getMonitoringDataAll(tags, since, now);
};
export const InsertNewAlert = async (data: MonitorAlertInsert): Promise<MonitorAlert | undefined> => {
if (await db.alertExists(data.monitor_tag, data.monitor_status, data.alert_status)) {
@@ -547,7 +550,7 @@ export const GetBadge = async (badgeType: BadgeType, params: BadgeParams): Promi
lastObj = await GetLatestStatusActiveAll();
} else {
// Single monitor status
const monitors = await GetMonitorsParsed({ tag, status: "ACTIVE", is_hidden: "NO" });
const monitors = await GetMonitorsParsed({ tag, status: GC.ACTIVE, is_hidden: GC.NO });
if (monitors.length === 0) {
return new Response(ErrorSvg, {
headers: { "Content-Type": "image/svg+xml" },
@@ -635,14 +638,14 @@ export const GetBadge = async (badgeType: BadgeType, params: BadgeParams): Promi
const siteData = await db.getSiteDataByKey("siteName");
const siteName = siteData?.value as string | undefined;
name = siteName || "All Monitors";
const goodMonitors = await GetMonitorsParsed({ status: "ACTIVE", is_hidden: "NO" });
const goodMonitors = await GetMonitorsParsed({ status: GC.ACTIVE, is_hidden: GC.NO });
const activeTags = goodMonitors.map((monitor) => monitor.tag);
stats = await db.getStatusCountsByInterval(activeTags, since, now - since, 1);
uptimeData = UptimeCalculator(stats);
} else {
// Single monitor badge
const monitors = await GetMonitorsParsed({ tag });
const monitors = await GetMonitorsParsed({ tag, status: GC.ACTIVE, is_hidden: GC.NO });
if (monitors.length === 0) {
return new Response(ErrorSvg, {
headers: { "Content-Type": "image/svg+xml" },
@@ -751,3 +754,6 @@ export const GetStatusCountsByIntervalGroupedByMonitor = async (
await setCache(cacheKey, result, 60);
return result;
};
export const GetLastKnownStatus = async (monitor_tag: string): Promise<MonitoringData | undefined> => {
return await db.getLastKnownStatus(monitor_tag);
};
@@ -108,6 +108,22 @@ export const GetLocaleFromCookie = (site: SiteDataTransformed, cookies: Cookies)
return selectedLang;
};
/**
* Returns the site URL used for building absolute public URLs, without a trailing slash.
* Prefers the configured siteURL and falls back to the ORIGIN env var; only absolute
* http(s) values are returned. Returns an empty string when neither is usable, in which
* case callers degrade to a relative path.
*/
export const GetSiteURL = async (): Promise<string> => {
const siteURL = await GetSiteDataByKey("siteURL");
for (const candidate of [siteURL, process.env.ORIGIN]) {
if (typeof candidate === "string" && /^https?:\/\//i.test(candidate)) {
return candidate.replace(/\/+$/, "");
}
}
return "";
};
export const GetSiteLogoURL = async (siteURL: string, logo: string, base: string): Promise<string> => {
if (logo.startsWith("http")) {
return logo;
@@ -138,14 +154,17 @@ export const GetSiteDataByKey = async (key: string): Promise<unknown> => {
return data.value;
};
/** Checks the env vars required for setup, without touching the database. */
export const HasRequiredEnv = (): boolean => {
return (
process.env.KENER_SECRET_KEY !== undefined &&
process.env.ORIGIN !== undefined &&
process.env.REDIS_URL !== undefined
);
};
export const IsSetupComplete = async (): Promise<boolean> => {
if (process.env.KENER_SECRET_KEY === undefined) {
return false;
}
if (process.env.ORIGIN === undefined) {
return false;
}
if (process.env.REDIS_URL === undefined) {
if (!HasRequiredEnv()) {
return false;
}
let data = await db.getAllSiteData();
+282 -61
View File
@@ -2,7 +2,7 @@ import db from "../db/db.js";
import type { PaginationInput } from "$lib/types/common";
import { GenerateToken, HashPassword, ValidatePassword, VerifyToken } from "./commonController.js";
import type { Cookies } from "@sveltejs/kit";
import type { UserRecordPublic, UserRecordDashboard } from "../types/db.js";
import type { UserRecordPublic, UserRecordDashboard, RoleRecord } from "../types/db.js";
import { GetAllSiteData } from "./controller.js";
import { siteDataToVariables } from "../notification/notification_utils.js";
import sendEmail from "../notification/email_notification.js";
@@ -16,7 +16,7 @@ export interface UserUpdateInput {
interface ManualUserUpdateInput {
updateType: string;
role?: string;
role_ids?: string[];
is_active?: number;
password?: string;
passwordPlain?: string;
@@ -32,7 +32,7 @@ interface NewUserInput {
name: string;
password: string;
plainPassword: string;
role: string;
role_ids: string[];
}
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -65,12 +65,18 @@ const validateNameOrThrow = (name: string): string => {
return normalizedName;
};
export const GetAllUsersPaginated = async (data: PaginationInput): Promise<UserRecordPublic[]> => {
return await db.getUsersPaginated(data.page, data.limit);
export const GetAllUsersPaginated = async (
data: PaginationInput,
filter?: { is_active?: number },
): Promise<UserRecordPublic[]> => {
return await db.getUsersPaginated(data.page, data.limit, filter);
};
export const GetAllUsersPaginatedDashboard = async (data: PaginationInput): Promise<UserRecordDashboard[]> => {
const users = await db.getUsersPaginated(data.page, data.limit);
export const GetAllUsersPaginatedDashboard = async (
data: PaginationInput,
filter?: { is_active?: number },
): Promise<UserRecordDashboard[]> => {
const users = await db.getUsersPaginated(data.page, data.limit, filter);
if (users.length === 0) return [];
// Batch fetch password statuses for all users
@@ -88,8 +94,8 @@ export const GetAllUsers = async () => {
return await db.getAllUsers();
};
export const GetUsersCount = async () => {
return await db.getUsersCount();
export const GetUsersCount = async (filter?: { is_active?: number }) => {
return await db.getTotalUsers(filter);
};
export const GetUserPasswordHashById = async (id: number) => {
@@ -145,14 +151,20 @@ export const UpdateUserData = async (data: UserUpdateInput): Promise<number> =>
}
};
export const CreateNewUser = async (currentUser: { role: string }, data: NewUserInput): Promise<number[]> => {
let acceptedRoles = ["member", "editor"];
if (!acceptedRoles.includes(data.role)) {
throw new Error("Invalid role");
export const CreateNewUser = async (data: NewUserInput): Promise<number[]> => {
if (!data.role_ids || data.role_ids.length === 0) {
throw new Error("At least one role is required");
}
if (currentUser.role === "member") {
throw new Error("Only admins and editors can create new users");
// Validate all role_ids exist and are active
for (const roleId of data.role_ids) {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" does not exist`);
}
if (role.status !== "ACTIVE") {
throw new Error(`Role "${roleId}" is not active`);
}
}
const normalizedEmail = validateEmailOrThrow(data.email);
@@ -163,11 +175,6 @@ export const CreateNewUser = async (currentUser: { role: string }, data: NewUser
throw new Error("Password cannot be empty");
}
//if data.role empty, throw error
if (!!!data.role) {
throw new Error("Role cannot be empty");
}
//if data.password not equal to data.plainPassword, throw error
if (data.password !== data.plainPassword) {
throw new Error("Passwords do not match");
@@ -182,7 +189,7 @@ export const CreateNewUser = async (currentUser: { role: string }, data: NewUser
email: normalizedEmail,
password_hash: await HashPassword(data.password),
name: normalizedName,
role: data.role,
role_ids: data.role_ids,
};
return await db.insertUser(user);
};
@@ -202,7 +209,7 @@ export const CreateFirstUser = async (data: { email: string; name: string; passw
email: normalizedEmail,
password_hash: await HashPassword(data.password),
name: normalizedName,
role: "admin",
role_ids: ["admin"],
is_owner: "YES",
};
return await db.insertUser(user);
@@ -229,33 +236,34 @@ export const UpdatePassword = async (data: PasswordUpdateInput): Promise<number>
});
};
const VALID_ROLES = ["admin", "editor", "member"] as const;
export const ManualUpdateUserData = async (
byUser: { id: number; role: string; is_owner: string },
forUserId: number,
data: ManualUserUpdateInput,
): Promise<number | undefined> => {
export const ManualUpdateUserData = async (forUserId: number, data: ManualUserUpdateInput): Promise<number | void> => {
let forUser = await db.getUserById(forUserId);
if (!forUser) {
throw new Error("User not found");
}
//only admins can update
if (byUser.role !== "admin") {
throw new Error("You do not have permission to update user");
}
// non-owner admins cannot modify other admins (self-updates are allowed)
if (forUser.role === "admin" && byUser.is_owner !== "YES" && forUser.id !== byUser.id) {
throw new Error("Only the owner can modify other admins");
}
if (data.updateType == "role") {
if (!data.role) throw new Error("Role is required");
if (!VALID_ROLES.includes(data.role as (typeof VALID_ROLES)[number])) {
throw new Error(`Invalid role. Must be one of: ${VALID_ROLES.join(", ")}`);
if (!data.role_ids || data.role_ids.length === 0) throw new Error("At least one role is required");
// Owner must always retain the admin role
if (forUser.is_owner === "YES" && !data.role_ids.includes("admin")) {
throw new Error("Owner must retain the admin role");
}
return await db.updateUserRole(forUser.id, data.role);
// Validate all role_ids exist and are active
for (const roleId of data.role_ids) {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" does not exist`);
}
if (role.status !== "ACTIVE") {
throw new Error(`Role "${roleId}" is not active`);
}
}
return await db.updateUserRoles(forUser.id, data.role_ids);
} else if (data.updateType == "is_active") {
if (data.is_active === undefined) throw new Error("is_active is required");
// Owner cannot be deactivated
if (forUser.is_owner === "YES" && data.is_active === 0) {
throw new Error("Owner account cannot be deactivated");
}
return await db.updateUserIsActive(forUser.id, data.is_active);
} else if (data.updateType == "password") {
if (!data.password || !data.passwordPlain) throw new Error("Password is required");
@@ -297,15 +305,20 @@ export const GetTotalUserPages = async (limit: number): Promise<number> => {
};
//send invitation email to user for account creation
export const SendInvitationEmail = async (email: string, role: string, name: string, currentUserRole: string) => {
if (currentUserRole === "member") {
throw new Error("Only admins and editors can create new users");
export const SendInvitationEmail = async (email: string, role_ids: string[], name: string) => {
if (!role_ids || role_ids.length === 0) {
throw new Error("At least one role is required");
}
// Admins can add admin, editor, member; Editors can only add editor, member
const acceptedRoles = currentUserRole === "admin" ? ["admin", "editor", "member"] : ["editor", "member"];
if (!acceptedRoles.includes(role)) {
throw new Error("Invalid role");
// Validate all role_ids exist and are active
for (const roleId of role_ids) {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" does not exist`);
}
if (role.status !== "ACTIVE") {
throw new Error(`Role "${roleId}" is not active`);
}
}
const normalizedEmail = validateEmailOrThrow(email);
@@ -323,7 +336,7 @@ export const SendInvitationEmail = async (email: string, role: string, name: str
email: normalizedEmail,
password_hash: "",
name: normalizedName,
role,
role_ids: role_ids,
is_active: 0,
});
} catch (error: unknown) {
@@ -364,11 +377,7 @@ export const SendInvitationEmail = async (email: string, role: string, name: str
};
//resend invitation email to existing user with blank password
export const ResendInvitationEmail = async (email: string, currentUserRole: string) => {
if (currentUserRole === "member") {
throw new Error("Only admins and editors can resend invitations");
}
export const ResendInvitationEmail = async (email: string) => {
const normalizedEmail = validateEmailOrThrow(email);
const user = await db.getUserByEmail(normalizedEmail);
@@ -410,17 +419,11 @@ export const ResendInvitationEmail = async (email: string, currentUserRole: stri
};
// send verification email with verification link
export const SendVerificationEmail = async (toUserId: number, currentUser: { id: number; role: string }) => {
export const SendVerificationEmail = async (toUserId: number, currentUserId: number) => {
if (!toUserId) {
throw new Error("User ID is required");
}
// Only admins/editors can send verification to other users.
// Members can only send verification email to themselves.
if (currentUser.role === "member" && currentUser.id !== toUserId) {
throw new Error("You do not have permission to send verification email for this user");
}
const user = await db.getUserById(toUserId);
if (!user) {
throw new Error("User not found");
@@ -458,3 +461,221 @@ export const SendVerificationEmail = async (toUserId: number, currentUser: { id:
template.template_text_body || "",
);
};
const RESTRICTED_ROLE_IDS = ["admin", "editor", "member"];
const ROLE_ID_REGEX = /^[a-z0-9_-]+$/;
const normalizeRoleId = (id: string): string => {
return id.trim().toLowerCase().replace(/\s+/g, "_");
};
export const CreateRole = async (data: { role_id: string; name: string }): Promise<RoleRecord> => {
const roleId = normalizeRoleId(data.role_id || "");
const roleName = data.name?.trim();
if (!roleId) {
throw new Error("Role ID is required");
}
if (!ROLE_ID_REGEX.test(roleId)) {
throw new Error("Role ID can only contain lowercase letters, numbers, underscores, and hyphens");
}
if (!roleName) {
throw new Error("Role name is required");
}
if (RESTRICTED_ROLE_IDS.includes(roleId)) {
throw new Error(`Role ID "${roleId}" is restricted and cannot be used`);
}
const existing = await db.getRoleById(roleId);
if (existing) {
throw new Error(`Role with ID "${roleId}" already exists`);
}
await db.insertRole({ id: roleId, role_name: roleName });
const created = await db.getRoleById(roleId);
if (!created) {
throw new Error("Failed to create role");
}
return created;
};
export const UpdateRole = async (roleId: string, data: { name?: string; status?: string }): Promise<RoleRecord> => {
if (!roleId) {
throw new Error("Role ID is required");
}
const existing = await db.getRoleById(roleId);
if (!existing) {
throw new Error(`Role "${roleId}" not found`);
}
if (existing.readonly === 1) {
throw new Error("Readonly roles cannot be updated");
}
const updates: { role_name?: string; status?: string } = {};
if (data.name !== undefined) {
const trimmed = data.name.trim();
if (!trimmed) {
throw new Error("Role name cannot be empty");
}
updates.role_name = trimmed;
}
if (data.status !== undefined) {
if (data.status !== "ACTIVE" && data.status !== "INACTIVE") {
throw new Error("Status must be ACTIVE or INACTIVE");
}
updates.status = data.status;
}
if (Object.keys(updates).length === 0) {
throw new Error("No valid fields to update");
}
await db.updateRole(roleId, updates);
const updated = await db.getRoleById(roleId);
if (!updated) {
throw new Error("Failed to retrieve updated role");
}
return updated;
};
export const DeleteRole = async (
roleId: string,
options: { action: "migrate"; targetRoleId: string } | { action: "remove" },
): Promise<{ success: true }> => {
if (!roleId) {
throw new Error("Role ID is required");
}
const existing = await db.getRoleById(roleId);
if (!existing) {
throw new Error(`Role "${roleId}" not found`);
}
if (existing.readonly === 1) {
throw new Error("Readonly roles cannot be deleted");
}
if (options.action === "migrate") {
const targetRoleId = options.targetRoleId?.trim();
if (!targetRoleId) {
throw new Error("Target role ID is required for migration");
}
if (targetRoleId === roleId) {
throw new Error("Target role cannot be the same as the role being deleted");
}
const targetRole = await db.getRoleById(targetRoleId);
if (!targetRole) {
throw new Error(`Target role "${targetRoleId}" not found`);
}
if (targetRole.status !== "ACTIVE") {
throw new Error("Cannot migrate users to an inactive role");
}
await db.migrateUsersRole(roleId, targetRoleId);
}
// CASCADE on FK will clean up users_roles and roles_permissions
await db.deleteRole(roleId);
return { success: true };
};
export const GetAllRoles = async (): Promise<RoleRecord[]> => {
return await db.getAllRoles();
};
export const GetAllPermissions = async () => {
return await db.getAllPermissions();
};
export const GetRolePermissions = async (roleId: string) => {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" not found`);
}
return await db.getRolePermissions(roleId);
};
export const UpdateRolePermissions = async (roleId: string, permissionIds: string[]) => {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" not found`);
}
if (role.readonly === 1) {
throw new Error("Readonly roles cannot have their permissions modified");
}
// Get current permissions
const current = await db.getRolePermissions(roleId);
const currentIds = new Set(current.map((p) => p.permissions_id));
const desiredIds = new Set(permissionIds);
// Add new permissions
for (const pid of permissionIds) {
if (!currentIds.has(pid)) {
await db.addRolePermission(roleId, pid);
}
}
// Remove old permissions
for (const pid of currentIds) {
if (!desiredIds.has(pid)) {
await db.removeRolePermission(roleId, pid);
}
}
return await db.getRolePermissions(roleId);
};
export const GetRoleUsers = async (roleId: string) => {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" not found`);
}
return await db.getUsersByRoleId(roleId);
};
export const AddUserToRole = async (roleId: string, userId: number) => {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" not found`);
}
if (role.status !== "ACTIVE") {
throw new Error(`Role "${roleId}" is not active`);
}
// Check if user already in role
const users = await db.getUsersByRoleId(roleId);
if (users.some((u) => u.id === userId)) {
throw new Error("User is already assigned to this role");
}
await db.addUserToRole(roleId, userId);
return { success: true };
};
export const RemoveUserFromRole = async (roleId: string, userId: number) => {
if (roleId === "admin") {
const user = await db.getUserById(userId);
if (user && user.is_owner === "YES") {
throw new Error("The owner cannot be removed from the admin role");
}
}
await db.removeUserFromRole(roleId, userId);
return { success: true };
};
export const GetUserPermissions = async (userId: number): Promise<Set<string>> => {
const permissionIds = await db.getUserPermissionIds(userId);
return new Set(permissionIds);
};
export const RequirePermission = (userPermissions: Set<string>, permissionId: string): void => {
if (!userPermissions.has(permissionId)) {
throw new Error("You do not have permission to perform this action");
}
};
+2 -2
View File
@@ -1,5 +1,5 @@
import DbImpl from "./dbimpl";
import knexOb from "../../../../knexfile.js";
import knexOb, { workerKnexOb } from "../../../../knexfile.js";
const instance: DbImpl = new DbImpl(knexOb);
const instance: DbImpl = new DbImpl(knexOb, workerKnexOb);
export default instance;
+77 -6
View File
@@ -1,5 +1,6 @@
import Knex from "knex";
import type { Knex as KnexType } from "knex";
import { runWithWorkerKnex } from "./poolContext.js";
// Import all repositories
import { MonitoringRepository } from "./repositories/monitoring.js";
@@ -29,6 +30,9 @@ export type * from "../types/db.js";
*/
class DbImpl {
private knex: KnexType;
// Dedicated pool for background jobs (Postgres/MySQL). Equals `knex` when
// there is no separate worker pool (e.g. SQLite).
private workerKnex: KnexType;
// Domain repositories
private monitoring!: MonitoringRepository;
@@ -48,7 +52,6 @@ class DbImpl {
// ============ Monitoring Data ============
insertMonitoringData!: MonitoringRepository["insertMonitoringData"];
getMonitoringData!: MonitoringRepository["getMonitoringData"];
getMonitoringDataAll!: MonitoringRepository["getMonitoringDataAll"];
getLatestMonitoringData!: MonitoringRepository["getLatestMonitoringData"];
getLatestMonitoringDataN!: MonitoringRepository["getLatestMonitoringDataN"];
getMonitoringDataPaginated!: MonitoringRepository["getMonitoringDataPaginated"];
@@ -65,11 +68,15 @@ class DbImpl {
consecutivelyStatusFor!: MonitoringRepository["consecutivelyStatusFor"];
consecutivelyLatencyGreaterThan!: MonitoringRepository["consecutivelyLatencyGreaterThan"];
consecutivelyLatencyLessThan!: MonitoringRepository["consecutivelyLatencyLessThan"];
getRecentSamplesForConfirmation!: MonitoringRepository["getRecentSamplesForConfirmation"];
getLastObservedStatus!: MonitoringRepository["getLastObservedStatus"];
backfillConfirmedStatus!: MonitoringRepository["backfillConfirmedStatus"];
updateMonitoringData!: MonitoringRepository["updateMonitoringData"];
deleteMonitorDataByTag!: MonitoringRepository["deleteMonitorDataByTag"];
getStatusCountsByInterval!: MonitoringRepository["getStatusCountsByInterval"];
getStatusCountsByIntervalGroupedByMonitor!: MonitoringRepository["getStatusCountsByIntervalGroupedByMonitor"];
getStatusCountsForLastN!: MonitoringRepository["getStatusCountsForLastN"];
getLastKnownStatus!: MonitoringRepository["getLastKnownStatus"];
// ============ Monitors ============
getMonitorsByTags!: MonitorsRepository["getMonitorsByTags"];
@@ -115,11 +122,29 @@ class DbImpl {
getUsersPaginated!: UsersRepository["getUsersPaginated"];
getTotalUsers!: UsersRepository["getTotalUsers"];
updateUserName!: UsersRepository["updateUserName"];
updateUserRole!: UsersRepository["updateUserRole"];
updateUserRoles!: UsersRepository["updateUserRoles"];
updateUserIsActive!: UsersRepository["updateUserIsActive"];
updateUserPasswordById!: UsersRepository["updateUserPasswordById"];
updateIsVerified!: UsersRepository["updateIsVerified"];
// ============ Roles ============
getRoleById!: UsersRepository["getRoleById"];
getAllRoles!: UsersRepository["getAllRoles"];
insertRole!: UsersRepository["insertRole"];
updateRole!: UsersRepository["updateRole"];
deleteRole!: UsersRepository["deleteRole"];
getUsersCountByRoleId!: UsersRepository["getUsersCountByRoleId"];
migrateUsersRole!: UsersRepository["migrateUsersRole"];
getRolePermissions!: UsersRepository["getRolePermissions"];
getAllPermissions!: UsersRepository["getAllPermissions"];
addRolePermission!: UsersRepository["addRolePermission"];
removeRolePermission!: UsersRepository["removeRolePermission"];
getUsersByRoleId!: UsersRepository["getUsersByRoleId"];
addUserToRole!: UsersRepository["addUserToRole"];
removeUserFromRole!: UsersRepository["removeUserFromRole"];
getUserPermissionIds!: UsersRepository["getUserPermissionIds"];
getUserRoleIds!: UsersRepository["getUserRoleIds"];
// ============ API Keys ============
createNewApiKey!: UsersRepository["createNewApiKey"];
updateApiKeyStatus!: UsersRepository["updateApiKeyStatus"];
@@ -353,8 +378,11 @@ class DbImpl {
deleteEmailTemplate!: EmailTemplateConfigRepository["deleteEmailTemplate"];
upsertEmailTemplate!: EmailTemplateConfigRepository["upsertEmailTemplate"];
constructor(opts: KnexType.Config) {
constructor(opts: KnexType.Config, workerOpts?: KnexType.Config | null) {
this.knex = Knex(opts);
// Separate pool for background jobs when configured (Postgres/MySQL);
// otherwise reuse the web pool (SQLite has a single connection).
this.workerKnex = workerOpts ? Knex(workerOpts) : this.knex;
// Initialize repositories
this.monitoring = new MonitoringRepository(this.knex);
@@ -390,7 +418,6 @@ class DbImpl {
private bindMonitoringMethods(): void {
this.insertMonitoringData = this.monitoring.insertMonitoringData.bind(this.monitoring);
this.getMonitoringData = this.monitoring.getMonitoringData.bind(this.monitoring);
this.getMonitoringDataAll = this.monitoring.getMonitoringDataAll.bind(this.monitoring);
this.getLatestMonitoringData = this.monitoring.getLatestMonitoringData.bind(this.monitoring);
this.getLatestMonitoringDataN = this.monitoring.getLatestMonitoringDataN.bind(this.monitoring);
this.getMonitoringDataPaginated = this.monitoring.getMonitoringDataPaginated.bind(this.monitoring);
@@ -407,6 +434,9 @@ class DbImpl {
this.consecutivelyStatusFor = this.monitoring.consecutivelyStatusFor.bind(this.monitoring);
this.consecutivelyLatencyGreaterThan = this.monitoring.consecutivelyLatencyGreaterThan.bind(this.monitoring);
this.consecutivelyLatencyLessThan = this.monitoring.consecutivelyLatencyLessThan.bind(this.monitoring);
this.getRecentSamplesForConfirmation = this.monitoring.getRecentSamplesForConfirmation.bind(this.monitoring);
this.getLastObservedStatus = this.monitoring.getLastObservedStatus.bind(this.monitoring);
this.backfillConfirmedStatus = this.monitoring.backfillConfirmedStatus.bind(this.monitoring);
this.updateMonitoringData = this.monitoring.updateMonitoringData.bind(this.monitoring);
this.deleteMonitorDataByTag = this.monitoring.deleteMonitorDataByTag.bind(this.monitoring);
this.getStatusCountsByInterval = this.monitoring.getStatusCountsByInterval.bind(this.monitoring);
@@ -414,6 +444,7 @@ class DbImpl {
this.monitoring,
);
this.getStatusCountsForLastN = this.monitoring.getStatusCountsForLastN.bind(this.monitoring);
this.getLastKnownStatus = this.monitoring.getLastKnownStatus.bind(this.monitoring);
}
private bindMonitorsMethods(): void {
@@ -460,7 +491,7 @@ class DbImpl {
this.getUsersPaginated = this.users.getUsersPaginated.bind(this.users);
this.getTotalUsers = this.users.getTotalUsers.bind(this.users);
this.updateUserName = this.users.updateUserName.bind(this.users);
this.updateUserRole = this.users.updateUserRole.bind(this.users);
this.updateUserRoles = this.users.updateUserRoles.bind(this.users);
this.updateUserIsActive = this.users.updateUserIsActive.bind(this.users);
this.updateUserPasswordById = this.users.updateUserPasswordById.bind(this.users);
this.updateIsVerified = this.users.updateIsVerified.bind(this.users);
@@ -469,6 +500,24 @@ class DbImpl {
this.deleteApiKey = this.users.deleteApiKey.bind(this.users);
this.getApiKeyByHashedKey = this.users.getApiKeyByHashedKey.bind(this.users);
this.getAllApiKeys = this.users.getAllApiKeys.bind(this.users);
// Roles
this.getRoleById = this.users.getRoleById.bind(this.users);
this.getAllRoles = this.users.getAllRoles.bind(this.users);
this.insertRole = this.users.insertRole.bind(this.users);
this.updateRole = this.users.updateRole.bind(this.users);
this.deleteRole = this.users.deleteRole.bind(this.users);
this.getUsersCountByRoleId = this.users.getUsersCountByRoleId.bind(this.users);
this.migrateUsersRole = this.users.migrateUsersRole.bind(this.users);
this.getRolePermissions = this.users.getRolePermissions.bind(this.users);
this.getAllPermissions = this.users.getAllPermissions.bind(this.users);
this.addRolePermission = this.users.addRolePermission.bind(this.users);
this.removeRolePermission = this.users.removeRolePermission.bind(this.users);
this.getUsersByRoleId = this.users.getUsersByRoleId.bind(this.users);
this.addUserToRole = this.users.addUserToRole.bind(this.users);
this.removeUserFromRole = this.users.removeUserFromRole.bind(this.users);
this.getUserPermissionIds = this.users.getUserPermissionIds.bind(this.users);
this.getUserRoleIds = this.users.getUserRoleIds.bind(this.users);
}
private bindSiteDataMethods(): void {
@@ -798,8 +847,30 @@ class DbImpl {
async init(): Promise<void> {}
/**
* Runs `fn` with all repository queries routed to the worker connection pool.
* Wrap background work (BullMQ job processors, schedulers) with this so a
* burst of jobs cannot exhaust the web pool that serves page loads.
*/
runInWorkerContext<T>(fn: () => Promise<T>): Promise<T> {
return runWithWorkerKnex(this.workerKnex, fn);
}
/** Probes database connectivity with a trivial query. Never throws. */
async ping(): Promise<boolean> {
try {
await this.knex.raw("select 1");
return true;
} catch {
return false;
}
}
async close(): Promise<void> {
return await this.knex.destroy();
await this.knex.destroy();
if (this.workerKnex !== this.knex) {
await this.workerKnex.destroy();
}
}
}
+26
View File
@@ -0,0 +1,26 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type { Knex as KnexType } from "knex";
// Per-execution-context selection of the database connection pool.
//
// Kener runs SvelteKit requests, the cron scheduler, and the BullMQ workers in
// a single process, all sharing one Knex instance. A burst of background jobs
// could therefore exhaust the connection pool and time out user-facing page
// loads (KnexTimeoutError on acquire). To prevent that, background work runs
// against a dedicated worker pool: queues/q.ts wraps every job processor in
// runWithWorkerKnex(), and BaseRepository reads getWorkerKnex() so its queries
// route to that pool. Anything outside a job (requests, startup, migrations)
// has no store set and falls back to the web pool.
//
// See knexfile.ts for pool sizing and docs .../setup/database-setup.md.
const workerKnexStorage = new AsyncLocalStorage<KnexType>();
/** Runs `fn` with all repository queries routed to the worker pool `knex`. */
export function runWithWorkerKnex<T>(knex: KnexType, fn: () => Promise<T>): Promise<T> {
return workerKnexStorage.run(knex, fn);
}
/** The worker pool for the current context, or undefined when not in a job. */
export function getWorkerKnex(): KnexType | undefined {
return workerKnexStorage.getStore();
}
+16 -2
View File
@@ -1,4 +1,5 @@
import type { Knex as KnexType } from "knex";
import { getWorkerKnex } from "../poolContext.js";
// Filter types for queries
export interface MonitorFilter {
@@ -35,9 +36,22 @@ export interface CountResult {
* Base repository class that provides access to the Knex instance
*/
export abstract class BaseRepository {
protected knex: KnexType;
private readonly fallbackKnex: KnexType;
constructor(knex: KnexType) {
this.knex = knex;
this.fallbackKnex = knex;
}
/**
* The Knex instance for the current execution context.
*
* Background jobs run inside a worker-pool context (set in queues/q.ts), so
* their queries use the dedicated worker connection pool. Everything else —
* SvelteKit requests, startup — falls back to the web pool this repository
* was constructed with. This keeps a burst of background jobs from exhausting
* the connections that serve page loads. See poolContext.ts and knexfile.ts.
*/
protected get knex(): KnexType {
return getWorkerKnex() ?? this.fallbackKnex;
}
}
@@ -483,6 +483,7 @@ export class MaintenancesRepository extends BaseRepository {
"maintenances_events.created_at",
"maintenances_events.updated_at",
"maintenances_events.status as status",
"maintenances.is_global as is_global",
)
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
@@ -516,6 +517,7 @@ export class MaintenancesRepository extends BaseRepository {
"maintenances_events.created_at",
"maintenances_events.updated_at",
"maintenances_events.status as status",
"maintenances.is_global as is_global",
)
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
@@ -564,6 +566,7 @@ export class MaintenancesRepository extends BaseRepository {
"monitors.is_hidden as monitor_is_hidden",
"maintenances_events.created_at",
"maintenances_events.updated_at",
"maintenances.is_global as is_global",
)
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
@@ -606,6 +609,7 @@ export class MaintenancesRepository extends BaseRepository {
"maintenances_events.created_at",
"maintenances_events.updated_at",
"maintenances_events.status as status",
"maintenances.is_global as is_global",
)
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
@@ -648,6 +652,7 @@ export class MaintenancesRepository extends BaseRepository {
"maintenances_events.created_at",
"maintenances_events.updated_at",
"maintenances_events.status as status",
"maintenances.is_global as is_global",
)
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
@@ -686,6 +691,7 @@ export class MaintenancesRepository extends BaseRepository {
"maintenances_events.created_at",
"maintenances_events.updated_at",
"maintenances_events.status as status",
"maintenances.is_global as is_global",
)
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
@@ -714,6 +720,7 @@ export class MaintenancesRepository extends BaseRepository {
start_date_time: row.start_date_time,
end_date_time: row.end_date_time,
status: row.status,
is_global: row.is_global,
created_at: row.created_at,
updated_at: row.updated_at,
monitors: [],
@@ -141,9 +141,17 @@ export class MonitorAlertConfigRepository extends BaseRepository {
}
/**
* Delete a monitor alert config by ID
* Delete a monitor alert config by ID, including all child rows.
*
* Child rows are removed explicitly even though FK cascades are declared:
* SQLite never enforces them (foreign_keys pragma is off), so relying on
* CASCADE orphans children on the default deployment. See
* docs/adr/0008-explicit-deletes-over-fk-cascades.md.
*/
async deleteMonitorAlertConfig(id: number): Promise<number> {
await this.knex("monitor_alerts_v2").where({ config_id: id }).del();
await this.knex("monitor_alerts_config_triggers").where({ monitor_alerts_id: id }).del();
await this.knex("monitor_alerts_config_monitors").where({ monitor_alerts_id: id }).del();
return await this.knex("monitor_alerts_config").where({ id }).del();
}
@@ -158,7 +166,11 @@ export class MonitorAlertConfigRepository extends BaseRepository {
if (configIds.length === 0) return 0;
// Remove the monitor from the junction table
// Remove the monitor from the junction table, along with its per-monitor
// alert state — a shared config survives the detach, but its v2 rows for
// this tag would otherwise dangle (see deleteMonitorAlertConfig on why
// FK cascades can't be relied on)
await this.knex("monitor_alerts_v2").where({ monitor_tag: monitorTag }).del();
await this.knex("monitor_alerts_config_monitors").where({ monitor_tag: monitorTag }).del();
// Delete any configs that now have zero monitors
@@ -170,7 +182,7 @@ export class MonitorAlertConfigRepository extends BaseRepository {
.where({ monitor_alerts_id: id })
.first<CountResult>();
if (Number(remainingMonitors?.count) === 0) {
await this.knex("monitor_alerts_config").where({ id }).del();
await this.deleteMonitorAlertConfig(id);
deletedCount++;
}
}
+136 -27
View File
@@ -10,18 +10,42 @@ import type {
TimestampStatusCountByMonitor,
} from "../../types/db.js";
/**
* 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];
/**
* Scheduled-check sample types that count toward a monitor's Confirmation Threshold
* (issue #712). Intentionally narrower than ALERT_VISIBLE_TYPES: MANUAL pushes
* and DEFAULT_STATUS fill stay transparent to threshold counting.
*/
const OBSERVED_CHECK_TYPES = [GC.REALTIME, GC.TIMEOUT, GC.ERROR];
/**
* Overlay sample types that FREEZE Confirmation Threshold counting (issue #712):
* while one is active the count does not advance, and it acts as a hard boundary the
* pending run cannot cross. Included in the confirmation lookback (unlike MANUAL/DEFAULT,
* which stay transparent) so the resolver can detect the boundary.
*/
const OVERLAY_TYPES = [GC.INCIDENT, GC.MAINTENANCE];
/**
* Repository for monitoring data operations
*/
export class MonitoringRepository extends BaseRepository {
async insertMonitoringData(data: MonitoringDataInsert): Promise<MonitoringData | null> {
const { monitor_tag, timestamp, status, latency, type, error_message } = data;
const { monitor_tag, timestamp, status, latency, type, error_message, raw_status } = data;
// Perform insert/update - works across PostgreSQL, MySQL, and SQLite
await this.knex("monitoring_data")
.insert({ monitor_tag, timestamp, status, latency, type, error_message })
.insert({ monitor_tag, timestamp, status, latency, type, error_message, raw_status })
.onConflict(["monitor_tag", "timestamp"])
.merge({ status, latency, type, error_message });
.merge({ status, latency, type, error_message, raw_status });
// Query and return the inserted/updated record (works consistently across all databases)
const record = await this.knex("monitoring_data")
@@ -40,27 +64,6 @@ export class MonitoringRepository extends BaseRepository {
.orderBy("timestamp", "asc");
}
// Groups by timestamp and applies priority: DOWN > DEGRADED > UP
async getMonitoringDataAll(monitor_tags: string[], start: number, end: number): Promise<MonitoringData[]> {
return await this.knex("monitoring_data")
.select(
"timestamp",
this.knex.raw(`
CASE
WHEN MAX(CASE WHEN status = 'DOWN' THEN 1 ELSE 0 END) = 1 THEN 'DOWN'
WHEN MAX(CASE WHEN status = 'DEGRADED' THEN 1 ELSE 0 END) = 1 THEN 'DEGRADED'
ELSE 'UP'
END as status
`),
)
.whereIn("monitor_tag", monitor_tags)
.where("timestamp", ">=", start)
.where("timestamp", "<=", end)
.whereNotNull("status")
.groupBy("timestamp")
.orderBy("timestamp", "asc");
}
async getLatestMonitoringData(monitor_tag: string): Promise<MonitoringData | undefined> {
return await this.knex("monitoring_data")
.where("monitor_tag", monitor_tag)
@@ -262,7 +265,7 @@ export class MonitoringRepository extends BaseRepository {
qb.select("*")
.from("monitoring_data")
.where("monitor_tag", monitor_tag)
.andWhere("type", "=", GC.REALTIME)
.whereIn("type", ALERT_VISIBLE_TYPES)
.orderBy("timestamp", "desc")
.limit(lastX);
})
@@ -288,7 +291,7 @@ export class MonitoringRepository extends BaseRepository {
qb.select("*")
.from("monitoring_data")
.where("monitor_tag", monitor_tag)
.andWhere("type", "=", GC.REALTIME)
.whereIn("type", ALERT_VISIBLE_TYPES)
.orderBy("timestamp", "desc")
.limit(lastX);
})
@@ -310,7 +313,7 @@ export class MonitoringRepository extends BaseRepository {
qb.select("*")
.from("monitoring_data")
.where("monitor_tag", monitor_tag)
.andWhere("type", "=", GC.REALTIME)
.whereIn("type", ALERT_VISIBLE_TYPES)
.orderBy("timestamp", "desc")
.limit(lastX);
})
@@ -326,6 +329,107 @@ export class MonitoringRepository extends BaseRepository {
return result.is_recovered === 1;
}
/**
* Recent samples the Confirmation Threshold resolver needs, newest first: scheduled-check
* observations (REALTIME/TIMEOUT/ERROR) plus incident/maintenance overlays. MANUAL pushes
* and DEFAULT fill are excluded — they stay transparent to the counter. Returns `type` so
* the resolver can stop at overlay rows (freeze). Observations whose status is NO_DATA are
* excluded entirely (neutral — they neither advance nor reset the count and must not consume lookback slots).
*/
async getRecentSamplesForConfirmation(
monitor_tag: string,
beforeTs: number,
limit: number,
): Promise<Array<{ timestamp: number; status: string | null; raw_status: string | null; type: string | null }>> {
return await this.knex("monitoring_data")
.select("timestamp", "status", "raw_status", "type")
.where("monitor_tag", monitor_tag)
.where("timestamp", "<", beforeTs)
.whereIn("type", [...OBSERVED_CHECK_TYPES, ...OVERLAY_TYPES])
.whereNot("status", GC.NO_DATA)
.orderBy("timestamp", "desc")
.limit(limit);
}
/**
* The committed status of the most recent real scheduled-check observation before `beforeTs`
* — the Confirmation Threshold "anchor" (the side currently shown). Looks past overlays,
* MANUAL/DEFAULT, and NO_DATA so a long incident/maintenance window can never hide the anchor
* (issue #712). Returns null when there is no prior observation (cold start).
*/
async getLastObservedStatus(monitor_tag: string, beforeTs: number): Promise<string | null> {
const row = await this.knex("monitoring_data")
.select("status")
.where("monitor_tag", monitor_tag)
.where("timestamp", "<", beforeTs)
.whereIn("type", OBSERVED_CHECK_TYPES)
.whereNot("status", GC.NO_DATA)
.orderBy("timestamp", "desc")
.limit(1)
.first();
return row ? (row.status ?? null) : null;
}
/**
* Backfill a confirmed status flip: set each row's committed status to its observed raw_status.
* `confirmThreshold` is the number of consecutive checks that confirmed the flip — when it is a
* number the run resolved to an unhealthy side and a per-row note ("Down"/"Degraded confirmed
* after N consecutive checks", matching each row's own severity) is appended to the existing
* error text; when it is null the run resolved to UP (recovery) and the error text is cleared.
*/
async backfillConfirmedStatus(
monitor_tag: string,
timestamps: number[],
confirmThreshold: number | null,
): Promise<number> {
if (timestamps.length === 0) return 0;
// Recovery (confirmed UP): rows become the UP side — clear any held error text in one update.
if (confirmThreshold === null) {
return await this.knex("monitoring_data")
.where("monitor_tag", monitor_tag)
.whereIn("timestamp", timestamps)
.whereNotNull("raw_status")
.update({
status: this.knex.ref("raw_status"),
error_message: null,
});
}
// Confirmed unhealthy: set each row's status from its observed raw_status and APPEND a
// severity-matched confirmation note to the existing error text (preserving the observed
// failure reason). Done per-row for portable string concatenation (|| vs CONCAT differ across
// SQLite/PG/MySQL), per-row severity wording, and idempotency if the backfill is replayed.
// The whole read+update window runs in one transaction — a confirmation flip is one logical
// write, so it must not leave the window half-confirmed/half-held if a row update fails.
return await this.knex.transaction(async (trx: KnexType.Transaction) => {
const rows = await trx("monitoring_data")
.select("timestamp", "error_message", "raw_status")
.where("monitor_tag", monitor_tag)
.whereIn("timestamp", timestamps)
.whereNotNull("raw_status");
let updated = 0;
for (const row of rows) {
const severity = row.raw_status === GC.DEGRADED ? "Degraded" : "Down";
const note = `${severity} confirmed after ${confirmThreshold} consecutive checks`;
const existing: string | null = row.error_message;
let nextMessage: string;
if (!existing) {
nextMessage = note;
} else if (existing.indexOf(note) !== -1) {
nextMessage = existing; // already appended — keep idempotent
} else {
nextMessage = `${existing} | ${note}`;
}
updated += await trx("monitoring_data")
.where({ monitor_tag, timestamp: row.timestamp })
.update({ status: row.raw_status, error_message: nextMessage });
}
return updated;
});
}
async updateMonitoringData(
monitor_tag: string,
start: number,
@@ -587,4 +691,9 @@ export class MonitoringRepository extends BaseRepository {
minLatency: Number(result?.min_latency) || 0,
};
}
//get the last known status for a monitor
async getLastKnownStatus(monitor_tag: string): Promise<MonitoringData | undefined> {
return await this.knex("monitoring_data").where("monitor_tag", monitor_tag).orderBy("timestamp", "desc").first();
}
}
@@ -2,6 +2,17 @@ import type { Knex as KnexType } from "knex";
import { BaseRepository, type MonitorFilter, type CountResult } from "./base.js";
import type { MonitorRecord, MonitorRecordInsert } from "../../types/db.js";
/**
* Clamp the Confirmation Threshold to its 160 invariant at the data layer, so the bound holds
* for every app write path (v4 API, manage API, clone, group), not only the v4 API validator.
* A non-finite/missing value defaults to 1 (off).
*/
function clampConfirmationThreshold(value: number | null | undefined): number {
const n = Math.round(Number(value));
if (!Number.isFinite(n)) return 1;
return Math.min(60, Math.max(1, n));
}
/**
* Repository for monitors CRUD operations
*/
@@ -28,6 +39,7 @@ export class MonitorsRepository extends BaseRepository {
type_data: data.type_data,
day_degraded_minimum_count: data.day_degraded_minimum_count,
day_down_minimum_count: data.day_down_minimum_count,
confirmation_threshold: clampConfirmationThreshold(data.confirmation_threshold),
include_degraded_in_downtime: data.include_degraded_in_downtime,
is_hidden: data.is_hidden || "NO",
monitor_settings_json: data.monitor_settings_json,
@@ -51,6 +63,7 @@ export class MonitorsRepository extends BaseRepository {
type_data: data.type_data,
day_degraded_minimum_count: data.day_degraded_minimum_count,
day_down_minimum_count: data.day_down_minimum_count,
confirmation_threshold: clampConfirmationThreshold(data.confirmation_threshold),
include_degraded_in_downtime: data.include_degraded_in_downtime,
is_hidden: data.is_hidden,
monitor_settings_json: data.monitor_settings_json,
+238 -20
View File
@@ -1,5 +1,14 @@
import { BaseRepository, type CountResult } from "./base.js";
import type { UserRecordInsert, UserRecordPublic, ApiKeyRecord, ApiKeyRecordInsert } from "../../types/db.js";
import type {
UserRecordInsert,
UserRecordPublic,
ApiKeyRecord,
ApiKeyRecordInsert,
RoleRecord,
RolePermissionRecord,
UserRoleRecord,
} from "../../types/db.js";
import { GetDbType } from "../../tool.js";
/**
* Repository for users, API keys operations
@@ -11,11 +20,46 @@ export class UsersRepository extends BaseRepository {
return await this.knex("users").count("* as count").first<CountResult>();
}
private readonly userColumns = [
"id",
"email",
"name",
"is_active",
"is_verified",
"is_owner",
"created_at",
"updated_at",
] as const;
private async enrichWithRoleIds(user: Record<string, unknown>): Promise<UserRecordPublic> {
const roleIds = await this.getUserRoleIds(user.id as number);
return { ...user, role_ids: roleIds } as UserRecordPublic;
}
private async enrichManyWithRoleIds(users: Record<string, unknown>[]): Promise<UserRecordPublic[]> {
if (users.length === 0) return [];
const userIds = users.map((u) => u.id as number);
const roleRows = await this.knex("users_roles")
.join("roles", "users_roles.roles_id", "roles.id")
.whereIn("users_roles.users_id", userIds)
.where("roles.status", "ACTIVE")
.select("users_roles.users_id as users_id", "roles.id as role_id");
const roleMap = new Map<number, string[]>();
for (const row of roleRows) {
const list = roleMap.get(row.users_id) || [];
list.push(row.role_id);
roleMap.set(row.users_id, list);
}
return users.map((u) => ({ ...u, role_ids: roleMap.get(u.id as number) || [] }) as UserRecordPublic);
}
async getUserByEmail(email: string): Promise<UserRecordPublic | undefined> {
return await this.knex("users")
.select("id", "email", "name", "is_active", "is_verified", "is_owner", "role", "created_at", "updated_at")
const row = await this.knex("users")
.select(...this.userColumns)
.where("email", email)
.first();
if (!row) return undefined;
return await this.enrichWithRoleIds(row);
}
async getUserPasswordHashById(id: number): Promise<{ password_hash: string } | undefined> {
@@ -28,22 +72,43 @@ export class UsersRepository extends BaseRepository {
}
async getUserById(id: number): Promise<UserRecordPublic | undefined> {
return await this.knex("users")
.select("id", "email", "name", "is_active", "is_verified", "is_owner", "role", "created_at", "updated_at")
const row = await this.knex("users")
.select(...this.userColumns)
.where("id", id)
.first();
if (!row) return undefined;
return await this.enrichWithRoleIds(row);
}
async insertUser(data: UserRecordInsert): Promise<number[]> {
return await this.knex("users").insert({
const dbType = GetDbType();
const insertData = {
email: data.email,
name: data.name,
password_hash: data.password_hash,
role: data.role,
is_owner: data.is_owner || "NO",
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
});
};
let userId: number;
if (dbType === "postgresql") {
const [row] = await this.knex("users").insert(insertData).returning("id");
userId = typeof row === "object" ? (row as { id: number }).id : (row as number);
} else {
const result = await this.knex("users").insert(insertData);
userId = result[0];
}
if (data.role_ids && data.role_ids.length > 0) {
const roleInserts = data.role_ids.map((roleId) => ({
users_id: userId,
roles_id: roleId,
}));
await this.knex("users_roles").insert(roleInserts);
}
return [userId];
}
async updateUserPassword(data: { id: number; password_hash: string }): Promise<number> {
@@ -54,21 +119,31 @@ export class UsersRepository extends BaseRepository {
}
async getAllUsers(): Promise<UserRecordPublic[]> {
return await this.knex("users")
.select("id", "email", "name", "role", "is_active", "is_verified", "is_owner", "created_at", "updated_at")
const rows = await this.knex("users")
.select(...this.userColumns)
.orderBy("created_at", "desc");
return await this.enrichManyWithRoleIds(rows);
}
async getUsersPaginated(page: number, limit: number): Promise<UserRecordPublic[]> {
return await this.knex("users")
.select("id", "email", "name", "role", "is_active", "is_verified", "is_owner", "created_at", "updated_at")
async getUsersPaginated(page: number, limit: number, filter?: { is_active?: number }): Promise<UserRecordPublic[]> {
const query = this.knex("users")
.select(...this.userColumns)
.orderBy("created_at", "desc")
.limit(limit)
.offset((page - 1) * limit);
if (filter?.is_active !== undefined) {
query.where("is_active", filter.is_active);
}
const rows = await query;
return await this.enrichManyWithRoleIds(rows);
}
async getTotalUsers(): Promise<CountResult | undefined> {
return await this.knex("users").count("* as count").first<CountResult>();
async getTotalUsers(filter?: { is_active?: number }): Promise<CountResult | undefined> {
const query = this.knex("users").count("* as count");
if (filter?.is_active !== undefined) {
query.where("is_active", filter.is_active);
}
return await query.first<CountResult>();
}
async updateUserName(id: number, name: string): Promise<number> {
@@ -78,11 +153,18 @@ export class UsersRepository extends BaseRepository {
});
}
async updateUserRole(id: number, role: string): Promise<number> {
return await this.knex("users").where({ id }).update({
role,
updated_at: this.knex.fn.now(),
});
async updateUserRoles(id: number, roleIds: string[]): Promise<void> {
await this.knex("users_roles").where("users_id", id).delete();
if (roleIds.length > 0) {
const inserts = roleIds.map((roleId) => ({
users_id: id,
roles_id: roleId,
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
}));
await this.knex("users_roles").insert(inserts);
}
await this.knex("users").where({ id }).update({ updated_at: this.knex.fn.now() });
}
async updateUserIsActive(id: number, is_active: number): Promise<number> {
@@ -138,4 +220,140 @@ export class UsersRepository extends BaseRepository {
}
// ============ Invitations ============
// ============ Roles ============
async getRoleById(id: string): Promise<RoleRecord | undefined> {
return await this.knex("roles").where("id", id).first();
}
async getAllRoles(): Promise<RoleRecord[]> {
return await this.knex("roles").orderBy("created_at", "asc");
}
async insertRole(data: { id: string; role_name: string; readonly?: number }): Promise<void> {
await this.knex("roles").insert({
id: data.id,
role_name: data.role_name,
readonly: data.readonly ?? 0,
status: "ACTIVE",
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
});
}
async updateRole(id: string, data: { role_name?: string; status?: string }): Promise<number> {
const updateData: Record<string, unknown> = { updated_at: this.knex.fn.now() };
if (data.role_name !== undefined) updateData.role_name = data.role_name;
if (data.status !== undefined) updateData.status = data.status;
return await this.knex("roles").where("id", id).update(updateData);
}
async deleteRole(id: string): Promise<number> {
return await this.knex("roles").where("id", id).delete();
}
async getUsersCountByRoleId(roleId: string): Promise<number> {
const result = await this.knex("users_roles").where("roles_id", roleId).count("* as count").first<CountResult>();
return result ? Number(result.count) : 0;
}
async migrateUsersRole(fromRoleId: string, toRoleId: string): Promise<void> {
// Find users who already have the target role to avoid duplicate PK
const usersWithTarget = this.knex("users_roles").where("roles_id", toRoleId).select("users_id");
// Update users who don't already have the target role
await this.knex("users_roles").where("roles_id", fromRoleId).whereNotIn("users_id", usersWithTarget).update({
roles_id: toRoleId,
updated_at: this.knex.fn.now(),
});
// Delete remaining assignments (users who already had the target role)
await this.knex("users_roles").where("roles_id", fromRoleId).delete();
}
// ============ Role Permissions ============
async getRolePermissions(roleId: string): Promise<RolePermissionRecord[]> {
return await this.knex("roles_permissions").where("roles_id", roleId);
}
async getAllPermissions(): Promise<Array<{ id: string; permission_name: string }>> {
return await this.knex("permissions").select("id", "permission_name").orderBy("id", "asc");
}
async addRolePermission(roleId: string, permissionId: string): Promise<void> {
await this.knex("roles_permissions").insert({
roles_id: roleId,
permissions_id: permissionId,
status: "ACTIVE",
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
});
}
async removeRolePermission(roleId: string, permissionId: string): Promise<number> {
return await this.knex("roles_permissions").where({ roles_id: roleId, permissions_id: permissionId }).delete();
}
// ============ Role Users ============
async getUsersByRoleId(roleId: string): Promise<Array<UserRecordPublic & { roles_id: string }>> {
const rows = await this.knex("users_roles")
.join("users", "users_roles.users_id", "users.id")
.where("users_roles.roles_id", roleId)
.select(
"users.id",
"users.email",
"users.name",
"users.is_active",
"users.is_verified",
"users.is_owner",
"users.created_at",
"users.updated_at",
"users_roles.roles_id",
);
const enriched = await this.enrichManyWithRoleIds(rows);
return enriched.map((u, i) => ({ ...u, roles_id: rows[i].roles_id }));
}
async addUserToRole(roleId: string, userId: number): Promise<void> {
await this.knex("users_roles").insert({
roles_id: roleId,
users_id: userId,
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
});
}
async removeUserFromRole(roleId: string, userId: number): Promise<number> {
return await this.knex("users_roles").where({ roles_id: roleId, users_id: userId }).delete();
}
async getUserRoleIds(userId: number): Promise<string[]> {
const rows = await this.knex("users_roles")
.join("roles", function () {
this.on("users_roles.roles_id", "roles.id");
})
.where("users_roles.users_id", userId)
.where("roles.status", "ACTIVE")
.distinct("roles.id as id")
.select();
return rows.map((r: { id: string }) => r.id);
}
async getUserPermissionIds(userId: number): Promise<string[]> {
const knex = this.knex;
const rows = await knex("users_roles")
.join("roles", function () {
this.on("users_roles.roles_id", "roles.id").andOn("roles.status", knex.raw("?", ["ACTIVE"]));
})
.join("roles_permissions", function () {
this.on("roles_permissions.roles_id", "roles.id").andOn("roles_permissions.status", knex.raw("?", ["ACTIVE"]));
})
.where("users_roles.users_id", userId)
.distinct("roles_permissions.permissions_id as id")
.select();
return rows.map((r: { id: string }) => r.id);
}
}
+2
View File
@@ -135,12 +135,14 @@ const seedSiteData = {
subMenuOptions: {
showShareBadgeMonitor: true,
showShareEmbedMonitor: true,
showRssFeed: true,
},
dataRetentionPolicy: {
enabled: true,
retentionDays: 90,
},
eventDisplaySettings: {
showInlineEvents: false,
incidents: {
enabled: true,
ongoing: { show: true },
+344
View File
@@ -0,0 +1,344 @@
import type { PageSettings, PageSettingsPatch } from "$lib/types/api";
import GC from "$lib/global-constants";
// Stored page_settings_json keys differ from the API contract for the meta
// fields: the manage UI writes camelCase (metaPageTitle, metaPageDescription,
// socialPagePreviewImage) while the v4 API exposes snake_case. The mapping
// lives here, at the storage boundary.
interface StoredPageSettings {
incidents?: unknown;
include_maintenances?: unknown;
monitor_status_history_days?: { desktop?: number; mobile?: number };
monitor_layout_style?: string;
metaPageTitle?: string;
metaPageDescription?: string;
socialPagePreviewImage?: string;
[key: string]: unknown;
}
const HISTORY_DAYS_MIN = GC.STATUS_HISTORY_DAYS_MIN;
const HISTORY_DAYS_MAX = GC.STATUS_HISTORY_DAYS_MAX;
export function getDefaultPageSettings(): PageSettings {
return {
incidents: {
enabled: true,
ongoing: { show: true },
resolved: { show: true, max_count: 5, days_in_past: 7 },
},
include_maintenances: {
enabled: true,
ongoing: {
show: true,
past: { show: true, max_count: 5, days_in_past: 7 },
upcoming: { show: true, max_count: 5, days_in_future: 30 },
},
},
monitor_status_history_days: {
desktop: GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP,
mobile: GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE,
},
monitor_layout_style: GC.DEFAULT_MONITOR_LAYOUT_STYLE,
};
}
function parseStored(storedJson: string | null | undefined): StoredPageSettings {
if (!storedJson) return {};
try {
const parsed = JSON.parse(storedJson);
return typeof parsed === "object" && parsed !== null ? (parsed as StoredPageSettings) : {};
} catch {
return {};
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
// Recursively merges patch into base: objects merge key-by-key, everything
// else replaces. Keys absent from the patch — including ones this module does
// not know about — are left untouched.
function deepMerge(base: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) continue;
const current = result[key];
result[key] = isPlainObject(current) && isPlainObject(value) ? deepMerge(current, value) : value;
}
return result;
}
export function mergePageSettings(defaults: PageSettings, partial?: PageSettingsPatch): PageSettings {
if (!partial) {
return defaults;
}
const merged: PageSettings = {
incidents: {
enabled: partial.incidents?.enabled ?? defaults.incidents.enabled,
ongoing: {
show: partial.incidents?.ongoing?.show ?? defaults.incidents.ongoing.show,
},
resolved: {
show: partial.incidents?.resolved?.show ?? defaults.incidents.resolved.show,
max_count: partial.incidents?.resolved?.max_count ?? defaults.incidents.resolved.max_count,
days_in_past: partial.incidents?.resolved?.days_in_past ?? defaults.incidents.resolved.days_in_past,
},
},
include_maintenances: {
enabled: partial.include_maintenances?.enabled ?? defaults.include_maintenances.enabled,
ongoing: {
show: partial.include_maintenances?.ongoing?.show ?? defaults.include_maintenances.ongoing.show,
past: {
show: partial.include_maintenances?.ongoing?.past?.show ?? defaults.include_maintenances.ongoing.past.show,
max_count:
partial.include_maintenances?.ongoing?.past?.max_count ??
defaults.include_maintenances.ongoing.past.max_count,
days_in_past:
partial.include_maintenances?.ongoing?.past?.days_in_past ??
defaults.include_maintenances.ongoing.past.days_in_past,
},
upcoming: {
show:
partial.include_maintenances?.ongoing?.upcoming?.show ??
defaults.include_maintenances.ongoing.upcoming.show,
max_count:
partial.include_maintenances?.ongoing?.upcoming?.max_count ??
defaults.include_maintenances.ongoing.upcoming.max_count,
days_in_future:
partial.include_maintenances?.ongoing?.upcoming?.days_in_future ??
defaults.include_maintenances.ongoing.upcoming.days_in_future,
},
},
},
monitor_status_history_days: {
desktop: partial.monitor_status_history_days?.desktop ?? defaults.monitor_status_history_days.desktop,
mobile: partial.monitor_status_history_days?.mobile ?? defaults.monitor_status_history_days.mobile,
},
monitor_layout_style: partial.monitor_layout_style ?? defaults.monitor_layout_style,
};
const metaPageTitle = partial.meta_page_title ?? defaults.meta_page_title;
const metaPageDescription = partial.meta_page_description ?? defaults.meta_page_description;
const socialPagePreviewImage = partial.social_page_preview_image ?? defaults.social_page_preview_image;
if (metaPageTitle !== undefined) merged.meta_page_title = metaPageTitle;
if (metaPageDescription !== undefined) merged.meta_page_description = metaPageDescription;
if (socialPagePreviewImage !== undefined) merged.social_page_preview_image = socialPagePreviewImage;
return merged;
}
function isValidHistoryDays(value: unknown): boolean {
return Number.isInteger(value) && (value as number) >= HISTORY_DAYS_MIN && (value as number) <= HISTORY_DAYS_MAX;
}
const boolOrUndefined = (value: unknown): boolean | undefined => (typeof value === "boolean" ? value : undefined);
const countOrUndefined = (value: unknown): number | undefined =>
Number.isInteger(value) && (value as number) >= 0 ? (value as number) : undefined;
// Read-side sanitizers: keep only correctly-typed leaves from stored event
// branches so wrong-typed values (e.g. enabled: "yes" from manual edits or
// older versions) never override defaults in API responses
function sanitizeStoredIncidents(value: unknown): PageSettingsPatch["incidents"] {
if (!isPlainObject(value)) return undefined;
const ongoing = isPlainObject(value.ongoing) ? value.ongoing : {};
const resolved = isPlainObject(value.resolved) ? value.resolved : {};
return {
enabled: boolOrUndefined(value.enabled),
ongoing: { show: boolOrUndefined(ongoing.show) },
resolved: {
show: boolOrUndefined(resolved.show),
max_count: countOrUndefined(resolved.max_count),
days_in_past: countOrUndefined(resolved.days_in_past),
},
};
}
function sanitizeStoredMaintenances(value: unknown): PageSettingsPatch["include_maintenances"] {
if (!isPlainObject(value)) return undefined;
const ongoing = isPlainObject(value.ongoing) ? value.ongoing : {};
const past = isPlainObject(ongoing.past) ? ongoing.past : {};
const upcoming = isPlainObject(ongoing.upcoming) ? ongoing.upcoming : {};
return {
enabled: boolOrUndefined(value.enabled),
ongoing: {
show: boolOrUndefined(ongoing.show),
past: {
show: boolOrUndefined(past.show),
max_count: countOrUndefined(past.max_count),
days_in_past: countOrUndefined(past.days_in_past),
},
upcoming: {
show: boolOrUndefined(upcoming.show),
max_count: countOrUndefined(upcoming.max_count),
days_in_future: countOrUndefined(upcoming.days_in_future),
},
},
};
}
function isValidLayoutStyle(value: unknown): value is PageSettings["monitor_layout_style"] {
return (GC.MONITOR_LAYOUT_STYLES as readonly string[]).includes(value as string);
}
/**
* Builds the API view of stored settings: defaults overlaid with stored
* values. Stored values that violate the API contract (unknown layout style,
* out-of-range days — e.g. from manual edits or older versions) are ignored
* so responses stay schema-compliant.
*/
export function toApiPageSettings(storedJson: string | null | undefined): PageSettings {
const stored = parseStored(storedJson);
const storedDays = isPlainObject(stored.monitor_status_history_days) ? stored.monitor_status_history_days : {};
const fromStore: PageSettingsPatch = {
incidents: sanitizeStoredIncidents(stored.incidents),
include_maintenances: sanitizeStoredMaintenances(stored.include_maintenances),
monitor_status_history_days: {
desktop: isValidHistoryDays(storedDays.desktop) ? (storedDays.desktop as number) : undefined,
mobile: isValidHistoryDays(storedDays.mobile) ? (storedDays.mobile as number) : undefined,
},
monitor_layout_style: isValidLayoutStyle(stored.monitor_layout_style) ? stored.monitor_layout_style : undefined,
meta_page_title: typeof stored.metaPageTitle === "string" ? stored.metaPageTitle : undefined,
meta_page_description: typeof stored.metaPageDescription === "string" ? stored.metaPageDescription : undefined,
social_page_preview_image:
typeof stored.socialPagePreviewImage === "string" ? stored.socialPagePreviewImage : undefined,
};
return mergePageSettings(getDefaultPageSettings(), fromStore);
}
/**
* Deep-merges a partial API payload into the stored settings JSON and returns
* the new JSON string. Only keys present in the patch are written; everything
* else in the stored JSON — including nested keys and top-level keys this
* module does not know about — is preserved, so an API write can never wipe
* settings written by other parts of the app, and clients may persist extra
* keys (the schema allows additional properties).
*/
export function applyPageSettingsPatch(
storedJson: string | null | undefined,
patch: PageSettingsPatch | undefined,
): string {
const stored = parseStored(storedJson);
if (!patch) {
return JSON.stringify(stored);
}
// Map the API's snake_case meta fields to their stored camelCase keys; all
// other keys are stored under their API names
const { meta_page_title, meta_page_description, social_page_preview_image, ...rest } = patch;
const mappedPatch: Record<string, unknown> = { ...rest };
if (meta_page_title !== undefined) mappedPatch.metaPageTitle = meta_page_title;
if (meta_page_description !== undefined) mappedPatch.metaPageDescription = meta_page_description;
if (social_page_preview_image !== undefined) mappedPatch.socialPagePreviewImage = social_page_preview_image;
return JSON.stringify(deepMerge(stored, mappedPatch));
}
/**
* Validates a partial page_settings payload from the API. Returns an error
* message, or null when valid. Bounds mirror the manage UI (history days
* 1-365, layout style one of the four shipped styles).
*/
export function validatePageSettings(partial: unknown): string | null {
if (partial === undefined) return null;
if (typeof partial !== "object" || partial === null || Array.isArray(partial)) {
return "page_settings must be an object";
}
const settings = partial as PageSettingsPatch;
// The event display branches and their known sub-objects must be objects;
// anything else would be deep-merged into storage as-is
if (settings.incidents !== undefined) {
if (!isPlainObject(settings.incidents)) {
return "incidents must be an object";
}
for (const key of ["ongoing", "resolved"] as const) {
if (settings.incidents[key] !== undefined && !isPlainObject(settings.incidents[key])) {
return `incidents.${key} must be an object`;
}
}
}
if (settings.include_maintenances !== undefined) {
if (!isPlainObject(settings.include_maintenances)) {
return "include_maintenances must be an object";
}
const ongoing = settings.include_maintenances.ongoing;
if (ongoing !== undefined) {
if (!isPlainObject(ongoing)) {
return "include_maintenances.ongoing must be an object";
}
for (const key of ["past", "upcoming"] as const) {
if (ongoing[key] !== undefined && !isPlainObject(ongoing[key])) {
return `include_maintenances.ongoing.${key} must be an object`;
}
}
}
}
// Leaf types inside the event branches must match the schema
const leafChecks: Array<{ path: readonly string[]; kind: "boolean" | "count" }> = [
{ path: ["incidents", "enabled"], kind: "boolean" },
{ path: ["incidents", "ongoing", "show"], kind: "boolean" },
{ path: ["incidents", "resolved", "show"], kind: "boolean" },
{ path: ["incidents", "resolved", "max_count"], kind: "count" },
{ path: ["incidents", "resolved", "days_in_past"], kind: "count" },
{ path: ["include_maintenances", "enabled"], kind: "boolean" },
{ path: ["include_maintenances", "ongoing", "show"], kind: "boolean" },
{ path: ["include_maintenances", "ongoing", "past", "show"], kind: "boolean" },
{ path: ["include_maintenances", "ongoing", "past", "max_count"], kind: "count" },
{ path: ["include_maintenances", "ongoing", "past", "days_in_past"], kind: "count" },
{ path: ["include_maintenances", "ongoing", "upcoming", "show"], kind: "boolean" },
{ path: ["include_maintenances", "ongoing", "upcoming", "max_count"], kind: "count" },
{ path: ["include_maintenances", "ongoing", "upcoming", "days_in_future"], kind: "count" },
];
for (const { path, kind } of leafChecks) {
let value: unknown = settings;
for (const key of path) {
if (!isPlainObject(value)) {
value = undefined;
break;
}
value = value[key];
}
if (value === undefined) continue;
if (kind === "boolean" && typeof value !== "boolean") {
return `${path.join(".")} must be a boolean`;
}
if (kind === "count" && !(Number.isInteger(value) && (value as number) >= 0)) {
return `${path.join(".")} must be a non-negative integer`;
}
}
if (settings.monitor_layout_style !== undefined) {
if (!GC.MONITOR_LAYOUT_STYLES.includes(settings.monitor_layout_style)) {
return `monitor_layout_style must be one of: ${GC.MONITOR_LAYOUT_STYLES.join(", ")}`;
}
}
if (settings.monitor_status_history_days !== undefined) {
const days = settings.monitor_status_history_days;
if (typeof days !== "object" || days === null || Array.isArray(days)) {
return "monitor_status_history_days must be an object";
}
for (const key of ["desktop", "mobile"] as const) {
const value = days[key];
if (value !== undefined) {
if (!Number.isInteger(value) || value < HISTORY_DAYS_MIN || value > HISTORY_DAYS_MAX) {
return `monitor_status_history_days.${key} must be an integer between ${HISTORY_DAYS_MIN} and ${HISTORY_DAYS_MAX}`;
}
}
}
}
for (const key of ["meta_page_title", "meta_page_description", "social_page_preview_image"] as const) {
const value = settings[key];
if (value !== undefined && typeof value !== "string") {
return `${key} must be a string`;
}
}
return null;
}
-2
View File
@@ -24,7 +24,6 @@ import {
UpdateMonitorAlertV2Status,
} from "../controllers/monitorAlertConfigController.js";
import type { IncidentInput } from "../controllers/incidentController.js";
import { InsertNewAlert } from "../controllers/controller.js";
import { GetMonitorAlertsV2 } from "../controllers/monitorAlertConfigController.js";
import db from "../db/db.js";
import { getUnixTime, differenceInSeconds } from "date-fns";
@@ -34,7 +33,6 @@ import sendEmail from "../notification/email_notification.js";
import sendWebhook from "$lib/server/notification/webhook_notification.js";
import sendSlack from "$lib/server/notification/slack_notification.js";
import sendDiscord from "$lib/server/notification/discord_notification.js";
import serverResolver from "../resolver.js";
import type { SiteDataForNotification, SubscriptionVariableMap } from "../notification/types.js";
import mdToHTML from "../../marked.js";
+57 -7
View File
@@ -7,6 +7,7 @@ import { GetMinuteStartNowTimestampUTC } from "../tool.js";
import db from "../db/db.js";
import monitorResponseQueue from "./monitorResponseQueue";
import GC from "../../global-constants.js";
import { resolveConfirmedStatus } from "../services/confirmationThreshold.js";
let monitorExecuteQueue: Queue | null = null;
let worker: Worker | null = null;
@@ -25,8 +26,13 @@ const getQueue = () => {
return monitorExecuteQueue;
};
async function manualMaintenance(monitor: MonitorRecordTyped): Promise<{ [timestamp: number]: MonitoringResult }> {
let startTs = GetMinuteStartNowTimestampUTC();
async function manualMaintenance(
monitor: MonitorRecordTyped,
ts?: number,
): Promise<{ [timestamp: number]: MonitoringResult }> {
// Key by the job's `ts` (already a minute-start) so the overlay aligns with the realtime/default
// rows and the freeze gate; fall back to "now" only when called without a ts.
let startTs = ts !== undefined ? ts : GetMinuteStartNowTimestampUTC();
let maintenanceArr = await db.getMaintenancesByMonitorTagRealtime(monitor.tag, startTs);
let impact = "";
@@ -66,8 +72,13 @@ async function manualMaintenance(monitor: MonitorRecordTyped): Promise<{ [timest
return manualData;
}
async function manualIncident(monitor: MonitorRecordTyped): Promise<{ [timestamp: number]: MonitoringResult }> {
let startTs = GetMinuteStartNowTimestampUTC();
async function manualIncident(
monitor: MonitorRecordTyped,
ts?: number,
): Promise<{ [timestamp: number]: MonitoringResult }> {
// Key by the job's `ts` (already a minute-start) so the overlay aligns with the realtime/default
// rows and the freeze gate; fall back to "now" only when called without a ts.
let startTs = ts !== undefined ? ts : GetMinuteStartNowTimestampUTC();
let incidentArr = await db.getIncidentsByMonitorTagRealtime(monitor.tag, startTs);
let impact = "";
@@ -115,13 +126,43 @@ const addWorker = () => {
const exeResult = await serviceClient.execute(ts);
// Fetch overlays AFTER the check runs so a maintenance/incident that starts mid-check is still
// detected, and key them by the job's `ts` so the freeze gate (incidentData[ts]) is
// timestamp-safe even if the job is delayed or retried (#756).
let incidentData: MonitoringResultTS = await manualIncident(monitor, ts);
let maintenanceData: MonitoringResultTS = await manualMaintenance(monitor, ts);
let realtimeData: MonitoringResultTS = {};
if (exeResult) {
realtimeData[ts] = exeResult;
}
// Always record what the check actually observed (forensics + grace counting).
realtimeData[ts].raw_status = exeResult.status;
let incidentData: MonitoringResultTS = await manualIncident(monitor);
let maintenanceData: MonitoringResultTS = await manualMaintenance(monitor);
// Confirmation Threshold damping (#712): scheduled checks only.
const threshold = Number(monitor.confirmation_threshold ?? 1);
const isScheduledCheck = ([GC.REALTIME, GC.TIMEOUT, GC.ERROR] as string[]).includes(exeResult.type);
// Confirmation Threshold freezes while an incident/maintenance overlay is active for this
// minute: the overlay wins display and the count must neither advance nor backfill (#756).
const overlayActive = incidentData[ts] !== undefined || maintenanceData[ts] !== undefined;
if (threshold > 1 && isScheduledCheck && !overlayActive) {
const resolved = await resolveConfirmedStatus({
monitor_tag: monitor.tag,
ts,
rawStatus: exeResult.status,
threshold,
});
realtimeData[ts].status = resolved.status;
if (resolved.pendingHold) {
// Hold the confirmed side for display, but PRESERVE the observed latency and error text —
// no diagnostic info is discarded. Tag the row to record that the status is being held
// during the grace period; on confirmation the backfill appends the confirmation note (#756).
const observedError = realtimeData[ts].error_message;
realtimeData[ts].error_message = observedError
? `${observedError} | Status held during grace period`
: "Status held during grace period";
}
}
}
let defaultData: MonitoringResultTS = {};
let mergedData: MonitoringResultTS = {};
@@ -186,6 +227,15 @@ const addWorker = () => {
}
}
// Preserve raw_status from realtime monitoring (overlays replace the merged object wholesale,
// so re-attach the observed value the resolver recorded).
for (const timestamp in mergedData) {
const ts = parseInt(timestamp);
if (realtimeData[ts]?.raw_status !== undefined) {
mergedData[ts].raw_status = realtimeData[ts].raw_status;
}
}
for (const timestamp in mergedData) {
monitorResponseQueue.push(monitor.tag, parseInt(timestamp), mergedData[timestamp]);
}
@@ -17,6 +17,7 @@ interface JobData {
monitorTag: string;
ts: number;
error_message?: string | null;
raw_status?: string | null;
}
const getQueue = () => {
@@ -30,7 +31,7 @@ const addWorker = () => {
if (worker) return worker;
worker = q.createWorker(getQueue(), async (job: Job): Promise<MonitoringData | null> => {
const { monitorTag, ts, status, latency, type, error_message } = job.data as JobData;
const { monitorTag, ts, status, latency, type, error_message, raw_status } = job.data as JobData;
const dbRes = await InsertMonitoringData({
monitor_tag: monitorTag,
@@ -39,6 +40,7 @@ const addWorker = () => {
latency: latency,
type: type,
error_message: error_message,
raw_status: raw_status,
});
if (!dbRes) {
+10 -1
View File
@@ -1,4 +1,5 @@
import { redisIOConnection } from "../redisConnector.js";
import db from "../db/db.js";
import {
Queue,
Worker,
@@ -40,7 +41,15 @@ export const createWorker = <T = unknown, R = unknown>(
concurrency: 5,
...options,
};
return new Worker<T, R>(queue.name, processor, opts);
// Route every job's database access to the worker pool. This is the single
// chokepoint all BullMQ workers and schedulers flow through, so wrapping here
// isolates background work from the web request pool (see db/poolContext.ts).
// Sandboxed (string/URL) processors run out-of-process and pass through.
const wrapped: Processor<T, R> =
typeof processor === "function"
? (job, token) => db.runInWorkerContext(() => Promise.resolve(processor(job, token)))
: processor;
return new Worker<T, R>(queue.name, wrapped, opts);
};
export default {
+30 -2
View File
@@ -1,17 +1,45 @@
import IORedis from "ioredis";
import Redis from "ioredis";
import type { RedisOptions } from "ioredis";
import dotenv from "dotenv";
dotenv.config();
let redisIOClient: IORedis | null = null;
let redisClient: IORedis | null = null;
function shouldReconnectAfterRedisError(message: string): boolean {
const m = message.toUpperCase();
// Failover: writes hit a replica until the client points at the new primary.
if (m.includes("READONLY")) return true;
// RDB/AOF reload after pod restart — commands fail until loading finishes.
if (m.includes("LOADING")) return true;
// Replication: primary unavailable during StatefulSet rollout.
if (m.includes("MASTERDOWN")) return true;
return false;
}
const redisClientOptions: RedisOptions = {
maxRetriesPerRequest: null,
// Detect dead peers during long K8s / network stalls (default ioredis keepAlive is off).
keepAlive: 30000,
// Allow long RDB reloads after a StatefulSet restart before giving up on "ready".
maxLoadingRetryTime: 120_000,
reconnectOnError: (error: Error) => {
const message = error?.message ?? "";
if (shouldReconnectAfterRedisError(message)) {
// Reconnect and retry the failed command once the connection is healthy again.
return 2;
}
return false;
},
};
export function redisIOConnection(): IORedis {
if (!redisIOClient) {
if (!process.env.REDIS_URL) {
throw new Error("REDIS_URL is not defined in environment variables");
}
redisIOClient = new IORedis(process.env.REDIS_URL, { maxRetriesPerRequest: null });
redisIOClient = new IORedis(process.env.REDIS_URL, redisClientOptions);
}
return redisIOClient;
}
@@ -21,7 +49,7 @@ export function redisConnection(): Redis {
if (!process.env.REDIS_URL) {
throw new Error("REDIS_URL is not defined in environment variables");
}
redisClient = new Redis(process.env.REDIS_URL, { maxRetriesPerRequest: null });
redisClient = new Redis(process.env.REDIS_URL, redisClientOptions);
}
return redisClient;
}
+243
View File
@@ -0,0 +1,243 @@
/**
* RSS 2.0 feed for Kener status pages.
*
* Two layers live in this file:
* 1. buildRssFeed — pure XML formatter (no I/O).
* 2. renderRssFeedResponse — fetches recent incidents + maintenances for a
* page, shapes them into items, and returns an HTTP Response. Shared by
* both the default-page and named-page route handlers.
*/
import db from "$lib/server/db/db.js";
import { GetAllSiteData } from "$lib/server/controllers/siteDataController.js";
import type { IncidentForMonitorListWithComments, MaintenanceEventsMonitorList } from "$lib/server/types/db.js";
export type RssFeedItemType = "incident" | "maintenance";
export interface RssFeedItem {
type: RssFeedItemType;
id: number;
title: string;
link: string;
pubDate: number;
description: string;
}
export interface BuildRssFeedArgs {
siteName: string;
siteURL: string;
basePath: string;
feedPath: string;
items: RssFeedItem[];
}
const TYPE_TITLE_PREFIX: Record<RssFeedItemType, string> = {
incident: "[Incident]",
maintenance: "[Maintenance]",
};
export function buildRssFeed(args: BuildRssFeedArgs): string {
const channelLink = joinUrl(args.siteURL, args.basePath);
const selfLink = joinUrl(args.siteURL, args.basePath, args.feedPath);
const channelTitle = `${args.siteName} — Incidents & Maintenance`;
const channelDescription = `Latest incidents and scheduled maintenance for ${args.siteName}`;
const lastBuildSeconds = args.items.length > 0 ? Math.max(...args.items.map((i) => i.pubDate)) : nowSeconds();
const itemsXml = args.items.map(renderItem).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(channelTitle)}</title>
<link>${escapeXml(channelLink)}</link>
<description>${escapeXml(channelDescription)}</description>
<language>en</language>
<lastBuildDate>${formatRfc822(lastBuildSeconds)}</lastBuildDate>
<atom:link href="${escapeXml(selfLink)}" rel="self" type="application/rss+xml" />
${itemsXml}
</channel>
</rss>
`;
}
function renderItem(item: RssFeedItem): string {
const prefixedTitle = `${TYPE_TITLE_PREFIX[item.type]} ${item.title}`;
const guid = `${item.type}-${item.id}`;
return ` <item>
<title>${escapeXml(prefixedTitle)}</title>
<link>${escapeXml(item.link)}</link>
<guid isPermaLink="false">${escapeXml(guid)}</guid>
<pubDate>${formatRfc822(item.pubDate)}</pubDate>
<description>${cdata(item.description)}</description>
</item>`;
}
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function cdata(value: string): string {
// Split any literal ]]> sequence so the CDATA section terminates only at ours.
const safe = value.replace(/]]>/g, "]]]]><![CDATA[>");
return `<![CDATA[${safe}]]>`;
}
function formatRfc822(seconds: number): string {
// Date#toUTCString returns RFC-1123 form, which RSS 2.0 readers accept as RFC-822.
return new Date(seconds * 1000).toUTCString();
}
function nowSeconds(): number {
return Math.floor(Date.now() / 1000);
}
function joinUrl(...parts: string[]): string {
const [origin, ...rest] = parts;
const trimmedOrigin = origin.replace(/\/+$/, "");
const path = rest
.map((p) => p.trim())
.filter((p) => p.length > 0)
.map((p) => "/" + p.replace(/^\/+/, "").replace(/\/+$/, ""))
.join("");
return trimmedOrigin + path;
}
// Window for items pulled into the feed. Bounded by both: last 90 days AND
// the most recent 50 entries after merging. Matches typical reader expectations
// without scanning the entire incident history.
const FEED_WINDOW_DAYS = 90;
const FEED_MAX_ITEMS = 50;
// Scope determines which monitors the feed covers.
// - page: scoped to a status page's monitor list (honors hidden-monitor
// stripping and globalPageVisibilitySettings.forceExclusivity when pagePath is null)
// - monitor: scoped to a single monitor by tag (404 if hidden, inactive, or unknown)
export type RenderRssFeedScope = { type: "page"; pagePath: string | null } | { type: "monitor"; tag: string };
export interface RenderRssFeedArgs {
scope: RenderRssFeedScope;
// Path of THIS feed under basePath, e.g. "/rss.xml" or "/monitors/foo/rss.xml".
feedPath: string;
}
export async function renderRssFeedResponse(args: RenderRssFeedArgs): Promise<Response> {
const siteData = await GetAllSiteData();
const siteURL = siteData.siteURL;
if (!siteURL) {
return new Response("Not found", { status: 404 });
}
const siteName = siteData.siteName || "Status";
const basePath = process.env.KENER_BASE_PATH || "";
let monitorTags: string[] | undefined = undefined;
if (args.scope.type === "monitor") {
const monitor = await db.getMonitorByTag(args.scope.tag);
if (!monitor || monitor.is_hidden === "YES" || monitor.status !== "ACTIVE") {
return new Response("Not found", { status: 404 });
}
monitorTags = [args.scope.tag];
} else {
let pagePath = args.scope.pagePath;
if (!!siteData.globalPageVisibilitySettings?.forceExclusivity && pagePath === null) {
pagePath = "";
}
if (pagePath !== null) {
const page = await db.getPageByPath(pagePath);
if (!page) {
return new Response("Not found", { status: 404 });
}
const pageMonitors = await db.getPageMonitorsExcludeHidden(page.id);
monitorTags = pageMonitors.map((m) => m.monitor_tag);
}
}
const nowTs = nowSeconds();
const startTs = nowTs - FEED_WINDOW_DAYS * 24 * 60 * 60;
// Maintenances need a future end too: a SCHEDULED maintenance has its
// start_date_time in the future, and we want subscribers to learn about
// upcoming windows. Incidents are always past, so they keep nowTs as end.
const futureTs = nowTs + FEED_WINDOW_DAYS * 24 * 60 * 60;
const [incidents, maintenances] = await Promise.all([
db.getIncidentsForEventsByDateRange(startTs, nowTs, monitorTags),
db.getMaintenanceEventsForEventsByDateRange(startTs, futureTs, monitorTags),
]);
const items: RssFeedItem[] = [];
for (const incident of incidents) {
items.push({
type: "incident",
id: incident.id,
title: incident.title,
link: joinUrl(siteURL, basePath, `/incidents/${incident.id}`),
pubDate: incident.comments[0]?.commented_at ?? incident.start_date_time,
description: buildIncidentDescription(incident),
});
}
for (const maintenance of maintenances) {
// Drop events whose affected monitors were all hidden: the DB layer strips
// hidden monitors from the row; a now-empty monitors[] means the public
// shouldn't see this on the events page either. A global maintenance is the
// exception — it has no per-monitor rows by design (it affects every
// monitor), so its empty monitors[] is expected and must still be published.
if (maintenance.monitors.length === 0 && maintenance.is_global !== "YES") continue;
items.push({
type: "maintenance",
id: maintenance.id,
title: maintenance.title,
link: joinUrl(siteURL, basePath, `/maintenances/${maintenance.id}`),
pubDate: maintenance.start_date_time,
description: buildMaintenanceDescription(maintenance),
});
}
items.sort((a, b) => b.pubDate - a.pubDate);
const capped = items.slice(0, FEED_MAX_ITEMS);
const xml = buildRssFeed({ siteName, siteURL, basePath, feedPath: args.feedPath, items: capped });
return new Response(xml, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=300",
},
});
}
function buildIncidentDescription(incident: IncidentForMonitorListWithComments): string {
const lines: string[] = [];
lines.push(`Status: ${incident.state}`);
if (incident.monitors.length > 0) {
const names = incident.monitors.map((m) => m.monitor_name).join(", ");
lines.push(`Affected: ${names}`);
}
const latest = incident.comments[0];
if (latest) {
lines.push("");
lines.push(latest.comment);
}
return lines.join("\n");
}
function buildMaintenanceDescription(maintenance: MaintenanceEventsMonitorList): string {
const lines: string[] = [];
lines.push(`Status: ${maintenance.status}`);
const start = formatRfc822(maintenance.start_date_time);
const end = maintenance.end_date_time != null ? formatRfc822(maintenance.end_date_time) : "open-ended";
lines.push(`Scheduled: ${start}${end}`);
if (maintenance.is_global === "YES") {
lines.push("Affected: All monitors");
} else if (maintenance.monitors.length > 0) {
const names = maintenance.monitors.map((m) => m.monitor_name).join(", ");
lines.push(`Affected: ${names}`);
}
if (maintenance.description) {
lines.push("");
lines.push(maintenance.description);
}
return lines.join("\n");
}
+1 -1
View File
@@ -47,7 +47,7 @@ const getRetentionPolicy = async (): Promise<DataRetentionPolicy> => {
const runDailyCleanup = async (): Promise<DailyCleanupResult> => {
const policy = await getRetentionPolicy();
const retentionDays = Math.max(1, Math.floor(policy.retentionDays || defaultPolicy.retentionDays));
console.log(`Data retention policy: enabled=${policy.enabled}, retentionDays=${retentionDays}`);
if (!policy.enabled) {
return {
skipped: true,
@@ -0,0 +1,107 @@
import GC from "../../global-constants.js";
import db from "../db/db.js";
export type Side = "UP" | "DOWN" | null;
/** Binary side: UP is healthy; DOWN/DEGRADED are unhealthy; everything else (NO_DATA) is neutral. */
export function sideOf(status: string | null | undefined): Side {
if (status === GC.UP) return "UP";
if (status === GC.DOWN || status === GC.DEGRADED) return "DOWN";
return null;
}
export interface ResolveInput {
monitor_tag: string;
ts: number;
rawStatus: string;
threshold: number; // >= 2 to damp; 1 behaves as off (any opposite observation confirms instantly)
}
export interface ResolveResult {
/** Effective status to commit for this minute. */
status: string;
/**
* True while the displayed status is the held confirmed side (pending confirmation) rather
* than the observed side. The caller keeps the observed latency and error text (tagging the
* row) — it does not blank them.
*/
pendingHold: boolean;
}
/** Minimal data access this resolver needs; defaults to the db singleton, injectable for tests. */
export interface ConfirmationDeps {
getRecentSamplesForConfirmation: typeof db.getRecentSamplesForConfirmation;
getLastObservedStatus: typeof db.getLastObservedStatus;
backfillConfirmedStatus: typeof db.backfillConfirmedStatus;
}
// Overlay sample types that freeze the count (must match OVERLAY_TYPES in the monitoring repository).
const OVERLAY_TYPES: string[] = [GC.INCIDENT, GC.MAINTENANCE];
// Extra lookback rows beyond the threshold for the pending-run scan: headroom for interleaved
// overlay rows. NO_DATA observations are excluded by the query, and the anchor is fetched
// separately, so the buffer need not scale with NO_DATA density or overlay-window length.
const LOOKBACK_BUFFER = 10;
/**
* Resolve the status to commit for one scheduled-check observation under Confirmation
* Threshold damping (issue #712).
*
* IMPORTANT ordering contract: this MUST be called BEFORE the current row at `ts` is
* persisted, and only when no incident/maintenance overlay is active for `ts` (the caller
* gates that — overlays freeze the count). It anchors on the most recent stored observation
* (timestamp < ts).
*
* Neutral (`NO_DATA`) observations are excluded from the count (neither advance nor reset).
* Overlay rows act as a hard boundary: the pending run never crosses one, so monitoring
* resumes with a fresh count after an incident/maintenance window.
*/
export async function resolveConfirmedStatus(
input: ResolveInput,
deps: ConfirmationDeps = db,
): Promise<ResolveResult> {
const { monitor_tag, ts, rawStatus, threshold } = input;
const observedSide = sideOf(rawStatus);
// Neutral observation (NO_DATA): pass through untouched — written honestly as grey.
if (observedSide === null) {
return { status: rawStatus, pendingHold: false };
}
// Anchor = the side currently shown = the most recent real observation's committed status.
// Fetched with a dedicated query (not from the windowed scan) so a long incident/maintenance
// window can never push the anchor out of range and bypass damping.
const confirmedStatus = await deps.getLastObservedStatus(monitor_tag, ts);
const confirmedSide = sideOf(confirmedStatus);
// Cold start / no usable anchor / same side: commit immediately.
if (confirmedStatus === null || confirmedSide === null || observedSide === confirmedSide) {
return { status: rawStatus, pendingHold: false };
}
// Opposite side: count the trailing pending run (incl. current = 1), stopping at an overlay
// boundary (freeze) or at a confirmed-side observation. NO_DATA rows are excluded by the query.
const recent = await deps.getRecentSamplesForConfirmation(monitor_tag, ts, threshold + LOOKBACK_BUFFER);
let pendingRun = 1;
const pendingTimestamps: number[] = [];
for (const row of recent) {
if (row.type !== null && OVERLAY_TYPES.includes(row.type)) break; // freeze boundary
const rawSide = sideOf(row.raw_status);
if (rawSide === null) continue; // NO_DATA: neutral (excluded by the query; this is a defensive guard)
if (rawSide === observedSide && sideOf(row.status) === confirmedSide) {
pendingRun++;
pendingTimestamps.push(row.timestamp);
} else {
break; // hit a confirmed-side observation (the anchor)
}
}
if (pendingRun >= threshold) {
// Unhealthy confirm passes the count so the backfill can write a per-row severity note
// ("Down"/"Degraded confirmed after N…"); recovery passes null (clears the held error text).
await deps.backfillConfirmedStatus(monitor_tag, pendingTimestamps, observedSide === "DOWN" ? threshold : null);
return { status: rawStatus, pendingHold: false };
}
// Still pending: hold the confirmed side.
return { status: confirmedStatus, pendingHold: true };
}
+20 -2
View File
@@ -1,5 +1,6 @@
import DNSResolver from "../dns.js";
import { GetLastKnownStatus } from "../controllers/monitorsController.js";
import type { NoneMonitor, MonitoringResult } from "../types/monitor.js";
import GC from "../../global-constants.js";
class NoneCall {
monitor: NoneMonitor;
@@ -8,7 +9,24 @@ class NoneCall {
this.monitor = monitor;
}
async execute(): Promise<null> {
async execute(): Promise<MonitoringResult | null> {
let overrideWithLastKnownStatus = this.monitor.type_data.overrideWithLastKnownStatus;
if (!!overrideWithLastKnownStatus) {
//get the last known status
let lastKnownStatus = await GetLastKnownStatus(this.monitor.tag);
if (
!!lastKnownStatus &&
!!lastKnownStatus.status &&
!!lastKnownStatus.type &&
lastKnownStatus.type === GC.MANUAL
) {
return {
status: lastKnownStatus.status,
latency: lastKnownStatus.latency || 0,
type: lastKnownStatus.type,
};
}
}
return null;
}
}
+36 -4
View File
@@ -1,5 +1,6 @@
// Server-only database types (based on migrations schema)
import type { Knex } from "knex";
import type { PageMonitorLayoutStyle } from "$lib/types/api";
// ============ monitoring_data table ============
export interface MonitoringData {
@@ -9,6 +10,7 @@ export interface MonitoringData {
latency: number | null;
type: string | null;
error_message?: string | null;
raw_status?: string | null;
}
export interface MonitoringDataInsert {
@@ -18,6 +20,7 @@ export interface MonitoringDataInsert {
latency: number;
type: string;
error_message?: string | null;
raw_status?: string | null;
}
export interface AggregatedMonitoringData {
@@ -77,6 +80,7 @@ export interface MonitorRecord {
external_url?: string | null;
day_degraded_minimum_count?: number | null;
day_down_minimum_count?: number | null;
confirmation_threshold?: number | null;
include_degraded_in_downtime?: string;
is_hidden: string;
monitor_settings_json: string | null;
@@ -134,6 +138,7 @@ export interface MonitorRecordTyped {
type_data: Record<string, unknown> | null;
day_degraded_minimum_count?: number | null;
day_down_minimum_count?: number | null;
confirmation_threshold?: number | null;
include_degraded_in_downtime?: string;
is_hidden: string;
monitor_settings_json: MonitorSettings | null;
@@ -157,6 +162,7 @@ export interface MonitorRecordInsert {
type_data?: string | null;
day_degraded_minimum_count?: number | null;
day_down_minimum_count?: number | null;
confirmation_threshold?: number | null;
include_degraded_in_downtime?: string;
is_hidden?: string;
monitor_settings_json?: string | null;
@@ -236,7 +242,7 @@ export interface UserRecord {
password_hash: string;
is_active: number;
is_verified: number;
role: string;
role_ids: string[]; // Array of role IDs
created_at: Date;
updated_at: Date;
}
@@ -245,9 +251,9 @@ export interface UserRecordInsert {
email: string;
name: string;
password_hash: string;
role_ids: string[]; // Array of role IDs
is_active?: number;
is_verified?: number;
role?: string;
is_owner?: string;
}
@@ -258,7 +264,7 @@ export interface UserRecordPublic {
is_active: number;
is_verified: number;
is_owner: string;
role: string;
role_ids: string[];
created_at: Date;
updated_at: Date;
}
@@ -266,6 +272,31 @@ export interface UserRecordDashboard extends UserRecordPublic {
has_password: boolean;
}
// ============ roles table ============
export interface RoleRecord {
id: string;
role_name: string;
readonly: number;
status: string;
created_at: Date;
updated_at: Date;
}
export interface RolePermissionRecord {
roles_id: string;
permissions_id: string;
status: string;
created_at: Date;
updated_at: Date;
}
export interface UserRoleRecord {
roles_id: string;
users_id: number;
created_at: Date;
updated_at: Date;
}
// ============ api_keys table ============
export interface ApiKeyRecord {
id: number;
@@ -443,7 +474,7 @@ export interface PageSettingsType {
desktop: number;
mobile: number;
};
monitor_layout_style: "default-list" | "default-grid" | "compact-list" | "compact-grid";
monitor_layout_style: PageMonitorLayoutStyle;
metaPageTitle?: string;
metaPageDescription?: string;
socialPagePreviewImage?: string;
@@ -616,6 +647,7 @@ export interface MaintenanceEventsMonitorList {
description: string | null;
start_date_time: number; // Unix timestamp - when the first occurrence starts
end_date_time: number; // Unix timestamp - when the first occurrence ends
is_global: YesNoType; // "YES" when the maintenance affects all monitors (no per-monitor rows)
monitors: MaintenanceMonitorImpact[];
created_at: Date;
updated_at: Date;
+4 -1
View File
@@ -8,13 +8,16 @@ export interface MonitoringResult {
latency: number;
type: string;
error_message?: string;
raw_status?: string;
}
export interface MonitoringResultTS {
[timestamp: number]: MonitoringResult;
}
export interface NoneMonitorTypeData {}
export interface NoneMonitorTypeData {
overrideWithLastKnownStatus: boolean;
}
export interface ApiMonitorTypeData {
url: string;
body?: string;
+68 -4
View File
@@ -3,6 +3,7 @@
import type { MonitorRecordTyped } from "$lib/server/types/db";
import type { MonitorPublicView } from "$lib/types/monitor";
import type GC from "$lib/global-constants";
export type ApiError = {
code: string;
@@ -91,6 +92,7 @@ export interface MonitorResponse {
type_data: MonitorTypeData | null;
include_degraded_in_downtime: string;
is_hidden: string;
confirmation_threshold?: number | null;
monitor_settings_json: MonitorSettings | null;
created_at: string;
updated_at: string;
@@ -117,6 +119,7 @@ export interface CreateMonitorRequest {
type_data?: MonitorTypeData | null;
include_degraded_in_downtime?: string;
is_hidden?: string;
confirmation_threshold?: number | null;
monitor_settings_json?: MonitorSettings | null;
}
@@ -136,6 +139,7 @@ export interface UpdateMonitorRequest {
type_data?: MonitorTypeData | null;
include_degraded_in_downtime?: string;
is_hidden?: string;
confirmation_threshold?: number | null;
monitor_settings_json?: MonitorSettings | null;
}
@@ -143,6 +147,10 @@ export interface UpdateMonitorResponse {
monitor: MonitorRecordTyped;
}
export interface DeleteMonitorResponse {
message: string;
}
// Monitoring Data API types
export interface MonitoringDataPoint {
monitor_tag: string;
@@ -198,6 +206,8 @@ export interface IncidentResponse {
monitors: IncidentMonitor[];
created_at: string;
updated_at: string;
/** Absolute URL of the public incident page */
url: string;
}
export interface IncidentDetailResponse extends IncidentResponse {
@@ -298,6 +308,13 @@ export interface MaintenanceResponse {
monitors: MaintenanceMonitor[];
created_at: string;
updated_at: string;
/**
* Absolute URL of the public page for this maintenance.
* Note: the public /maintenances/<id> route is keyed by maintenance EVENT id
* by default, so this URL carries ?type=maintenance. Link via this field,
* never by concatenating `id` onto a path. See docs/adr/0002.
*/
url: string;
}
export interface GetMaintenancesListResponse {
@@ -344,6 +361,8 @@ export interface MaintenanceEventResponse {
status: "SCHEDULED" | "READY" | "ONGOING" | "COMPLETED" | "CANCELLED";
created_at: string;
updated_at: string;
/** Absolute URL of the public page for this maintenance event */
url: string;
}
export interface GetMaintenanceEventsListResponse {
@@ -357,8 +376,15 @@ export interface GetMaintenanceEventResponse {
}
export interface UpdateMaintenanceEventRequest {
start_date_time: number;
end_date_time: number;
/** Window edit mode: both times required. Cannot be combined with `status`. */
start_date_time?: number;
end_date_time?: number;
/**
* Transition mode: COMPLETED (from ONGOING) or CANCELLED (from SCHEDULED/READY/ONGOING).
* Cannot be combined with time fields. Transitioning an ONGOING event moves its
* end_date_time to the moment of the transition.
*/
status?: "COMPLETED" | "CANCELLED";
}
export interface UpdateMaintenanceEventResponse {
@@ -386,6 +412,8 @@ export interface MaintenanceEventDetailResponse {
maintenance_rrule: string;
maintenance_duration_seconds: number;
monitors: MaintenanceMonitor[];
/** Absolute URL of the public page for this maintenance event */
url: string;
}
export interface GetMaintenanceEventsDetailListResponse {
@@ -436,9 +464,40 @@ export interface PageSettingsMaintenances {
ongoing: PageSettingsMaintenancesOngoing;
}
export interface PageSettingsHistoryDays {
desktop: number;
mobile: number;
}
/**
* Recursive partial, so patch payloads can update any subset of nested fields.
* Recursion applies only to plain object maps; arrays and other special object
* types pass through unchanged.
*/
export type DeepPartial<T> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[K in keyof T]?: T[K] extends (infer U)[] ? U[] : T[K] extends Record<string, any> ? DeepPartial<T[K]> : T[K];
};
/**
* Patch payload for page_settings: any subset of nested fields. Provided
* fields are deep-merged into the current settings; omitted fields are left
* untouched.
*/
export type PageSettingsPatch = DeepPartial<PageSettings>;
export type PageMonitorLayoutStyle = (typeof GC.MONITOR_LAYOUT_STYLES)[number];
export interface PageSettings {
incidents: PageSettingsIncidents;
include_maintenances: PageSettingsMaintenances;
/** Days of status history shown on the page, per device class (1-365). */
monitor_status_history_days: PageSettingsHistoryDays;
monitor_layout_style: PageMonitorLayoutStyle;
/** Per-page meta/social overrides; stored as camelCase keys internally. */
meta_page_title?: string;
meta_page_description?: string;
social_page_preview_image?: string;
}
export interface PageMonitorResponse {
@@ -447,6 +506,11 @@ export interface PageMonitorResponse {
export interface PageResponse {
id: number;
/**
* The page's path segment. The home page (stored path is empty) renders as
* the addressable token `~home`; its public URL is the site root.
* See docs/adr/0004-home-page-api-token.md.
*/
page_path: string;
page_title: string;
page_header: string;
@@ -472,7 +536,7 @@ export interface CreatePageRequest {
page_header: string;
page_subheader?: string | null;
page_logo?: string | null;
page_settings?: Partial<PageSettings>;
page_settings?: PageSettingsPatch;
monitors?: string[];
}
@@ -486,7 +550,7 @@ export interface UpdatePageRequest {
page_header?: string;
page_subheader?: string | null;
page_logo?: string | null;
page_settings?: Partial<PageSettings>;
page_settings?: PageSettingsPatch;
monitors?: string[];
}
+9
View File
@@ -0,0 +1,9 @@
export interface NotificationEvent {
eventURL: string;
eventTitle: string;
eventDate: string;
eventType: string;
eventStartDateTime: number;
eventEndDateTime: number | null;
eventStatus: string;
}
+2
View File
@@ -89,6 +89,7 @@ export interface SiteSubscriptionsSettings {
export interface SiteSubMenuOptions {
showShareBadgeMonitor: boolean;
showShareEmbedMonitor: boolean;
showRssFeed: boolean;
}
export interface DataRetentionPolicy {
@@ -97,6 +98,7 @@ export interface DataRetentionPolicy {
}
export interface EventDisplaySettings {
showInlineEvents: boolean;
incidents: {
enabled: boolean;
ongoing: {
+27 -8
View File
@@ -4,15 +4,9 @@
import { ModeWatcher } from "mode-watcher";
import { resolve } from "$app/paths";
import { Toaster } from "$lib/components/ui/sonner/index.js";
let base = resolve("/");
import clientResolver from "$lib/client/resolver.js";
let { children, data } = $props();
const colorUp = $derived(data.siteStatusColors.UP);
const colorDegraded = $derived(data.siteStatusColors.DEGRADED);
const colorDown = $derived(data.siteStatusColors.DOWN);
const colorMaintenance = $derived(data.siteStatusColors.MAINTENANCE);
import KenerNav from "$lib/components/KenerNav.svelte";
</script>
@@ -21,7 +15,32 @@
<svelte:head>
<meta name="robots" content="noindex, nofollow" />
{@html `<style>:root{--up:${colorUp};--degraded:${colorDegraded};--down:${colorDown};--maintenance:${colorMaintenance};}</style>`}
<link rel="icon" href={data.favicon} />
{#if data.font?.cssSrc}
<link rel="stylesheet" href={data.font.cssSrc} />
{/if}
{@html `
<style id="dynamic-styles">
body {
--up: ${data.siteStatusColors.UP};
--degraded: ${data.siteStatusColors.DEGRADED};
--down: ${data.siteStatusColors.DOWN};
--maintenance: ${data.siteStatusColors.MAINTENANCE};
--accent: ${data.siteStatusColors.ACCENT || "#f4f4f5"};
--accent-foreground: ${data.siteStatusColors.ACCENT_FOREGROUND || data.siteStatusColors.ACCENT || "#e96e2d"};
${data.font?.family ? `--font-family:'${data.font.family}', sans-serif;` : ""}
}
:is(.dark) body {
--up: ${data.siteStatusColorsDark.UP};
--degraded: ${data.siteStatusColorsDark.DEGRADED};
--down: ${data.siteStatusColorsDark.DOWN};
--maintenance: ${data.siteStatusColorsDark.MAINTENANCE};
--accent: ${data.siteStatusColorsDark.ACCENT || "#27272a"};
--accent-foreground: ${data.siteStatusColorsDark.ACCENT_FOREGROUND || data.siteStatusColorsDark.ACCENT || "#e96e2d"};
}
${data.customCSS || ""}
</style>`}
<script src={clientResolver(resolve, "/capture.js")}></script>
</svelte:head>
<main>
<!-- Nav -->
@@ -59,6 +59,13 @@ export const actions: Actions = {
});
}
if (!userDB.role_ids || userDB.role_ids.length === 0) {
return fail(403, {
error: "Your account has no active roles assigned. Please contact an administrator.",
values: { email },
});
}
const token = await GenerateToken(userDB);
const cookieConfig = CookieConfig();
cookies.set(cookieConfig.name, token, {
@@ -10,6 +10,8 @@ import type {
} from "$lib/types/api";
import GC from "$lib/global-constants";
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
import serverResolver from "$lib/server/resolver";
function formatDateToISO(date: Date | string): string {
if (date instanceof Date) {
@@ -56,6 +58,7 @@ export const GET: RequestHandler = async ({ url }) => {
}
// Build response with monitors for each incident
const siteUrl = await GetSiteURL();
const incidents: IncidentResponse[] = [];
for (const incident of rawIncidents) {
const monitors = await db.getIncidentMonitorsByIncidentID(incident.id);
@@ -72,6 +75,7 @@ export const GET: RequestHandler = async ({ url }) => {
})),
created_at: formatDateToISO(incident.created_at),
updated_at: formatDateToISO(incident.updated_at),
url: siteUrl + serverResolver(`/incidents/${incident.id}`),
});
}
@@ -207,6 +211,7 @@ export const POST: RequestHandler = async ({ request }) => {
})),
created_at: formatDateToISO(createdIncident.created_at),
updated_at: formatDateToISO(createdIncident.updated_at),
url: (await GetSiteURL()) + serverResolver(`/incidents/${createdIncident.id}`),
},
};
@@ -9,6 +9,8 @@ import type {
BadRequestResponse,
} from "$lib/types/api";
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
import serverResolver from "$lib/server/resolver";
function formatDateToISO(date: Date | string): string {
if (date instanceof Date) {
@@ -43,6 +45,7 @@ async function buildIncidentResponse(incidentId: number): Promise<IncidentDetail
})),
created_at: formatDateToISO(incident.created_at),
updated_at: formatDateToISO(incident.updated_at),
url: (await GetSiteURL()) + serverResolver(`/incidents/${incident.id}`),
};
}
@@ -14,6 +14,8 @@ import {
GenerateMaintenanceEvents,
isOneTimeRrule,
} from "$lib/server/controllers/maintenanceController";
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
import serverResolver from "$lib/server/resolver";
import { rrulestr } from "rrule";
function formatDateToISO(date: Date | string): string {
@@ -67,6 +69,7 @@ export const GET: RequestHandler = async ({ url }) => {
}
// Build response with monitors for each maintenance
const siteUrl = await GetSiteURL();
const maintenances: MaintenanceResponse[] = [];
for (const maintenance of rawMaintenances) {
const monitors = await db.getMaintenanceMonitors(maintenance.id);
@@ -84,6 +87,7 @@ export const GET: RequestHandler = async ({ url }) => {
})),
created_at: formatDateToISO(maintenance.created_at),
updated_at: formatDateToISO(maintenance.updated_at),
url: siteUrl + serverResolver(`/maintenances/${maintenance.id}?type=maintenance`),
});
}
@@ -267,6 +271,7 @@ export const POST: RequestHandler = async ({ request }) => {
})),
created_at: formatDateToISO(maintenance.created_at),
updated_at: formatDateToISO(maintenance.updated_at),
url: (await GetSiteURL()) + serverResolver(`/maintenances/${maintenance.id}?type=maintenance`),
};
const response: CreateMaintenanceResponse = {
@@ -10,6 +10,8 @@ import type {
} from "$lib/types/api";
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
import { GenerateMaintenanceEvents, isOneTimeRrule } from "$lib/server/controllers/maintenanceController";
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
import serverResolver from "$lib/server/resolver";
import { rrulestr } from "rrule";
function formatDateToISO(date: Date | string): string {
@@ -55,6 +57,7 @@ async function buildMaintenanceResponse(maintenanceId: number): Promise<Maintena
})),
created_at: formatDateToISO(maintenance.created_at),
updated_at: formatDateToISO(maintenance.updated_at),
url: (await GetSiteURL()) + serverResolver(`/maintenances/${maintenance.id}?type=maintenance`),
};
}
@@ -1,6 +1,8 @@
import { json, type RequestHandler } from "@sveltejs/kit";
import db from "$lib/server/db/db";
import type { GetMaintenanceEventsListResponse, MaintenanceEventResponse } from "$lib/types/api";
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
import serverResolver from "$lib/server/resolver";
function formatDateToISO(date: Date | string): string {
if (date instanceof Date) {
@@ -31,14 +33,16 @@ export const GET: RequestHandler = async ({ locals, url }) => {
const paginatedEvents = allEvents.slice(offset, offset + limit);
// Build response
const siteUrl = await GetSiteURL();
const events: MaintenanceEventResponse[] = paginatedEvents.map((event) => ({
id: event.id,
maintenance_id: event.maintenance_id,
start_date_time: event.start_date_time,
end_date_time: event.end_date_time,
status: event.status as "SCHEDULED" | "ONGOING" | "COMPLETED" | "CANCELLED",
status: event.status as MaintenanceEventResponse["status"],
created_at: formatDateToISO(event.created_at),
updated_at: formatDateToISO(event.updated_at),
url: siteUrl + serverResolver(`/maintenances/${event.id}`),
}));
const response: GetMaintenanceEventsListResponse = {
@@ -10,6 +10,10 @@ import type {
BadRequestResponse,
} from "$lib/types/api";
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
import { UpdateMaintenanceEventStatus } from "$lib/server/controllers/maintenanceController";
import GC from "$lib/global-constants";
import serverResolver from "$lib/server/resolver";
function formatDateToISO(date: Date | string): string {
if (date instanceof Date) {
@@ -20,7 +24,7 @@ function formatDateToISO(date: Date | string): string {
return parsed.toISOString();
}
function buildEventResponse(event: {
async function buildEventResponse(event: {
id: number;
maintenance_id: number;
start_date_time: number;
@@ -28,15 +32,16 @@ function buildEventResponse(event: {
status: string;
created_at: Date | string;
updated_at: Date | string;
}): MaintenanceEventResponse {
}): Promise<MaintenanceEventResponse> {
return {
id: event.id,
maintenance_id: event.maintenance_id,
start_date_time: event.start_date_time,
end_date_time: event.end_date_time,
status: event.status as "SCHEDULED" | "ONGOING" | "COMPLETED" | "CANCELLED",
status: event.status as MaintenanceEventResponse["status"],
created_at: formatDateToISO(event.created_at),
updated_at: formatDateToISO(event.updated_at),
url: (await GetSiteURL()) + serverResolver(`/maintenances/${event.id}`),
};
}
@@ -67,7 +72,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
}
const response: GetMaintenanceEventResponse = {
event: buildEventResponse(event),
event: await buildEventResponse(event),
};
return json(response);
@@ -113,7 +118,46 @@ export const PATCH: RequestHandler = async ({ locals, params, request }) => {
return json(errorResponse, { status: 400 });
}
// Validate required fields - both are required for event update
// Transition mode: `status` alone, mutually exclusive with window edits
if (body.status !== undefined) {
if (body.start_date_time !== undefined || body.end_date_time !== undefined) {
const errorResponse: BadRequestResponse = {
error: {
code: "BAD_REQUEST",
message: "Cannot update status and start/end times in the same request",
},
};
return json(errorResponse, { status: 400 });
}
if (body.status !== GC.COMPLETED && body.status !== GC.CANCELLED) {
const errorResponse: BadRequestResponse = {
error: {
code: "BAD_REQUEST",
message: `status must be ${GC.COMPLETED} or ${GC.CANCELLED}`,
},
};
return json(errorResponse, { status: 400 });
}
try {
const updatedEvent = await UpdateMaintenanceEventStatus(eventId, body.status);
const response: UpdateMaintenanceEventResponse = {
event: await buildEventResponse(updatedEvent),
};
return json(response);
} catch (err) {
const errorResponse: BadRequestResponse = {
error: {
code: "BAD_REQUEST",
message: err instanceof Error ? err.message : "Failed to update event status",
},
};
return json(errorResponse, { status: 400 });
}
}
// Window edit mode: both times required
if (body.start_date_time === undefined || body.start_date_time === null) {
const errorResponse: BadRequestResponse = {
error: {
@@ -182,7 +226,7 @@ export const PATCH: RequestHandler = async ({ locals, params, request }) => {
}
const response: UpdateMaintenanceEventResponse = {
event: buildEventResponse(updatedEvent),
event: await buildEventResponse(updatedEvent),
};
return json(response);
@@ -6,6 +6,8 @@ import type {
MaintenanceMonitor,
} from "$lib/types/api";
import { GetNowTimestampUTC, GetMinuteStartTimestampUTC } from "$lib/server/tool";
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
import serverResolver from "$lib/server/resolver";
const VALID_EVENT_STATUSES = ["SCHEDULED", "ONGOING", "COMPLETED", "CANCELLED", "READY"];
@@ -70,6 +72,7 @@ export const GET: RequestHandler = async ({ url }) => {
});
// For each event, get the monitors for that maintenance
const siteUrl = await GetSiteURL();
const events: MaintenanceEventDetailResponse[] = [];
for (const event of rawEvents) {
const monitors = await db.getMaintenanceMonitors(event.maintenance_id);
@@ -83,13 +86,14 @@ export const GET: RequestHandler = async ({ url }) => {
event_id: event.event_id,
event_start_date_time: event.event_start_date_time,
event_end_date_time: event.event_end_date_time,
event_status: event.event_status as "SCHEDULED" | "ONGOING" | "COMPLETED" | "CANCELLED",
event_status: event.event_status as MaintenanceEventDetailResponse["event_status"],
maintenance_title: event.maintenance_title,
maintenance_description: event.maintenance_description,
maintenance_status: event.maintenance_status as "ACTIVE" | "INACTIVE",
maintenance_rrule: event.maintenance_rrule,
maintenance_duration_seconds: event.maintenance_duration_seconds,
monitors: monitorList,
url: siteUrl + serverResolver(`/maintenances/${event.event_id}`),
});
}
@@ -101,6 +101,19 @@ export const POST: RequestHandler = async ({ request }) => {
return json(errorResponse, { status: 400 });
}
// Validate confirmation_threshold
let confirmationThreshold = 1;
if (body.confirmation_threshold !== undefined && body.confirmation_threshold !== null) {
const ct = Number(body.confirmation_threshold);
if (!Number.isInteger(ct) || ct < 1 || ct > 60) {
const errorResponse: BadRequestResponse = {
error: { code: "BAD_REQUEST", message: "confirmation_threshold must be an integer between 1 and 60" },
};
return json(errorResponse, { status: 400 });
}
confirmationThreshold = ct;
}
// Prepare monitor data for insertion
const monitorData = {
tag: body.tag.trim(),
@@ -115,6 +128,7 @@ export const POST: RequestHandler = async ({ request }) => {
type_data: body.type_data ? JSON.stringify(body.type_data) : null,
include_degraded_in_downtime: body.include_degraded_in_downtime ?? "NO",
is_hidden: body.is_hidden ?? "NO",
confirmation_threshold: confirmationThreshold,
monitor_settings_json: body.monitor_settings_json ? JSON.stringify(body.monitor_settings_json) : null,
};
@@ -1,11 +1,12 @@
import { json, type RequestHandler } from "@sveltejs/kit";
import db from "$lib/server/db/db";
import { GetMonitorsParsed } from "$lib/server/controllers/monitorsController";
import { GetMonitorsParsed, DeleteMonitorCompletelyUsingTag } from "$lib/server/controllers/monitorsController";
import type {
GetMonitorResponse,
MonitorResponse,
UpdateMonitorRequest,
UpdateMonitorResponse,
DeleteMonitorResponse,
BadRequestResponse,
} from "$lib/types/api";
@@ -79,6 +80,22 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
updateData.is_hidden = body.is_hidden !== undefined ? body.is_hidden : existingMonitor.is_hidden;
if (body.confirmation_threshold === null) {
// Explicit null resets the grace period to the default (1 = off); undefined keeps the existing value.
updateData.confirmation_threshold = 1;
} else if (body.confirmation_threshold !== undefined) {
const ct = Number(body.confirmation_threshold);
if (!Number.isInteger(ct) || ct < 1 || ct > 60) {
const errorResponse: BadRequestResponse = {
error: { code: "BAD_REQUEST", message: "confirmation_threshold must be an integer between 1 and 60" },
};
return json(errorResponse, { status: 400 });
}
updateData.confirmation_threshold = ct;
} else {
updateData.confirmation_threshold = existingMonitor.confirmation_threshold ?? 1;
}
// Handle JSON fields - merge with existing data instead of replacing
if (body.type_data !== undefined) {
if (body.type_data === null) {
@@ -143,3 +160,20 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
return json(response);
};
export const DELETE: RequestHandler = async ({ locals }) => {
// Monitor is validated by middleware and available in locals
const monitor = locals.monitor!;
// Removes the monitor and everything keyed to its tag: monitoring data,
// incident/maintenance/page links, alerts, alert configs, group
// memberships (with weight rebalancing), and caches. The scheduler drops
// the orphaned BullMQ job on its next reconcile.
await DeleteMonitorCompletelyUsingTag(monitor.tag);
const response: DeleteMonitorResponse = {
message: `Monitor '${monitor.tag}' deleted successfully`,
};
return json(response);
};
@@ -11,6 +11,7 @@ import GC from "$lib/global-constants";
import { UpdateMonitoringData } from "$lib/server/controllers/monitorsController";
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
import { SetLastMonitoringValue } from "$lib/server/cache/setGet";
import alertingQueue from "$lib/server/queues/alertingQueue";
export const GET: RequestHandler = async ({ locals, url }) => {
// Monitor is validated by middleware and available in locals
@@ -169,6 +170,18 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
await SetLastMonitoringValue(monitorTag, latestData);
}
// MANUAL samples are alert-visible (docs/adr/0005), so re-evaluate alerts once for the
// last written sample — for NONE monitors nothing else would ever trigger evaluation.
// UpdateMonitoringData floors both bounds to minute starts and writes through the floored
// end inclusive, so the last stored row is always at GetMinuteStartTimestampUTC(end_ts).
// Best-effort: the rows are already committed; a queue outage must not fail the request.
const lastWrittenTs = GetMinuteStartTimestampUTC(body.end_ts);
try {
await alertingQueue.push(monitorTag, lastWrittenTs, body.status);
} catch (err) {
console.error(`Failed to enqueue alert evaluation for ${monitorTag} after MANUAL data write:`, err);
}
// Calculate the number of data points that will be returned by GET
// GET uses: timestamp >= start_ts AND timestamp < end_ts
// Data is stored at minute-aligned timestamps
@@ -10,6 +10,7 @@ import type {
import GC from "$lib/global-constants";
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
import { SetLastMonitoringValue } from "$lib/server/cache/setGet";
import alertingQueue from "$lib/server/queues/alertingQueue";
export const GET: RequestHandler = async ({ params, locals }) => {
// Monitor is validated by middleware and available in locals
@@ -160,6 +161,15 @@ export const PATCH: RequestHandler = async ({ params, locals, request }) => {
await SetLastMonitoringValue(monitorTag, latestData);
}
// MANUAL samples are alert-visible (docs/adr/0005), so re-evaluate alerts for this
// sample — for NONE monitors nothing else would ever trigger evaluation.
// Best-effort: the row is already committed; a queue outage must not fail the request.
try {
await alertingQueue.push(monitorTag, timestamp, status);
} catch (err) {
console.error(`Failed to enqueue alert evaluation for ${monitorTag} after MANUAL data write:`, err);
}
// Fetch the updated data
const updatedData = await db.getMonitoringDataAt(monitorTag, timestamp);

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