Compare commits

...

177 Commits

Author SHA1 Message Date
github-actions[bot] e5b745ebbe chore(release): bump version to 4.1.2 2026-07-13 05:31:55 +00:00
Raj Nandan Sharma ce17afc2cb Merge pull request #784 from rajnandan1/release/4.1.2
Create changelog for version 4.1.2 with new features, improvements, a…
2026-07-13 11:01:14 +05:30
Raj Nandan Sharma fd1f21cad4 Create changelog for version 4.1.2 with new features, improvements, and bug fixes 2026-07-13 11:00:40 +05:30
Raj Nandan Sharma 6c47ba4d9a Merge pull request #780 from garadar/feature-follow-redirects
Handle follow rediretc or not
2026-07-13 10:53:06 +05:30
Raj Nandan Sharma e61c61ccc7 Merge pull request #783 from lokiee0/feature/reactive-chart-id
Refactor chartId to use $derived for reactive updates
2026-07-13 10:50:31 +05:30
Logesh K 7c297fce41 Refactor chartId to use $derived for reactive updates 2026-07-12 19:02:35 +05:30
Raj Nandan Sharma 0412dd91fd Merge pull request #782 from lokiee0/fix/781-favicon-path
Fix favicon resolution for KENER_BASE_PATH
2026-07-11 23:35:13 +05:30
lokiee0 4e593ec86d Guard favicon resolution when favicon is undefined 2026-07-11 12:34:24 +05:30
lokiee0 0f171fb8e6 Fix favicon resolution for KENER_BASE_PATH 2026-07-11 02:24:07 +05:30
alberta 86490909ec fixe max and step value 2026-07-10 11:46:37 +02:00
alberta 329cbccf75 Handle follow rediretc or not 2026-07-09 16:09:05 +02:00
Raj Nandan Sharma 10fb002a81 Merge pull request #779 from phinguyen/fix/vietnamese-translation
fix: Vietnamese translation
2026-07-09 14:29:17 +05:30
Phi ND 6292fe05ae Fix Vietnamese translation for Unknown impact 2026-07-09 15:20:38 +07:00
Phi Nguyen 88c6d3f76e Update src/lib/locales/vi.json
Agreed. ĐANG XÁC MINH is closer to “verifying/confirming” and can imply that the cause is already being checked. In this incident-management context, INVESTIGATING refers to the earlier phase where the team is still looking into the issue, so ĐANG ĐIỀU TRA is more accurate and clearer for Vietnamese users.

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-07-09 15:12:35 +07:00
Phi ND 70d4578a87 fix: Vietnamese translation 2026-07-09 15:03:34 +07:00
Raj Nandan Sharma ea07851b9d Merge pull request #775 from rajnandan1/fix/774
fix: notify subscribers when incident comments are posted
2026-07-07 23:06:11 +05:30
Raj Nandan Sharma 7c35d0ce66 Merge branch 'main' into fix/774 2026-07-07 23:05:57 +05:30
Raj Nandan Sharma e7a5b1df39 fix: address review comments on incident comment notifications
- Import GetAllSiteData directly from siteDataController instead of the
  controller barrel, which re-exports incidentController and creates a
  circular import (risk of partially-initialized modules at runtime).
- Pass a stable per-comment deduplication id to subscriberQueue.push so a
  retried/double push notifies once; without it the queue falls back to a
  Date.now()-suffixed id that never deduplicates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 22:53:58 +05:30
Raj Nandan Sharma 864896c416 Merge pull request #777 from TTurletti/fix/theme-not-respected
fix: pass defaultMode to ModeWatcher so site theme is respected
2026-07-06 22:31:06 +05:30
TTurletti 2d330b82de fix: pass defaultMode to ModeWatcher so site theme is respected on first visit 2026-07-06 13:42:53 +02:00
Raj Nandan Sharma f2f58e6b87 fix: notify subscribers when incident comments are posted
Manually created/updated incidents never reached the subscriber
notification workflow (#774): only alertingQueue pushed to
subscriberQueue, so dashboard and API incidents stayed silent.

Make AddIncidentComment the single notification choke point: posting a
comment on an INCIDENT-type incident notifies "incidents" subscribers,
regardless of source (alert, dashboard, or API). Remove the two bespoke
pushes in alertingQueue — alert-driven incidents now notify through the
same path via their auto-created comments, instead of twice.

The dedup id is now the comment id (previously the incident id with a
Date.now() suffix, which never deduplicated anything). Alert-created
incident emails now carry the incident state (INVESTIGATING) instead of
the alert status (TRIGGERED) in the subject; body content is unchanged.

Also drop the dead commented-out queueController blocks left from the
old notification system.

Fixes #774

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:17:21 +05:30
Raj Nandan Sharma c155a1b637 Merge pull request #772 from phatlet/feature/enhance-grpc-dns-monitoring
Enhance gRPC and DNS monitors with TLS options
2026-06-30 13:38:22 +05:30
phatlet c92c9ec273 fix: use rejectUnauthorized and checkServerIdentity for gRPC insecure TLS 2026-06-29 15:20:04 +07:00
phatlet 4470346e23 fix: address review feedback for gRPC insecure TLS and DNS-over-TLS 2026-06-29 15:07:32 +07:00
phatlet 29fcd22e20 feat: enhance grpc, dns monitoring 2026-06-29 14:51:34 +07:00
Raj Nandan Sharma ef46836c5b Merge pull request #770 from jhermesn/feat/external-url-api-support
feat(api): expose external_url in monitors POST and PATCH endpoints
2026-06-24 22:31:06 +05:30
Jorge Hermes 5f01558f7c fix(api): add external_url to Monitor response schema 2026-06-24 10:17:42 -03:00
Jorge Hermes 66350b8268 fix(api): make external_url nullable in CreateMonitorRequest schema 2026-06-24 09:25:16 -03:00
Jorge Hermes 1889f86e01 feat(api): expose external_url in monitors POST and PATCH endpoints
The field was already stored in the DB, mapped through the controller,
and present on MonitorRecordTyped — but missing from CreateMonitorRequest
and UpdateMonitorRequest, so the API silently dropped it.
2026-06-24 09:19:22 -03:00
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
github-actions[bot] 555372f175 chore(release): bump version to 4.0.21 2026-03-27 17:58:55 +00:00
Raj Nandan Sharma bd5fd409d1 chore: update documentation for v4.0.21 changelog and navigation 2026-03-27 22:27:24 +05:30
Raj Nandan Sharma f61d82e13f Merge pull request #688 from rajnandan1/implement/672
refactor: implement global maintenance notification settings and upda…
2026-03-27 21:28:02 +05:30
Raj Nandan Sharma f39588fff7 refactor: enhance maintenance notification settings validation and update event status logic 2026-03-27 21:02:51 +05:30
Raj Nandan Sharma 0716f271df refactor: implement global maintenance notification settings and update related event handling 2026-03-27 20:46:00 +05:30
Raj Nandan Sharma 1085c3e561 Merge pull request #679 from rajnandan1/release-4.0.21
refactor: support multi-monitor alerts by updating alert configuratio…
2026-03-25 16:44:43 +05:30
Raj Nandan Sharma 93907e6d96 refactor: improve error handling in tcpEval and evalResp validation 2026-03-25 16:40:44 +05:30
Raj Nandan Sharma d03fd63c64 refactor: support multi-monitor alerts by updating alert configuration and database schema
- Modify alertToVariables function to accept an optional monitorTag parameter for better flexibility in alert naming.
- Update sendAlertNotifications function to pass monitorTag to alertToVariables.
- Enhance addWorker function to handle multiple monitors by querying and creating alerts with the appropriate monitorTag.
- Introduce a new junction table for many-to-many relationships between monitor alerts and monitors.
- Update database migration to accommodate the new schema and backfill existing data.
- Revise frontend components to support multiple monitor selections in alert configurations.
- Enhance documentation to include analytics provider setup instructions.

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

All npm audit checks pass with 0 vulnerabilities.
2026-03-23 11:55:45 +01:00
Raj Nandan Sharma b1f8a505a5 Merge pull request #677 from rajnandan1/maintenance-prominient
refactor: implement upcoming maintenances feature in dashboard and mo…
2026-03-23 09:19:16 +05:30
Raj Nandan Sharma 104da58646 refactor: implement upcoming maintenances feature in dashboard and monitor views. implements #665 and #674 2026-03-23 09:18:45 +05:30
Raj Nandan Sharma ed6e97e8c9 refactor: update Google Tag Manager script source and enhance configuration with transport URL 2026-03-22 23:11:43 +05:30
Raj Nandan Sharma 0b25274874 refactor: enhance Google Tag Manager integration with transport URL and script host options 2026-03-22 20:31:34 +05:30
Raj Nandan Sharma bb8ab41bfe Merge pull request #673 from danynocz/update-czech-slovakia-march
Update Czech and Slovak translation
2026-03-20 22:16:01 +05:30
_DANYNO_ b43a5fb343 Update Slovak translations 2026-03-20 15:23:22 +01:00
_DANYNO_ 554caa5018 Update Czech translation 2026-03-20 15:19:55 +01:00
Raj Nandan Sharma ffe7403043 refactor: remove unused <svelte:head> elements and associated metadata from documentation layouts 2026-03-20 16:41:58 +05:30
Raj Nandan Sharma a26d0ece59 Merge pull request #671 from rajnandan1/fixcustom-js-css-guide-link
Fixcustom js css guide link
2026-03-20 15:37:50 +05:30
204 changed files with 10150 additions and 3630 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;
-27
View File
@@ -1,27 +0,0 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": ["src/app.html", "build/main.js", "scripts/**/*.{js,ts}", "migrations/**/*.{js,ts}", "seeds/**/*.{js,ts}"],
"project": ["src/**/*.{js,ts,svelte}", "scripts/**/*.{js,ts}", "migrations/**/*.{js,ts}", "seeds/**/*.{js,ts}"],
"ignore": ["src/lib/components/ui/**"],
"ignoreDependencies": [
"@babel/runtime",
"js-yaml",
"mysql2",
"node-cache",
"pg",
"pg-pool",
"randomstring",
"style-to-object",
"lucide-svelte",
"marked-gfm-heading-id"
],
"ignoreExportsUsedInFile": {
"interface": true,
"type": true
},
"ignoreBinaries": [],
"compilers": {
"css": ["postcss"],
"svelte": ["svelte"]
}
}
@@ -0,0 +1,215 @@
/**
* Migration: Multi-Monitor Alerts
*
* This migration refactors the monitor alerting system to support many-to-many
* relationships between alert configurations and monitors. Previously, each
* `monitor_alerts_config` row was tied to exactly one monitor via a `monitor_tag`
* foreign key column. This migration:
*
* 1. Creates a new `monitor_alerts_config_monitors` junction table that links
* `monitor_alerts_config` rows to one or more `monitors` rows, enabling a
* single alert configuration to fire across multiple monitors.
*
* 2. Adds a `monitor_tag` column to `monitor_alerts_v2` so that each firing
* alert record knows which specific monitor triggered it (important when one
* config covers many monitors).
*
* 3. Migrates existing data: copies every `monitor_alerts_config.monitor_tag`
* value into the new junction table and backfills `monitor_alerts_v2.monitor_tag`
* from the same source, preserving all historical alert records.
*
* 4. Removes the one-to-one constraint on `monitor_alerts_config.monitor_tag` by
* dropping its foreign key and setting the column nullable (SQLite workaround:
* nulls the column directly since SQLite cannot drop foreign key constraints
* inline).
*
* 5. Adds a composite index on `monitor_alerts_v2 (config_id, monitor_tag,
* alert_status)` for fast per-monitor alert status lookups.
*
* The `down` migration reverses these steps: restores the first junction-table
* entry back onto `monitor_alerts_config.monitor_tag`, re-adds the foreign key
* (non-SQLite), removes the `monitor_tag` column from `monitor_alerts_v2`
* (non-SQLite), and drops the junction table.
*/
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Step 1: Create monitor_alerts_config_monitors junction table
if (!(await knex.schema.hasTable("monitor_alerts_config_monitors"))) {
await knex.schema.createTable("monitor_alerts_config_monitors", (table) => {
table.integer("monitor_alerts_id").unsigned().notNullable();
table.string("monitor_tag", 255).notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Composite primary key
table.primary(["monitor_alerts_id", "monitor_tag"]);
// Foreign keys
table.foreign("monitor_alerts_id").references("id").inTable("monitor_alerts_config").onDelete("CASCADE");
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
});
}
// Step 2: Add monitor_tag column to monitor_alerts_v2 for per-monitor alert tracking
const hasV2Column = await knex.schema.hasColumn("monitor_alerts_v2", "monitor_tag");
if (!hasV2Column) {
await knex.schema.alterTable("monitor_alerts_v2", (table) => {
table.string("monitor_tag", 255).nullable();
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
});
}
// Step 3: Migrate existing data from monitor_alerts_config.monitor_tag to junction table
// and backfill monitor_alerts_v2.monitor_tag from the same source
const existingConfigs = await knex("monitor_alerts_config").whereNotNull("monitor_tag").select("id", "monitor_tag");
if (existingConfigs.length > 0) {
// Build a config_id -> monitor_tag map for backfilling alerts
const configTagMap = new Map<number, string>();
const inserts = existingConfigs.map((config: { id: number; monitor_tag: string }) => {
configTagMap.set(config.id, config.monitor_tag);
return {
monitor_alerts_id: config.id,
monitor_tag: config.monitor_tag,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
};
});
// Batch insert into junction table
const chunkSize = 100;
for (let i = 0; i < inserts.length; i += chunkSize) {
await knex("monitor_alerts_config_monitors").insert(inserts.slice(i, i + chunkSize));
}
// Backfill monitor_tag on existing monitor_alerts_v2 rows
const existingAlerts = await knex("monitor_alerts_v2").whereNull("monitor_tag").select("id", "config_id");
for (const alert of existingAlerts) {
const tag = configTagMap.get(alert.config_id);
if (tag) {
await knex("monitor_alerts_v2").where({ id: alert.id }).update({ monitor_tag: tag });
}
}
}
// Step 4: Drop foreign key and make monitor_tag nullable on monitor_alerts_config
const dbClient = knex.client.config.client;
if (dbClient === "sqlite3" || dbClient === "better-sqlite3") {
// SQLite cannot ALTER COLUMN, so we rebuild the table with monitor_tag nullable
await knex.transaction(async (trx) => {
await trx.raw("PRAGMA foreign_keys = OFF");
await trx.raw("DROP TABLE IF EXISTS monitor_alerts_config_new");
await trx.raw(`
CREATE TABLE monitor_alerts_config_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_tag VARCHAR(255),
alert_for VARCHAR(50) NOT NULL,
alert_value VARCHAR(255) NOT NULL,
failure_threshold INTEGER NOT NULL DEFAULT 1,
success_threshold INTEGER NOT NULL DEFAULT 1,
alert_description TEXT,
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await trx.raw(`
INSERT INTO monitor_alerts_config_new
(id, monitor_tag, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at)
SELECT
id, NULL, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at
FROM monitor_alerts_config
`);
await trx.raw("DROP TABLE monitor_alerts_config");
await trx.raw("ALTER TABLE monitor_alerts_config_new RENAME TO monitor_alerts_config");
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
} catch (_e) {
/* index may already exist */
}
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
} catch (_e) {
/* index may already exist */
}
await trx.raw("PRAGMA foreign_keys = ON");
});
} else {
try {
await knex.schema.alterTable("monitor_alerts_config", (table) => {
table.dropForeign(["monitor_tag"]);
});
} catch (_e) {
// Foreign key may not exist or already dropped
}
await knex.schema.alterTable("monitor_alerts_config", (table) => {
table.string("monitor_tag", 255).nullable().alter();
});
await knex("monitor_alerts_config").update({ monitor_tag: null });
}
// Step 5: Add composite index on monitor_alerts_v2 for fast lookups
try {
await knex.raw(
"CREATE INDEX idx_monitor_alerts_v2_config_tag_status ON monitor_alerts_v2 (config_id, monitor_tag, alert_status)",
);
} catch (_e) {
/* index already exists */
}
}
export async function down(knex: Knex): Promise<void> {
// Step 1: Drop the composite index on monitor_alerts_v2
try {
await knex.raw("DROP INDEX IF EXISTS idx_monitor_alerts_v2_config_tag_status");
} catch (_e) {
/* index may not exist */
}
// Step 2: Copy first monitor_tag from junction table back to monitor_alerts_config
const configs = await knex("monitor_alerts_config").select("id");
for (const config of configs) {
const firstMonitor = await knex("monitor_alerts_config_monitors").where({ monitor_alerts_id: config.id }).first();
if (firstMonitor) {
await knex("monitor_alerts_config").where({ id: config.id }).update({ monitor_tag: firstMonitor.monitor_tag });
}
}
// Step 3: Delete configs that have no monitors (can't satisfy NOT NULL)
await knex("monitor_alerts_config").whereNull("monitor_tag").del();
// Step 4: Re-add foreign key constraint on monitor_alerts_config (non-SQLite only)
const dbClient = knex.client.config.client;
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
await knex.schema.alterTable("monitor_alerts_config", (table) => {
table.string("monitor_tag", 255).notNullable().alter();
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
});
}
// Step 5: Drop monitor_tag column from monitor_alerts_v2 (non-SQLite only)
const hasV2Column = await knex.schema.hasColumn("monitor_alerts_v2", "monitor_tag");
if (hasV2Column) {
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
await knex.schema.alterTable("monitor_alerts_v2", (table) => {
table.dropForeign(["monitor_tag"]);
table.dropColumn("monitor_tag");
});
}
}
// Step 6: Drop junction table
await knex.schema.dropTableIfExists("monitor_alerts_config_monitors");
}
@@ -0,0 +1,144 @@
/**
* Migration: Fix SQLite monitor_alerts_config.monitor_tag NOT NULL constraint
*
* The earlier migration 20260325120000_multi_monitor_alerts nulled out data in
* monitor_alerts_config.monitor_tag for SQLite but could not alter the column
* constraint (SQLite doesn't support ALTER COLUMN). This migration recreates
* the table with monitor_tag as nullable, preserving all data and indexes.
*
* Only runs on SQLite/better-sqlite3; other databases already had the column
* altered in the previous migration.
*/
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
const dbClient = knex.client.config.client;
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
return; // Already handled by 20260325120000_multi_monitor_alerts
}
// Check if monitor_tag is already nullable (migration 1 already rebuilt the table)
const tableInfo: Array<{ name: string; notnull: number }> = await knex.raw(
"PRAGMA table_info(monitor_alerts_config)",
);
const monitorTagCol = tableInfo.find((col) => col.name === "monitor_tag");
if (monitorTagCol && monitorTagCol.notnull === 0) {
return; // Column is already nullable, nothing to do
}
// Column is still NOT NULL — rebuild the table to make it nullable
try {
await knex.transaction(async (trx) => {
await trx.raw("PRAGMA foreign_keys = OFF");
await trx.raw("DROP TABLE IF EXISTS monitor_alerts_config_new");
await trx.raw(`
CREATE TABLE monitor_alerts_config_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_tag VARCHAR(255),
alert_for VARCHAR(50) NOT NULL,
alert_value VARCHAR(255) NOT NULL,
failure_threshold INTEGER NOT NULL DEFAULT 1,
success_threshold INTEGER NOT NULL DEFAULT 1,
alert_description TEXT,
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await trx.raw(`
INSERT INTO monitor_alerts_config_new
(id, monitor_tag, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at)
SELECT
id, NULL, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at
FROM monitor_alerts_config
`);
await trx.raw("DROP TABLE monitor_alerts_config");
await trx.raw("ALTER TABLE monitor_alerts_config_new RENAME TO monitor_alerts_config");
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
} catch (_e) {
/* index may already exist */
}
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
} catch (_e) {
/* index may already exist */
}
await trx.raw("PRAGMA foreign_keys = ON");
});
} catch (e) {
await knex.raw("PRAGMA foreign_keys = ON");
throw e;
}
}
export async function down(knex: Knex): Promise<void> {
const dbClient = knex.client.config.client;
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
return;
}
// Revert: make monitor_tag NOT NULL again via table rebuild
try {
await knex.transaction(async (trx) => {
await trx.raw("PRAGMA foreign_keys = OFF");
await trx.raw(`
CREATE TABLE monitor_alerts_config_old (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_tag VARCHAR(255) NOT NULL,
alert_for VARCHAR(50) NOT NULL,
alert_value VARCHAR(255) NOT NULL,
failure_threshold INTEGER NOT NULL DEFAULT 1,
success_threshold INTEGER NOT NULL DEFAULT 1,
alert_description TEXT,
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Only copy rows that have a non-null monitor_tag
await trx.raw(`
INSERT INTO monitor_alerts_config_old
(id, monitor_tag, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at)
SELECT
id, monitor_tag, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at
FROM monitor_alerts_config
WHERE monitor_tag IS NOT NULL
`);
await trx.raw("DROP TABLE monitor_alerts_config");
await trx.raw("ALTER TABLE monitor_alerts_config_old RENAME TO monitor_alerts_config");
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
} catch (_e) {
/* index may already exist */
}
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
} catch (_e) {
/* index may already exist */
}
await trx.raw("PRAGMA foreign_keys = ON");
});
} catch (e) {
await knex.raw("PRAGMA foreign_keys = ON");
throw e;
}
}
@@ -0,0 +1,58 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// 1. Roles table
if (!(await knex.schema.hasTable("roles"))) {
await knex.schema.createTable("roles", (table) => {
table.string("id", 100).primary();
table.text("role_name").notNullable();
table.integer("readonly").notNullable().defaultTo(0);
table.string("status", 20).notNullable().defaultTo("ACTIVE");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
});
}
// 2. Permissions table
if (!(await knex.schema.hasTable("permissions"))) {
await knex.schema.createTable("permissions", (table) => {
table.string("id", 100).primary();
table.text("permission_name").notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
});
}
// 3. Roles ↔ Permissions junction table
if (!(await knex.schema.hasTable("roles_permissions"))) {
await knex.schema.createTable("roles_permissions", (table) => {
table.string("roles_id", 100).notNullable().references("id").inTable("roles").onDelete("CASCADE");
table.string("permissions_id", 100).notNullable().references("id").inTable("permissions").onDelete("CASCADE");
table.string("status", 20).notNullable().defaultTo("ACTIVE");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
table.primary(["roles_id", "permissions_id"]);
});
}
// 4. Users ↔ Roles junction table
if (!(await knex.schema.hasTable("users_roles"))) {
await knex.schema.createTable("users_roles", (table) => {
table.string("roles_id", 100).notNullable().references("id").inTable("roles").onDelete("CASCADE");
table.integer("users_id").unsigned().notNullable().references("id").inTable("users").onDelete("CASCADE");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
table.primary(["roles_id", "users_id"]);
table.index("users_id", "idx_users_roles_users_id");
});
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("users_roles");
await knex.schema.dropTableIfExists("roles_permissions");
await knex.schema.dropTableIfExists("permissions");
await knex.schema.dropTableIfExists("roles");
}
@@ -0,0 +1,94 @@
import type { Knex } from "knex";
// Maps the legacy users.role string to the new roles.id value.
// The old default was "user"; everything unmapped falls back to "member".
const ROLE_MAP: Record<string, string> = {
admin: "admin",
editor: "editor",
member: "member",
user: "member",
};
export async function up(knex: Knex): Promise<void> {
const hasColumn = await knex.schema.hasColumn("users", "role");
if (!hasColumn) return;
// 1. Ensure the three target roles exist so FK inserts succeed.
// Seeds will reconcile permissions later; we only need the rows.
const rolesToEnsure = [
{ id: "admin", role_name: "Administrator" },
{ id: "editor", role_name: "Editor" },
{ id: "member", role_name: "Member" },
];
for (const role of rolesToEnsure) {
const exists = await knex("roles").where("id", role.id).first();
if (!exists) {
await knex("roles").insert({
id: role.id,
role_name: role.role_name,
readonly: 1,
status: "ACTIVE",
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
}
// 2. Read users.role into memory BEFORE dropping the column.
// On SQLite, dropColumn recreates the table (create → copy → drop → rename),
// which can discard DML inserts to tables with FKs pointing at users.
const users: Array<{ id: number; role: string }> = await knex("users").select("id", "role");
// 3. Drop the column first.
await knex.schema.alterTable("users", (table) => {
table.dropColumn("role");
});
// 4. Now populate users_roles from the in-memory snapshot.
for (const user of users) {
const newRoleId = ROLE_MAP[user.role] ?? "member";
const alreadyAssigned = await knex("users_roles").where({ roles_id: newRoleId, users_id: user.id }).first();
if (!alreadyAssigned) {
await knex("users_roles").insert({
roles_id: newRoleId,
users_id: user.id,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
}
}
// Reverse map: pick the highest-precedence role when backfilling.
const REVERSE_ROLE_PRECEDENCE: string[] = ["admin", "editor", "member"];
export async function down(knex: Knex): Promise<void> {
const hasColumn = await knex.schema.hasColumn("users", "role");
if (!hasColumn) {
await knex.schema.alterTable("users", (table) => {
table.string("role").defaultTo("member");
});
}
// Backfill users.role from users_roles using deterministic precedence
const assignments: Array<{ users_id: number; roles_id: string }> = await knex("users_roles").select(
"users_id",
"roles_id",
);
// Group roles by user
const userRolesMap = new Map<number, string[]>();
for (const row of assignments) {
const list = userRolesMap.get(row.users_id) || [];
list.push(row.roles_id);
userRolesMap.set(row.users_id, list);
}
// Pick highest-precedence role for each user
for (const [userId, roleIds] of userRolesMap) {
const bestRole = REVERSE_ROLE_PRECEDENCE.find((r) => roleIds.includes(r)) || roleIds[0] || "member";
await knex("users").where("id", userId).update({ role: bestRole });
}
}
@@ -0,0 +1,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",
);
});
}
+211 -221
View File
@@ -1,12 +1,12 @@
{
"name": "kener",
"version": "4.0.20",
"version": "4.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kener",
"version": "4.0.20",
"version": "4.1.2",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
@@ -89,7 +89,7 @@
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/kit": "^2.53.3",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
@@ -114,7 +114,7 @@
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.43.8",
"svelte": "^5.53.5",
"svelte-awesome-color-picker": "^4.1.0",
"svelte-check": "^4.3.4",
"svelte-sonner": "^1.0.7",
@@ -1792,9 +1792,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
"integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
"cpu": [
"arm"
],
@@ -1805,9 +1805,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
"integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
"cpu": [
"arm64"
],
@@ -1818,9 +1818,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
"integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
"cpu": [
"arm64"
],
@@ -1831,9 +1831,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
"integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
"cpu": [
"x64"
],
@@ -1844,9 +1844,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
"integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
"cpu": [
"arm64"
],
@@ -1857,9 +1857,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
"integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
"cpu": [
"x64"
],
@@ -1870,9 +1870,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
"integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
"cpu": [
"arm"
],
@@ -1883,9 +1883,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
"integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
"cpu": [
"arm"
],
@@ -1896,9 +1896,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
"integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
"cpu": [
"arm64"
],
@@ -1909,9 +1909,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
"integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
"cpu": [
"arm64"
],
@@ -1922,9 +1922,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
"integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
"cpu": [
"loong64"
],
@@ -1935,9 +1935,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz",
"integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==",
"cpu": [
"loong64"
],
@@ -1948,9 +1948,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
"integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
"cpu": [
"ppc64"
],
@@ -1961,9 +1961,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz",
"integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==",
"cpu": [
"ppc64"
],
@@ -1974,9 +1974,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
"integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
"cpu": [
"riscv64"
],
@@ -1987,9 +1987,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
"integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
"cpu": [
"riscv64"
],
@@ -2000,9 +2000,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
"integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
"cpu": [
"s390x"
],
@@ -2013,9 +2013,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
"integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
"cpu": [
"x64"
],
@@ -2026,9 +2026,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
"integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
"cpu": [
"x64"
],
@@ -2039,9 +2039,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz",
"integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==",
"cpu": [
"x64"
],
@@ -2052,9 +2052,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
"integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
"cpu": [
"arm64"
],
@@ -2065,9 +2065,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
"integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
"cpu": [
"arm64"
],
@@ -2078,9 +2078,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
"integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
"cpu": [
"ia32"
],
@@ -2091,9 +2091,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
"integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
"cpu": [
"x64"
],
@@ -2104,9 +2104,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
"integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
"cpu": [
"x64"
],
@@ -2262,9 +2262,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.52.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.52.0.tgz",
"integrity": "sha512-zG+HmJuSF7eC0e7xt2htlOcEMAdEtlVdb7+gAr+ef08EhtwUsjLxcAwBgUCJY3/5p08OVOxVZti91WfXeuLvsg==",
"version": "2.55.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz",
"integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -2272,12 +2272,11 @@
"@types/cookie": "^0.6.0",
"acorn": "^8.14.1",
"cookie": "^0.6.0",
"devalue": "^5.6.2",
"devalue": "^5.6.4",
"esm-env": "^1.2.2",
"kleur": "^4.1.5",
"magic-string": "^0.30.5",
"mrmime": "^2.0.0",
"sade": "^1.8.1",
"set-cookie-parser": "^3.0.0",
"sirv": "^3.0.0"
},
@@ -2289,10 +2288,10 @@
},
"peerDependencies": {
"@opentelemetry/api": "^1.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
"typescript": "^5.3.3",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
@@ -2868,8 +2867,7 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/@uiw/codemirror-theme-github": {
"version": "4.25.4",
@@ -2984,9 +2982,9 @@
"license": "Python-2.0"
},
"node_modules/aria-query": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -3142,10 +3140,13 @@
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/barse": {
"version": "0.4.3",
@@ -3334,13 +3335,15 @@
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/browserslist": {
@@ -3755,12 +3758,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
@@ -3809,9 +3806,9 @@
}
},
"node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -4538,9 +4535,9 @@
}
},
"node_modules/devalue": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
"version": "5.6.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
"license": "MIT"
},
"node_modules/dns2": {
@@ -4591,9 +4588,9 @@
}
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -5064,25 +5061,16 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fast-xml-parser": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
"node_modules/fast-xml-builder": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
"funding": [
{
"type": "github",
@@ -5091,7 +5079,24 @@
],
"license": "MIT",
"dependencies": {
"strnum": "^2.1.0"
"path-expression-matcher": "^1.1.3"
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.8",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz",
"integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.2.0",
"strnum": "^2.2.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -5577,42 +5582,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob/node_modules/balanced-match": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz",
"integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==",
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
"integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/globalthis": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
@@ -7151,27 +7120,27 @@
}
},
"node_modules/mailparser": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.1.tgz",
"integrity": "sha512-6vHZcco3fWsDMkf4Vz9iAfxvwrKNGbHx0dV1RKVphQ/zaNY34Buc7D37LSa09jeSeybWzYcTPjhiZFxzVRJedA==",
"version": "3.9.5",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.5.tgz",
"integrity": "sha512-i5mXdEqDrh7I095uiQiebOrc00AOIJRLlpze1nBb82oZpI4GaCJIn9ypXR2bqb/Ayr7q+nmT6k11yYU5Age/Gg==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0",
"he": "1.2.0",
"html-to-text": "9.0.5",
"iconv-lite": "0.7.0",
"iconv-lite": "0.7.2",
"libmime": "5.3.7",
"linkify-it": "5.0.0",
"nodemailer": "7.0.11",
"nodemailer": "8.0.3",
"punycode.js": "2.3.1",
"tlds": "1.261.0"
}
},
"node_modules/mailparser/node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -7185,9 +7154,9 @@
}
},
"node_modules/mailparser/node_modules/nodemailer": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz",
"integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==",
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.3.tgz",
"integrity": "sha512-JQNBqvK+bj3NMhUFR3wmCl3SYcOeMotDiwDBvIoCuQdF0PvlIY0BH+FJ2CG7u4cXKPChplE78oowlH/Otsc4ZQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -7358,15 +7327,18 @@
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"license": "ISC",
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^1.1.7"
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "*"
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
@@ -7466,6 +7438,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
@@ -8090,6 +8063,21 @@
"node": ">= 0.8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz",
"integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -8979,9 +8967,9 @@
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
"integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@@ -8994,31 +8982,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.57.1",
"@rollup/rollup-android-arm64": "4.57.1",
"@rollup/rollup-darwin-arm64": "4.57.1",
"@rollup/rollup-darwin-x64": "4.57.1",
"@rollup/rollup-freebsd-arm64": "4.57.1",
"@rollup/rollup-freebsd-x64": "4.57.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
"@rollup/rollup-linux-arm64-musl": "4.57.1",
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
"@rollup/rollup-linux-loong64-musl": "4.57.1",
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
"@rollup/rollup-linux-x64-gnu": "4.57.1",
"@rollup/rollup-linux-x64-musl": "4.57.1",
"@rollup/rollup-openbsd-x64": "4.57.1",
"@rollup/rollup-openharmony-arm64": "4.57.1",
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
"@rollup/rollup-win32-x64-gnu": "4.57.1",
"@rollup/rollup-win32-x64-msvc": "4.57.1",
"@rollup/rollup-android-arm-eabi": "4.60.0",
"@rollup/rollup-android-arm64": "4.60.0",
"@rollup/rollup-darwin-arm64": "4.60.0",
"@rollup/rollup-darwin-x64": "4.60.0",
"@rollup/rollup-freebsd-arm64": "4.60.0",
"@rollup/rollup-freebsd-x64": "4.60.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
"@rollup/rollup-linux-arm-musleabihf": "4.60.0",
"@rollup/rollup-linux-arm64-gnu": "4.60.0",
"@rollup/rollup-linux-arm64-musl": "4.60.0",
"@rollup/rollup-linux-loong64-gnu": "4.60.0",
"@rollup/rollup-linux-loong64-musl": "4.60.0",
"@rollup/rollup-linux-ppc64-gnu": "4.60.0",
"@rollup/rollup-linux-ppc64-musl": "4.60.0",
"@rollup/rollup-linux-riscv64-gnu": "4.60.0",
"@rollup/rollup-linux-riscv64-musl": "4.60.0",
"@rollup/rollup-linux-s390x-gnu": "4.60.0",
"@rollup/rollup-linux-x64-gnu": "4.60.0",
"@rollup/rollup-linux-x64-musl": "4.60.0",
"@rollup/rollup-openbsd-x64": "4.60.0",
"@rollup/rollup-openharmony-arm64": "4.60.0",
"@rollup/rollup-win32-arm64-msvc": "4.60.0",
"@rollup/rollup-win32-ia32-msvc": "4.60.0",
"@rollup/rollup-win32-x64-gnu": "4.60.0",
"@rollup/rollup-win32-x64-msvc": "4.60.0",
"fsevents": "~2.3.2"
}
},
@@ -9092,6 +9080,7 @@
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"mri": "^1.1.0"
@@ -9849,9 +9838,9 @@
"license": "MIT"
},
"node_modules/strnum": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
"funding": [
{
"type": "github",
@@ -9904,20 +9893,21 @@
}
},
"node_modules/svelte": {
"version": "5.50.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.50.1.tgz",
"integrity": "sha512-/Jlom4ddkISyVHXpM2O5dXP9pYnaiFrVQzPbIL1/pEoOa77ZunCb6nDgUCTNCQ/X3t64z9ukrK6R+BbB3kPR3A==",
"version": "5.54.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.54.1.tgz",
"integrity": "sha512-ow8tncN097Ty8U1H+C3bM1xNlsCbnO2UZeN0lWBnv8f3jKho7QTTQ2LWbMXrPQDodLjH91n4kpNnLolyRhVE6A==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@types/estree": "^1.0.5",
"@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1",
"aria-query": "^5.3.1",
"aria-query": "5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"devalue": "^5.6.2",
"devalue": "^5.6.4",
"esm-env": "^1.2.1",
"esrap": "^2.2.2",
"is-reference": "^3.0.3",
@@ -10448,9 +10438,9 @@
}
},
"node_modules/undici": {
"version": "7.21.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz",
"integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==",
"version": "7.24.5",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz",
"integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
+13 -3
View File
@@ -1,6 +1,6 @@
{
"name": "kener",
"version": "4.0.20",
"version": "4.1.2",
"type": "module",
"private": false,
"license": "MIT",
@@ -55,7 +55,7 @@
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/kit": "^2.53.3",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
@@ -80,7 +80,7 @@
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.43.8",
"svelte": "^5.53.5",
"svelte-awesome-color-picker": "^4.1.0",
"svelte-check": "^4.3.4",
"svelte-sonner": "^1.0.7",
@@ -172,5 +172,15 @@
"style-to-object": "^1.0.14",
"svelte-codemirror-editor": "^2.1.0",
"vite-plugin-package-version": "^1.1.0"
},
"overrides": {
"fast-xml-parser": "^5.5.6",
"rollup": "^4.59.0",
"undici": "^7.24.0",
"minimatch": "^10.2.3",
"devalue": "^5.6.4",
"dompurify": "^3.3.2",
"cookie": "^0.7.0",
"mailparser": "^3.9.3"
}
}
+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);
}
+58 -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) {
@@ -152,6 +152,21 @@ function IsValidNameServer(nameServer: string): boolean {
const regex = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/;
return regex.test(nameServer);
}
function IsValidDnsResolver(resolver: string): boolean {
const normalizedResolver = resolver.trim();
const ipv4Parts = normalizedResolver.split(".");
if (
ipv4Parts.length === 4 &&
ipv4Parts.every((part) => /^\d+$/.test(part) && Number(part) >= 0 && Number(part) <= 255)
) {
return true;
}
const ipType = ValidateIpAddress(normalizedResolver);
if (ipType === "IP6") {
return true;
}
return IsValidHost(normalizedResolver);
}
const IsValidURL = function (url: string): boolean {
return /^(http|https):\/\/[^ "]+$/.test(url);
};
@@ -316,46 +331,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 {
@@ -376,8 +398,10 @@ export {
ValidateIpAddress,
IsValidHost,
IsValidNameServer,
IsValidDnsResolver,
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"
@@ -17,7 +17,7 @@
config: ChartConfig;
} = $props();
const chartId = `chart-${id || uid.replace(/:/g, "")}`;
const chartId = $derived(`chart-${id || uid.replace(/:/g, "")}`);
setChartContext({
get config() {
@@ -5,8 +5,10 @@
type ToggleVariants = VariantProps<typeof toggleVariants>;
interface ToggleGroupContext extends ToggleVariants {
spacing?: number;
interface ToggleGroupContext {
get variant(): ToggleVariants["variant"];
get size(): ToggleVariants["size"];
get spacing(): number | undefined;
}
export function setToggleGroupCtx(props: ToggleGroupContext) {
@@ -33,9 +35,15 @@
}: ToggleGroupPrimitive.RootProps & ToggleVariants & { spacing?: number } = $props();
setToggleGroupCtx({
variant,
size,
spacing,
get variant() {
return variant;
},
get size() {
return size;
},
get spacing() {
return spacing;
},
});
</script>
+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,
+31 -29
View File
@@ -22,39 +22,39 @@
"Day": "Den",
"Day Uptime": "Denní dostupnost",
"Days": "Dní",
"Degraded": "Zhoršený",
"DEGRADED": "ZHORŠENÝ",
"Degraded Performance": "Zhoršený výkon",
"Didn't receive the code? Resend": "Nepřišel vám kód? Odeslat znovu",
"Down": "Nedostupný",
"DOWN": "VÝPADEK",
"Degraded": "Omezený",
"DEGRADED": "OMEZENÝ",
"Degraded Performance": "Snížený výkon",
"Didn't receive the code? Resend": "Nepřišel kód? Odeslat znovu",
"Down": "Nedostupné",
"DOWN": "NEDOSTUPNÉ",
"Duration": "Trvání",
"Edit Monitor": "Upravit monitor",
"Email address": "E-mailová adresa",
"Embed Monitor": "Vložit monitor",
"Embed this monitor in your website or app": "Vložte tento monitor na svůj web nebo do aplikace",
"Embed this monitor in your website or app": "Vložte tento monitor na web nebo do aplikace",
"End Time": "Konec",
"Enter the verification code sent to your email.": "Zadejte ověřovací kód zaslaný na váš e-mail.",
"Enter the verification code sent to your email.": "Zadejte ověřovací kód zaslaný na e-mail",
"Events": "Události",
"Failed to load data": "Nepodařilo se načíst data",
"Failed to load latency data": "Nepodařilo se načíst data latence",
"Failed to load status data for this day": "Nepodařilo se načíst data stavu pro tento den",
"Failed to send verification code": "Nepodařilo se odeslat ověřovací kód",
"Failed to update preference": "Nepodařilo se aktualizovat nastavení",
"Failed to update preference": "Nepodařilo se uložit nastavení",
"Format": "Formát",
"Get badges for this monitor": "Získat odznaky pro tento monitor",
"Get notified about incidents and scheduled maintenance.": "Dostávejte upozornění na incidenty a plánovanou údržbu.",
"Get notified about incidents and scheduled maintenance.": "Dostávejte upozornění na incidenty a plánovanou údržbu",
"Get notified about incidents updates": "Dostávejte upozornění na aktualizace incidentů",
"Get notified about scheduled maintenance": "Dostávejte upozornění na plánovanou údržbu",
"Home": "Domů",
"IDENTIFIED": "IDENTIFIKOVÁNO",
"iFrame": "iFrame",
"Impact": "Dopad",
"incident": "Incident",
"incident": "incident",
"Incident": "Incident",
"Incident Updates": "Aktualizace incidentů",
"Incidents": "Incidenty",
"Included Monitors (%count)": "Zahrnutých monitorů: %count",
"Included Monitors (%count)": "Zahrnuté monitory (%count)",
"INVESTIGATING": "VYŠETŘOVÁNÍ",
"Last Updated": "Naposledy aktualizováno",
"Latency": "Latence",
@@ -62,18 +62,18 @@
"Latency Over Time": "Latence v čase",
"Latency Trend": "Trend latence",
"Latest Latency": "Poslední latence",
"Latest Status": "Posled stav",
"Latest Status": "Naposledy zjištěný stav",
"Light": "Světlý",
"Live Status": "Aktuální stav",
"Loading your preferences...": "Načítám vaše nastavení...",
"maintenance": "Údržba",
"Loading your preferences...": "Načítá nastavení...",
"maintenance": "údržba",
"Maintenance": "Údržba",
"MAINTENANCE": "ÚDRŽBA",
"Maintenance Updates": "Aktualizace údržby",
"Maintenances": "Údrždy",
"Maintenances": "Údržby",
"Major System Outage": "Závažný výpadek systému",
"Manage Site": "Spravovat stránku",
"Manage your notification preferences.": "Spravujte svá nastavení oznámení.",
"Manage your notification preferences.": "Spravujte nastavení oznámení",
"Max Latency": "Max. latence",
"maximum": "maximální",
"Maximum Latency": "Maximální latence",
@@ -82,58 +82,60 @@
"Minimum Latency": "Minimální latence",
"Minute-by-minute status data for this day": "Minutová data stavu pro tento den",
"MONITORING": "MONITOROVÁNÍ",
"Network error. Please try again.": "Chyba sítě. Zkuste to prosím znovu.",
"Network error. Please try again.": "Chyba sítě. Zkuste to znovu",
"No Events in %currentMonth": "V měsíci %currentMonth nejsou plánované žádné události",
"No events to show": "Žádné události k zobrazení",
"No incidents for this day": "Pro tento den nejsou evidovány žádné incidenty",
"No latency data available for this day": "Pro tento den nejsou k dispozici data latence",
"No latency data available for this day": "Pro tento den nejsou dostupná data latence",
"No maintenances for this day": "Pro tento den není naplánovaná žádná údržba",
"No monitors affected": "Žádné zasažené monitory",
"No monitors available.": "Žádné dostupné monitory.",
"No monitors available.": "Žádné dostupné monitory",
"No ongoing maintenances": "Žádná probíhající údržba",
"No past maintenances": "Žádná minulá údržba",
"No Status Available": "Stav není k dispozici",
"No upcoming maintenances": "Žádná nadcházející údržba",
"No Updates": "Žádné aktualizace",
"No updates yet": "Zatím bez aktualizací",
"No updates yet": "Zatím žádné aktualizace",
"NO_DATA": "Žádná data",
"Notifications": "Oznámení",
"One-time": "Jednorázově",
"Ongoing": "Probíhající",
"ONGOING": "PROBÍHAJÍCÍ",
"Ongoing Maintenances": "Probíhající údržby",
"Operational": "V provozu",
"Partial Degraded Performance": "Částečně zhoršený výkon",
"Partial Degraded Performance": "Částečně omezený provoz",
"Partial System Outage": "Částečný výpadek systému",
"Past": "Minulé",
"Per-Minute Status": "Stav po minutách",
"Pinging": "Pingování",
"Please enter a valid email address": "Zadejte platnou e-mailovou adresu",
"Please enter the 6-digit verification code": "Zadejte prosím 6místný ověřovací kód",
"Please enter the 6-digit verification code": "Zadejte 6místný ověřovací kód",
"Read less": "Zobrazit méně",
"Read more": "Zobrazit více",
"READY": "PŘIPRAVENO",
"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",
"Script": "Skript",
"Select Language": "Vyberte jazyk",
"Select latency metric to display": "Vyberte metriku latence k zobrazení",
"Select latency metric to display": "Vyberte metriku latence",
"Select Range": "Vyberte rozsah",
"Sending...": "Odesílání...",
"Standard": "Standardní",
"Start Time": "Začátek",
"Status": "Stav",
"Status Badge": "Odznak stavu",
"Status Embed": "Status pro vložení",
"Status Embed": "Stav pro vložení",
"Status history and latency trend": "Historie stavu a trend latence",
"Subscribe": "Odebírat",
"Subscribe": "Přihlásit se k odběru",
"Subscribe to Updates": "Odebírat aktualizace",
"Theme": "Motiv",
"There are no incidents or maintenances scheduled for this month.": "Na tento měsíc nejsou naplánované incidenty ani údržba.",
"There are no ongoing incidents or maintenance events.": "V tuto chvíli neprobíhají žádné incidenty ani údržba.",
"There are no incidents or maintenances scheduled for this month.": "Na tento měsíc nejsou naplánované žádné incidenty ani údržby",
"There are no ongoing incidents or maintenance events.": "V tuto chvíli neprobíhají žádné incidenty ani údržba",
"Timeline": "Časová osa",
"Total Incidents": "Celkem incidentů",
"Total Maintenances": "Celkem údržeb",
@@ -151,6 +153,6 @@
"Verification failed": "Ověření se nezdařilo",
"Verify": "Ověřit",
"Verifying": "Ověřování",
"We sent a 6-digit code to": "Poslali jsme 6místný kód na"
"We sent a 6-digit code to": "Odeslali jsme 6místný kód na"
}
}
+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": "Скрипт",
+36 -34
View File
@@ -6,7 +6,7 @@
"30 Days": "30 dní",
"7 Days": "7 dní",
"90 Days": "90 dní",
"Affected Monitors (%count)": "Ovládacie monitory (%count)",
"Affected Monitors (%count)": "Ovplyvnené monitory (%count)",
"All Systems Operational": "Všetky systémy sú v prevádzke",
"average": "priemerná",
"Average Latency": "Priemerná latencia",
@@ -22,58 +22,58 @@
"Day": "Deň",
"Day Uptime": "Denná dostupnosť",
"Days": "Dní",
"Degraded": "Zhoršený",
"DEGRADED": "ZHORŠENÝ",
"Degraded Performance": "Zhoršený výkon",
"Didn't receive the code? Resend": "Nedostali ste kód? Odoslať znova",
"Down": "Nedostupný",
"DOWN": "VÝPADOK",
"Degraded": "Obmedzený",
"DEGRADED": "OBMEDZENÝ",
"Degraded Performance": "Znížený výkon",
"Didn't receive the code? Resend": "Neprišiel kód? Odoslať znova",
"Down": "Nedostupné",
"DOWN": "NEDOSTUPNÉ",
"Duration": "Trvanie",
"Edit Monitor": "Upraviť monitor",
"Email address": "E-mailová adresa",
"Embed Monitor": "Vložiť monitor",
"Embed this monitor in your website or app": "Vložte tento monitor na svoj web alebo do aplikácie",
"Embed this monitor in your website or app": "Vložte tento monitor na web alebo do aplikácie",
"End Time": "Koniec",
"Enter the verification code sent to your email.": "Zadajte overovací kód zaslaný na váš e-mail.",
"Enter the verification code sent to your email.": "Zadajte overovací kód zaslaný na e-mail",
"Events": "Udalosti",
"Failed to load data": "Nepodarilo sa načítať dáta",
"Failed to load latency data": "Nepodarilo sa načítať dáta latencie",
"Failed to load status data for this day": "Nepodarilo sa načítať dáta stavu pre tento deň",
"Failed to load status data for this day": "Nepodarilo sa načítať stavové dáta pre tento deň",
"Failed to send verification code": "Nepodarilo sa odoslať overovací kód",
"Failed to update preference": "Nepodarilo sa aktualizovať nastavenia",
"Failed to update preference": "Nepodarilo sa uložiť nastavenie",
"Format": "Formát",
"Get badges for this monitor": "Získať odznaky pre tento monitor",
"Get notified about incidents and scheduled maintenance.": "Dostávajte upozornenia na incidenty a plánovanú údržbu.",
"Get notified about incidents and scheduled maintenance.": "Dostávajte upozornenia na incidenty a plánovanú údržbu",
"Get notified about incidents updates": "Dostávajte upozornenia na aktualizácie incidentov",
"Get notified about scheduled maintenance": "Dostávajte upozornenia na plánovanú údržbu",
"Home": "Domov",
"IDENTIFIED": "IDENTIFIKOVANÉ",
"iFrame": "iFrame",
"Impact": "Dopad",
"incident": "Incident",
"incident": "incident",
"Incident": "Incident",
"Incident Updates": "Aktualizácie incidentov",
"Incidents": "Incidenty",
"Included Monitors (%count)": "Zahrnutých monitorov: %count",
"INVESTIGATING": "VYŠETROVANIE",
"Included Monitors (%count)": "Zahrnuté monitory (%count)",
"INVESTIGATING": "PREBIEHA VYŠETROVANIE",
"Last Updated": "Naposledy aktualizované",
"Latency": "Latencia",
"Latency Embed": "Vložená latencia",
"Latency Over Time": "Latencia v čase",
"Latency Trend": "Trend latencie",
"Latest Latency": "Posledná latencia",
"Latest Status": "Posledný stav",
"Latest Status": "Naposledy zistený stav",
"Light": "Svetlý",
"Live Status": "Aktuálny stav",
"Loading your preferences...": "Načítavam vaše nastavenia...",
"maintenance": "Údržba",
"Loading your preferences...": "Načítavanie nastavení...",
"maintenance": "údržba",
"Maintenance": "Údržba",
"MAINTENANCE": "ÚDRŽBA",
"Maintenance Updates": "Aktualizácie údržby",
"Maintenances": "Údržby",
"Major System Outage": "Závažný výpadok systému",
"Manage Site": "Spravovať stránku",
"Manage your notification preferences.": "Spravujte svoje nastavenia oznámení.",
"Manage your notification preferences.": "Spravujte nastavenia upozornení",
"Max Latency": "Max. latencia",
"maximum": "maximálna",
"Maximum Latency": "Maximálna latencia",
@@ -82,27 +82,28 @@
"Minimum Latency": "Minimálna latencia",
"Minute-by-minute status data for this day": "Minútové dáta stavu pre tento deň",
"MONITORING": "MONITOROVANIE",
"Network error. Please try again.": "Chyba siete. Skúste to znova.",
"No Events in %currentMonth": "V mesiaci %currentMonth nie sú plánované žiadne udalosti",
"Network error. Please try again.": "Chyba siete. Skúste to znova",
"No Events in %currentMonth": "V mesiaci %currentMonth nie sú žiadne udalosti",
"No events to show": "Žiadne udalosti na zobrazenie",
"No incidents for this day": "Pre tento deň nie sú evidované žiadne incidenty",
"No latency data available for this day": "Pre tento deň nie sú k dispozícii dáta latencie",
"No latency data available for this day": "Pre tento deň nie sú dostupné dáta latencie",
"No maintenances for this day": "Pre tento deň nie je naplánovaná žiadna údržba",
"No monitors affected": "Žiadne zasiahnuté monitory",
"No monitors available.": "Žiadne dostupné monitory.",
"No monitors affected": "Žiadne ovplyvnené monitory",
"No monitors available.": "Žiadne dostupné monitory",
"No ongoing maintenances": "Žiadna prebiehajúca údržba",
"No past maintenances": "Žiadna minula údržba",
"No past maintenances": "Žiadna minulá údržba",
"No Status Available": "Stav nie je k dispozícii",
"No upcoming maintenances": "Žiadna nadchádzajúca údržba",
"No Updates": "Žiadne aktualizácie",
"No updates yet": "Zatiaľ bez aktualizácií",
"No updates yet": "Zatiaľ žiadne aktualizácie",
"NO_DATA": "Žiadne dáta",
"Notifications": "Oznámenia",
"Notifications": "Upozornenia",
"One-time": "Jednorazovo",
"Ongoing": "Prebiehajúce",
"ONGOING": "PREBIEHAJÚCE",
"Ongoing Maintenances": "Prebiehajúce údržby",
"Operational": "V prevádzke",
"Partial Degraded Performance": "Čiastočne zhoršený výkon",
"Partial Degraded Performance": "Čiastočne obmedzený výkon",
"Partial System Outage": "Čiastočný výpadok systému",
"Past": "Minulé",
"Per-Minute Status": "Stav po minútach",
@@ -115,25 +116,26 @@
"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",
"Script": "Skript",
"Select Language": "Vyberte jazyk",
"Select latency metric to display": "Vyberte metriku latencie na zobrazenie",
"Select latency metric to display": "Vyberte metriku latencie",
"Select Range": "Vyberte rozsah",
"Sending...": "Odosielanie...",
"Standard": "Štandardný",
"Start Time": "Začiatok",
"Status": "Stav",
"Status Badge": "Odznak stavu",
"Status Embed": "Status na vloženie",
"Status Embed": "Stav na vloženie",
"Status history and latency trend": "História stavu a trend latencie",
"Subscribe": "Odober",
"Subscribe": "Prihlásiť sa na odber",
"Subscribe to Updates": "Odoberať aktualizácie",
"Theme": "Motív",
"There are no incidents or maintenances scheduled for this month.": "Na tento mesiac nie sú naplánované incidenty ani údržba.",
"There are no ongoing incidents or maintenance events.": "V tejto chvíli neprebiehajú žiadne incidenty ani údržba.",
"There are no incidents or maintenances scheduled for this month.": "Na tento mesiac nie sú naplánované žiadne incidenty ani údržby",
"There are no ongoing incidents or maintenance events.": "Momentálne neprebiehajú žiadne incidenty ani údržba",
"Timeline": "Časová os",
"Total Incidents": "Celkom incidentov",
"Total Maintenances": "Celkom údržieb",
@@ -151,6 +153,6 @@
"Verification failed": "Overenie zlyhalo",
"Verify": "Overiť",
"Verifying": "Overovanie",
"We sent a 6-digit code to": "Poslali sme 6-miestny kód na"
"We sent a 6-digit code to": "Odoslali sme 6-miestny kód na"
}
}
+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-значний код на"
}
}
+44 -42
View File
@@ -1,32 +1,32 @@
{
"name": "Tiếng Việt",
"mappings": {
"%latency %metric latency": "%latency %metric độ trễ",
"Affected Monitors (%count)": "Màn hình bị ảnh hưởng (%count)",
"All Systems Operational": "Tất cả hệ thống đang hoạt động",
"%latency %metric latency": "Độ trễ %metric: %latency",
"Affected Monitors (%count)": "Mục giám sát bị ảnh hưởng (%count)",
"All Systems Operational": "Hệ thống hoạt động bình thường",
"Average Latency": "Độ trễ trung bình",
"Avg Latency": "Độ trễ trung bình",
"Back": "Mặt sau",
"Avg Latency": "Độ trễ TB",
"Back": "Quay lại",
"Badges": "Huy hiệu",
"CANCELLED": "ĐÃ HỦY",
"COMPLETED": "HOÀN THÀNH",
"Continue": "Tiếp tục",
"Copied": "Đã sao chép",
"Current": "Hiện tại",
"Dark": "Tối tăm",
"Dark": "Tối",
"Day": "Ngày",
"Day Uptime": "Thời gian hoạt động trong ngày",
"Days": "Ngày",
"Degraded": "Suy giảm",
"DEGRADED": "SUY GIẢM",
"Degraded Performance": "Hiệu suất suy giảm",
"Didn't receive the code? Resend": "Không nhận được mã? ",
"Degraded Performance": "Hiệu năng suy giảm",
"Didn't receive the code? Resend": "Không nhận được mã? Gửi lại",
"Down": "Ngừng hoạt động",
"DOWN": "NGỪNG HOẠT ĐỘNG",
"Duration": "Khoảng thời gian",
"Duration": "Thời lượng",
"Email address": "Địa chỉ email",
"Embed Monitor": "Màn hình nhúng",
"Embed this monitor in your website or app": "Nhúng màn hình này vào trang web hoặc ứng dụng của bạn",
"Embed Monitor": "Nhúng mục giám sát",
"Embed this monitor in your website or app": "Nhúng mục giám sát này vào trang web hoặc ứng dụng của bạn",
"End Time": "Thời gian kết thúc",
"Enter the verification code sent to your email.": "Nhập mã xác minh được gửi tới email của bạn.",
"Events": "Sự kiện",
@@ -35,18 +35,18 @@
"Failed to load status data for this day": "Không thể tải dữ liệu trạng thái cho ngày này",
"Failed to send verification code": "Không gửi được mã xác minh",
"Failed to update preference": "Không thể cập nhật tùy chọn",
"Get badges for this monitor": "Nhận huy hiệu cho màn hình này",
"Get notified about incidents and scheduled maintenance.": "Nhận thông báo về sự cố và bảo trì theo lịch trình.",
"Get notified about incidents updates": "Nhận thông báo về cập nhật sự cố",
"Get notified about scheduled maintenance": "Nhận thông báo về bảo trì theo lịch trình",
"IDENTIFIED": "IDENTIFIED",
"Get badges for this monitor": "Lấy huy hiệu cho mục giám sát này",
"Get notified about incidents and scheduled maintenance.": "Nhận thông báo về sự cố và bảo trì đã lên lịch.",
"Get notified about incidents updates": "Nhận thông báo về các cập nhật sự cố",
"Get notified about scheduled maintenance": "Nhận thông báo khi có lịch bảo trì",
"IDENTIFIED": "ĐÃ XÁC ĐỊNH",
"iFrame": "iFrame",
"Impact": "Sự va chạm",
"Impact": "Mức ảnh hưởng",
"incident": "Sự cố",
"Incident Updates": "Cập nhật sự cố",
"Incidents": "Sự cố",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Included Monitors (%count)": "Mục giám sát được bao gồm (%count)",
"INVESTIGATING": "ĐANG ĐIỀU TRA",
"Last Updated": "Cập nhật lần cuối",
"Latency": "Độ trễ",
"Latency Embed": "Nhúng độ trễ",
@@ -54,14 +54,14 @@
"Latency Trend": "Xu hướng độ trễ",
"Latest Latency": "Độ trễ mới nhất",
"Latest Status": "Trạng thái mới nhất",
"Light": "Ánh sáng",
"Light": "Sáng",
"Live Status": "Trạng thái trực tiếp",
"Loading your preferences...": "Đang tải tùy chọn của bạn...",
"maintenance": "Bảo trì",
"MAINTENANCE": "BẢO TRÌ",
"Maintenance Updates": "Cập nhật bảo trì",
"Maintenances": "Bảo trì",
"Major System Outage": "Sự cố hệ thống nghiêm trọng",
"Major System Outage": "Gián đoạn hệ thống nghiêm trọng",
"Manage Site": "Quản lý trang",
"Manage your notification preferences.": "Quản lý tùy chọn thông báo của bạn.",
"Max Latency": "Độ trễ tối đa",
@@ -69,37 +69,39 @@
"Min Latency": "Độ trễ tối thiểu",
"Minimum Latency": "Độ trễ tối thiểu",
"Minute-by-minute status data for this day": "Dữ liệu trạng thái từng phút cho ngày này",
"MONITORING": "MONITORING",
"Network error. Please try again.": "Lỗi mạng. ",
"MONITORING": "ĐANG THEO DÕI",
"Network error. Please try again.": "Lỗi mạng. Vui lòng thử lại.",
"No Events in %currentMonth": "Không có sự kiện nào trong %currentMonth",
"No events to show": "Không có sự kiện nào để hiển thị",
"No incidents for this day": "Không có sự cố nào trong ngày này",
"No latency data available for this day": "Không có dữ liệu về độ trễ cho ngày này",
"No maintenances for this day": "Không bảo trì trong ngày này",
"No monitors affected": "Không có màn hình nào bị ảnh hưởng",
"No monitors available.": "Không có trình giám sát nào khả dụng.",
"No ongoing maintenances": "Không có bảo trì liên tục",
"No past maintenances": "Không có bảo trì trước đây",
"No maintenances for this day": "Không có đợt bảo trì nào trong ngày này",
"No monitors affected": "Không có mục giám sát nào bị ảnh hưởng",
"No monitors available.": "Không có mục giám sát nào.",
"No ongoing maintenances": "Không có đợt bảo trì nào đang diễn ra",
"No past maintenances": "Không có đợt bảo trì nào đã qua",
"No Status Available": "Không có trạng thái khả dụng",
"No upcoming maintenances": "Không có bảo trì sắp tới",
"No upcoming maintenances": "Không có đợt bảo trì nào sắp tới",
"No Updates": "Không có cập nhật",
"No updates yet": "Chưa có cập nhật nào",
"Notifications": "Thông báo",
"One-time": "Một lần",
"Ongoing": "Đang thực hiện",
"Operational": "Hoạt động",
"Partial Degraded Performance": "Hiệu suất suy giảm một phần",
"Partial System Outage": "Sự cố hệ thống một phần",
"Past": "Quá khứ",
"Per-Minute Status": "Trạng thái mỗi phút",
"Pinging": "Ping",
"Open events page": "Mở trang sự kiện",
"Operational": "Hoạt động bình thường",
"Partial Degraded Performance": "Hiệu năng suy giảm một phần",
"Partial System Outage": "Gián đoạn hệ thống một phần",
"Past": "Đã qua",
"Per-Minute Status": "Trạng thái từng phút",
"Pinging": "Đang ping",
"Please enter a valid email address": "Vui lòng nhập địa chỉ email hợp lệ",
"Please enter the 6-digit verification code": "Vui lòng nhập mã xác minh gồm 6 chữ số",
"Read less": "Đọc ít hơn",
"Read less": "Thu gọn",
"Read more": "Đọc thêm",
"READY": "SẴN SÀNG",
"Recurring": "Định kỳ",
"RESOLVED": "RESOLVED",
"RESOLVED": "ĐÃ KHẮC PHỤC",
"RSS feed": "Nguồn RSS",
"SCHEDULED": "ĐÃ LÊN LỊCH",
"Scheduled Events (%count)": "Sự kiện đã lên lịch (%count)",
"Script": "Kịch bản",
@@ -111,16 +113,16 @@
"Start Time": "Thời gian bắt đầu",
"Status": "Trạng thái",
"Status Badge": "Huy hiệu trạng thái",
"Status Embed": "Trạng thái nhúng",
"Status Embed": "Nhúng trạng thái",
"Status history and latency trend": "Lịch sử trạng thái và xu hướng độ trễ",
"Subscribe": ặt mua",
"Subscribe to Updates": "Đăng ký cập nhật",
"There are no incidents or maintenances scheduled for this month.": "Không có sự cố hoặc bảo trì nào được lên kế hoạch trong tháng này.",
"Subscribe": ăng ký",
"Subscribe to Updates": "Đăng ký nhận cập nhật",
"There are no incidents or maintenances scheduled for this month.": "Không có sự cố hoặc đợt bảo trì nào được lên lịch trong tháng này.",
"There are no ongoing incidents or maintenance events.": "Không có sự cố hoặc sự kiện bảo trì nào đang diễn ra.",
"Total Incidents": "Tổng số sự cố",
"Total Maintenances": "Tổng số lần bảo trì",
"Under Maintenance": "Đang bảo trì",
"Unknown impact": "Tác động không xác định",
"Unknown impact": "Không xác định được mức ảnh hưởng",
"UP": "HOẠT ĐỘNG",
"Upcoming": "Sắp tới",
"Update Incident": "Cập nhật sự cố",
+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[];
}
@@ -159,6 +152,7 @@ export interface PageDashboardData {
pageStatus: { statusSummary: string; statusClass: string };
ongoingIncidents: IncidentForMonitorListWithComments[];
ongoingMaintenances: MaintenanceEventsMonitorList[];
upcomingMaintenances: MaintenanceEventsMonitorList[];
monitorTags: string[];
monitorGroupMembersByTag: Record<string, string[]>;
pageDetails: PageRecordTyped;
@@ -366,6 +360,7 @@ export const GetPageDashboardData = async (
pageStatus: BuildPageStatus([], nowTs),
ongoingIncidents: [],
ongoingMaintenances: [],
upcomingMaintenances: [],
monitorTags,
monitorGroupMembersByTag: {},
pageDetails: pageDetailsTyped,
@@ -375,16 +370,24 @@ 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] = await Promise.all([
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[]),
showInlineEvents && eventSettings.maintenances.enabled && eventSettings.maintenances.upcoming.show
? GetUpcomingMaintenanceEventsForMonitorList(
monitorTags,
eventSettings.maintenances.upcoming.maxCount,
eventSettings.maintenances.upcoming.daysInFuture,
)
: Promise.resolve([] as MaintenanceEventsMonitorList[]),
]);
const pageStatus = BuildPageStatus(latestData, nowTs);
@@ -403,6 +406,7 @@ export const GetPageDashboardData = async (
pageStatus,
ongoingIncidents,
ongoingMaintenances,
upcomingMaintenances,
monitorTags,
monitorGroupMembersByTag,
pageDetails: pageDetailsTyped,
@@ -17,6 +17,11 @@ import type {
} from "../types/db.js";
import GC from "../../global-constants.js";
import { getUnixTime, differenceInSeconds } from "date-fns";
import { siteDataToVariables } from "../notification/notification_utils.js";
import { GetAllSiteData } from "./siteDataController.js";
import subscriberQueue from "../queues/subscriberQueue.js";
import mdToHTML from "../../marked.js";
import type { SubscriptionVariableMap } from "../notification/types.js";
interface IncidentsDashboardInput {
page: number;
@@ -322,11 +327,6 @@ export const CreateIncident = async (data: IncidentInput): Promise<{ incident_id
let newIncident = await db.createIncident(incident);
//we need to
// queueController.PushDataToQueue(newIncident.id, "createIncident", {
// message: `${incident.incident_type} Created`,
// ...incident,
// });
return {
incident_id: newIncident.id,
};
@@ -354,25 +354,6 @@ export const UpdateIncident = async (incident_id: number, data: IncidentUpdateIn
is_global: data.is_global !== undefined ? data.is_global : incidentExists.is_global,
};
//check if updateObject same as incidentExists
// if (
// JSON.stringify(updateObject) ===
// JSON.stringify({
// id: incidentExists.id,
// title: incidentExists.title,
// start_date_time: incidentExists.start_date_time,
// status: incidentExists.status,
// state: incidentExists.state,
// end_date_time: incidentExists.end_date_time,
// })
// ) {
// queueController.PushDataToQueue(incident_id, "updateIncident", {
// message: `${incidentExists.incident_type} has been updated to ${updateObject.state}`,
// incident_type: incidentExists.incident_type,
// title: incidentExists.title,
// });
// }
return await db.updateIncident(updateObject as IncidentRecord);
};
@@ -402,11 +383,6 @@ export const AddIncidentMonitor = async (
throw new Error(`Incident with id ${incident_id} does not exist`);
}
// queueController.PushDataToQueue(incident_id, "insertIncidentMonitor", {
// title: incidentExists.title,
// message: `Monitor ${monitor_tag} added to ${incidentExists.incident_type}. Impact is ${monitor_impact}`,
// incident_type: incidentExists.incident_type,
// });
return await db.insertIncidentMonitorWithMerge({
incident_id,
monitor_tag,
@@ -429,11 +405,6 @@ export const UpdateCommentByID = async (
if (!commentExists) {
throw new Error(`Comment with id ${comment_id} does not exist`);
}
// queueController.PushDataToQueue(incident_id, "updateIncidentComment", {
// title: incidentExists.title,
// message: `${comment}`,
// incident_type: incidentExists.incident_type,
// });
let c = await db.updateIncidentCommentByID(comment_id, comment, state, commented_at);
if (c) {
let incidentUpdate: IncidentUpdateInput = {
@@ -450,6 +421,38 @@ export const UpdateCommentByID = async (
}
return c;
};
// Subscriber notifications are driven solely by incident comments: the comment
// timeline is the incident's public communication channel, so posting a comment
// is the one event that emails "incidents" subscribers — regardless of whether
// the incident came from an alert, the dashboard, or the API. This is the single
// choke point; alertingQueue must not push its own incident notifications, or
// alert-driven incidents would notify twice.
const notifySubscribersOfComment = async (
incident: Pick<IncidentRecord, "id" | "title">,
comment: IncidentCommentRecord,
): Promise<void> => {
try {
const siteData = await GetAllSiteData();
const siteUrl = siteDataToVariables(siteData).site_url;
const variables: SubscriptionVariableMap = {
title: incident.title,
cta_url: `${siteUrl}incidents/${incident.id}`,
cta_text: "View Incident",
update_text: mdToHTML(comment.comment),
update_subject: `[#${incident.id}:${comment.state}] ${incident.title}`,
update_id: String(comment.id),
event_type: "incidents",
};
// Stable dedup id per comment so a retried/double push notifies once — without
// it subscriberQueue falls back to a Date.now()-suffixed id that never dedupes.
await subscriberQueue.push(variables, {
deduplication: { id: `subscriber-incidents-${comment.id}` },
});
} catch (err) {
console.error(`Error sending subscriber notification for incident ${incident.id}:`, err);
}
};
export const AddIncidentComment = async (
incident_id: number,
comment: string,
@@ -480,6 +483,7 @@ export const AddIncidentComment = async (
}
}
await UpdateIncident(incident_id, incidentUpdate);
await notifySubscribersOfComment(incidentExists, c);
}
return c;
+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,
@@ -16,6 +16,7 @@ import { maintenanceToVariables, siteDataToVariables } from "../notification/not
import { GetAllSiteData } from "./controller.js";
import subscriberQueue from "../queues/subscriberQueue.js";
import GC from "../../global-constants";
import seedSiteData from "../db/seedSiteData.js";
// ============ Input Interfaces ============
@@ -78,6 +79,7 @@ export interface MaintenanceWithEvents extends MaintenanceWithMonitors {
export function determineEventStatus(
eventStartTimestamp: number,
eventEndTimestamp: number,
reminderBufferSeconds: number = 3600,
): "SCHEDULED" | "READY" | "ONGOING" | "COMPLETED" | "CANCELLED" {
const nowTimestamp = Math.floor(Date.now() / 1000);
@@ -87,38 +89,90 @@ export function determineEventStatus(
if (nowTimestamp >= eventStartTimestamp) {
return "ONGOING";
}
// 60 minutes = 3600 seconds
if (eventStartTimestamp - nowTimestamp <= 3600) {
if (eventStartTimestamp - nowTimestamp <= reminderBufferSeconds) {
return "READY";
}
return "SCHEDULED";
}
// ============ Helper to create a maintenance event with notification ============
export const CreateMaintenanceEventWithNotification = async (
maintenance_id: number,
start_date_time: number,
end_date_time: number,
title: string,
description: string | null,
): Promise<MaintenanceEventRecord> => {
const siteData = await GetAllSiteData();
const notificationSettings =
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
const reminderBufferSeconds = notificationSettings.reminder_buffer_hours * 3600;
const event = await db.createMaintenanceEvent({
maintenance_id,
start_date_time,
end_date_time,
status: determineEventStatus(start_date_time, end_date_time, reminderBufferSeconds),
});
try {
if (notificationSettings.event_types.created) {
const siteVars = siteDataToVariables(siteData);
const siteUrl = siteVars.site_url;
const monitors = await db.getMonitorsByMaintenanceId(maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const eventDetailed: MaintenanceEventRecordDetailed = {
id: event.id,
maintenance_id,
start_date_time,
end_date_time,
status: event.status as MaintenanceEventRecordDetailed["status"],
created_at: new Date(),
updated_at: new Date(),
title,
description,
};
const update = maintenanceToVariables(
eventDetailed,
monitorNames,
"**has been created**",
"created",
"Maintenance Created",
siteUrl,
);
await subscriberQueue.push(update);
}
} catch (err) {
console.error(`Error sending created notification for maintenance event ${event.id}:`, err);
}
return event;
};
// ============ Helper to generate upcoming events from RRULE ============
/**
* Generate maintenance events for the next N days based on the RRULE
* Generate maintenance events based on the RRULE
* @param maintenance_id - The maintenance record ID
* @param start_date_time - Unix timestamp for the DTSTART
* @param rrule - The RRULE string (e.g., FREQ=WEEKLY;BYDAY=SU)
* @param duration_seconds - Duration of each maintenance window
* @param daysAhead - Number of days to look ahead (default 7)
* @param count - Maximum number of events to create (default 1)
*/
export const GenerateMaintenanceEvents = async (
maintenance_id: number,
start_date_time: number,
rrule: string,
duration_seconds: number,
daysAhead: number = 7,
count: number = 1,
): Promise<MaintenanceEventRecord[]> => {
const createdEvents: MaintenanceEventRecord[] = [];
// Convert start timestamp to Date (UTC)
const dtstart = new Date(start_date_time * 1000);
// Define the window to generate events
const now = new Date();
const windowEnd = addDays(now, daysAhead);
try {
// Build the full RRULE string with DTSTART
@@ -127,22 +181,30 @@ export const GenerateMaintenanceEvents = async (
// Parse the RRULE
const rule = rrulestr(fullRrule);
// Get occurrences between now and window end
// For one-time (COUNT=1), we use dtstart as the reference
let occurrences: Date[];
// Get occurrences based on count
let occurrences: Date[] = [];
if (rrule.includes("COUNT=1")) {
// One-time maintenance: only create event if start_date_time is in the future or within window
if (dtstart >= now || (dtstart <= windowEnd && dtstart >= addDays(now, -1))) {
// One-time maintenance: only create event if start_date_time is recent or in the future
if (dtstart >= now || dtstart >= addDays(now, -1)) {
occurrences = [dtstart];
} else {
occurrences = [];
}
} else {
// Recurring: get all occurrences in the window
occurrences = rule.between(now, windowEnd, true);
// Recurring: get the next `count` occurrences from now
let searchFrom = now;
for (let i = 0; i < count; i++) {
const next = rule.after(searchFrom, i === 0);
if (!next) break;
occurrences.push(next);
searchFrom = new Date(next.getTime() + 1000);
}
}
// Fetch maintenance info for notifications
const maintenance = await db.getMaintenanceById(maintenance_id);
const maintenanceTitle = maintenance?.title || "";
const maintenanceDescription = maintenance?.description || null;
// Create events for each occurrence
for (const occurrence of occurrences) {
const eventStart = Math.floor(occurrence.getTime() / 1000);
@@ -153,12 +215,13 @@ export const GenerateMaintenanceEvents = async (
const alreadyExists = existing.some((e) => e.start_date_time === eventStart);
if (!alreadyExists) {
const event = await db.createMaintenanceEvent({
const event = await CreateMaintenanceEventWithNotification(
maintenance_id,
start_date_time: eventStart,
end_date_time: eventEnd,
status: determineEventStatus(eventStart, eventEnd),
});
eventStart,
eventEnd,
maintenanceTitle,
maintenanceDescription,
);
createdEvents.push(event);
}
}
@@ -202,8 +265,8 @@ export const CreateMaintenance = async (data: CreateMaintenanceInput): Promise<{
await db.addMonitorsToMaintenanceWithStatus(maintenance.id, data.monitors);
}
// Generate initial events for the next 7 days
await GenerateMaintenanceEvents(maintenance.id, data.start_date_time, data.rrule, data.duration_seconds, 7);
// Generate initial events
await GenerateMaintenanceEvents(maintenance.id, data.start_date_time, data.rrule, data.duration_seconds, 1);
return {
maintenance_id: maintenance.id,
@@ -336,7 +399,7 @@ export const UpdateMaintenance = async (id: number, data: UpdateMaintenanceInput
}
}
// Regenerate the event
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 7);
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 1);
} else {
// For recurring maintenances: delete future SCHEDULED events and regenerate
for (const event of events) {
@@ -345,8 +408,8 @@ export const UpdateMaintenance = async (id: number, data: UpdateMaintenanceInput
await db.deleteMaintenanceEvent(event.id);
}
}
// Regenerate events for the next 7 days
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 7);
// Regenerate events
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 1);
}
}
}
@@ -377,11 +440,16 @@ export const CreateMaintenanceEvent = async (data: CreateMaintenanceEventInput):
throw new Error("End date/time must be after start date/time");
}
const siteData = await GetAllSiteData();
const notificationSettings =
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
const reminderBufferSeconds = notificationSettings.reminder_buffer_hours * 3600;
const event = await db.createMaintenanceEvent({
maintenance_id: data.maintenance_id,
start_date_time: data.start_date_time,
end_date_time: data.end_date_time,
status: determineEventStatus(data.start_date_time, data.end_date_time),
status: determineEventStatus(data.start_date_time, data.end_date_time, reminderBufferSeconds),
});
return event;
@@ -418,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> => {
@@ -534,20 +659,23 @@ export const formatDurationSeconds = (seconds: number): string => {
/**
* Update maintenance event statuses based on current time:
* 1. SCHEDULED events starting within 60 minutes → READY
* 1. SCHEDULED events starting within the reminder buffer → READY
* 2. READY events where current time is within start/end → ONGOING
* 3. ONGOING events where end_date_time has passed → COMPLETED
*/
export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
const currentTimestamp = GetMinuteStartNowTimestampUTC();
const sixtyMinutesInSeconds = 60 * 60;
const siteData = await GetAllSiteData();
const siteVars = siteDataToVariables(siteData);
const siteUrl = siteVars.site_url;
//get global maintenance notification settings
const notificationSettings =
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
const reminderBufferSeconds = notificationSettings.reminder_buffer_hours * 3600;
try {
// 1. Mark SCHEDULED events starting within 60 minutes as READY
const scheduledEvents = await db.getScheduledEventsStartingSoon(currentTimestamp, sixtyMinutesInSeconds);
// 1. Mark SCHEDULED events starting within the reminder buffer as READY
const scheduledEvents = await db.getScheduledEventsStartingSoon(currentTimestamp, reminderBufferSeconds);
for (const event of scheduledEvents) {
await db.updateMaintenanceEventStatus(event.id, GC.READY);
console.log(`Maintenance event ${event.id} marked as READY (starts at ${event.start_date_time})`);
@@ -557,15 +685,17 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
new Date(event.start_date_time * 1000),
new Date(currentTimestamp * 1000),
);
const update = maintenanceToVariables(
event,
monitorNames,
`**is starting in ${timeUntilStart}**`,
"starting_soon",
"Maintenance Starting Soon",
siteUrl,
);
await subscriberQueue.push(update);
if (notificationSettings.event_types.reminder) {
const update = maintenanceToVariables(
event,
monitorNames,
`**is starting in ${timeUntilStart}**`,
"starting_soon",
"Maintenance Starting Soon",
siteUrl,
);
await subscriberQueue.push(update);
}
}
// 2. Catch-up: SCHEDULED events that missed the READY window and already started → ONGOING
@@ -575,15 +705,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
console.log(`Maintenance event ${event.id} marked as ONGOING (catch-up from SCHEDULED)`);
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const update = maintenanceToVariables(
event,
monitorNames,
"**is now in progress**",
"ongoing",
"Maintenance In Progress",
siteUrl,
);
await subscriberQueue.push(update);
if (notificationSettings.event_types.started) {
const update = maintenanceToVariables(
event,
monitorNames,
"**is now in progress**",
"ongoing",
"Maintenance In Progress",
siteUrl,
);
await subscriberQueue.push(update);
}
}
// 3. Mark READY events that are now in progress as ONGOING
@@ -593,15 +726,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
console.log(`Maintenance event ${event.id} marked as ONGOING`);
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const update = maintenanceToVariables(
event,
monitorNames,
"**is now in progress**",
"ongoing",
"Maintenance In Progress",
siteUrl,
);
await subscriberQueue.push(update);
if (notificationSettings.event_types.started) {
const update = maintenanceToVariables(
event,
monitorNames,
"**is now in progress**",
"ongoing",
"Maintenance In Progress",
siteUrl,
);
await subscriberQueue.push(update);
}
}
// 4. Mark ONGOING events that have ended as COMPLETED
@@ -611,15 +747,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
console.log(`Maintenance event ${event.id} marked as COMPLETED`);
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const update = maintenanceToVariables(
event,
monitorNames,
"**has been completed**",
"completed",
"Maintenance Completed",
siteUrl,
);
await subscriberQueue.push(update);
if (notificationSettings.event_types.ended) {
const update = maintenanceToVariables(
event,
monitorNames,
"**has been completed**",
"completed",
"Maintenance Completed",
siteUrl,
);
await subscriberQueue.push(update);
}
}
} catch (error) {
console.error("Error updating maintenance event statuses:", error);
@@ -127,19 +127,20 @@ export async function CreateMonitorAlertConfig(
// Validate input
validateMonitorAlertConfigInput(data);
if (!data.monitor_tag) {
throw new Error("monitor_tag is required");
if (!data.monitor_tags || data.monitor_tags.length === 0) {
throw new Error("At least one monitor is required");
}
// Check if monitor exists
const monitor = await db.getMonitorByTag(data.monitor_tag);
if (!monitor) {
throw new Error(`Monitor with tag '${data.monitor_tag}' not found`);
// Check if all monitors exist
for (const tag of data.monitor_tags) {
const monitor = await db.getMonitorByTag(tag);
if (!monitor) {
throw new Error(`Monitor with tag '${tag}' not found`);
}
}
// Prepare insert data
const insertData: MonitorAlertConfigInsert = {
monitor_tag: data.monitor_tag,
alert_for: data.alert_for,
alert_value: data.alert_value,
failure_threshold: data.failure_threshold,
@@ -153,6 +154,9 @@ export async function CreateMonitorAlertConfig(
// Insert alert config
const id = await db.insertMonitorAlertConfig(insertData);
// Add monitors to junction table
await db.addMonitorsToAlertConfig(id, data.monitor_tags);
// Add triggers if provided
if (data.trigger_ids && data.trigger_ids.length > 0) {
await db.addTriggersToMonitorAlertConfig(id, data.trigger_ids);
@@ -194,6 +198,19 @@ export async function UpdateMonitorAlertConfig(
validateAlertValue(alertFor, data.alert_value);
}
// Validate monitor_tags if provided
if (data.monitor_tags !== undefined) {
if (data.monitor_tags.length === 0) {
throw new Error("At least one monitor is required");
}
for (const tag of data.monitor_tags) {
const monitor = await db.getMonitorByTag(tag);
if (!monitor) {
throw new Error(`Monitor with tag '${tag}' not found`);
}
}
}
// Prepare update data
const updateData: MonitorAlertConfigUpdate = {};
if (data.alert_for !== undefined) updateData.alert_for = data.alert_for;
@@ -210,6 +227,11 @@ export async function UpdateMonitorAlertConfig(
await db.updateMonitorAlertConfig(data.id, updateData);
}
// Update monitors if provided
if (data.monitor_tags !== undefined) {
await db.replaceAlertConfigMonitors(data.id, data.monitor_tags);
}
// Update triggers if provided
if (data.trigger_ids !== undefined) {
await db.replaceMonitorAlertConfigTriggers(data.id, data.trigger_ids);
@@ -306,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;
}
@@ -420,6 +443,7 @@ function validateAlertStatus(value: string): asserts value is MonitorAlertStatus
*/
export async function CreateMonitorAlertV2(
configId: number,
monitorTag?: string | null,
incidentId?: number | null,
): Promise<MonitorAlertV2Record> {
// Check if config exists
@@ -438,6 +462,7 @@ export async function CreateMonitorAlertV2(
const insertData: MonitorAlertV2Insert = {
config_id: configId,
monitor_tag: monitorTag || null,
incident_id: incidentId || null,
alert_status: "TRIGGERED",
};
@@ -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);
};
@@ -20,6 +20,7 @@ import type {
SiteDateTimeFormat,
SiteSubscriptionsSettings,
SitemapXMLConfig,
GlobalMaintenanceNotificationSettings,
} from "../../types/site.js";
export interface SiteDataTransformed {
@@ -66,6 +67,7 @@ export interface SiteDataTransformed {
metaSiteTitle?: string;
metaSiteDescription?: string;
sitemap?: SitemapXMLConfig;
globalMaintenanceNotificationSettings?: GlobalMaintenanceNotificationSettings;
}
export function InsertKeyValue(key: string, value: string): Promise<number[]> {
@@ -106,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;
@@ -136,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();
+7 -2
View File
@@ -43,12 +43,12 @@ export const siteDataKeys: SiteDataKey[] = [
},
{
key: "favicon",
isValid: (value) => typeof value === "string" && value.trim().length > 0,
isValid: (value) => typeof value === "string",
data_type: "string",
},
{
key: "logo",
isValid: (value) => typeof value === "string" && value.trim().length > 0,
isValid: (value) => typeof value === "string",
data_type: "string",
},
{
@@ -291,4 +291,9 @@ export const siteDataKeys: SiteDataKey[] = [
isValid: IsValidJSONString,
data_type: "object",
},
{
key: "globalMaintenanceNotificationSettings",
isValid: IsValidJSONString,
data_type: "object",
},
];
+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;
+91 -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"];
@@ -292,6 +317,12 @@ class DbImpl {
isTriggerUsedInMonitorAlertConfig!: MonitorAlertConfigRepository["isTriggerUsedInMonitorAlertConfig"];
getMonitorAlertConfigsByTriggerId!: MonitorAlertConfigRepository["getMonitorAlertConfigsByTriggerId"];
// ============ Monitor Alert Config Monitors ============
addMonitorsToAlertConfig!: MonitorAlertConfigRepository["addMonitorsToAlertConfig"];
removeAllMonitorsFromAlertConfig!: MonitorAlertConfigRepository["removeAllMonitorsFromAlertConfig"];
replaceAlertConfigMonitors!: MonitorAlertConfigRepository["replaceAlertConfigMonitors"];
getAlertConfigMonitorTags!: MonitorAlertConfigRepository["getAlertConfigMonitorTags"];
// ============ Monitor Alerts V2 ============
insertMonitorAlertV2!: MonitorAlertConfigRepository["insertMonitorAlertV2"];
updateMonitorAlertV2!: MonitorAlertConfigRepository["updateMonitorAlertV2"];
@@ -347,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);
@@ -384,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);
@@ -401,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);
@@ -408,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 {
@@ -454,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);
@@ -463,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 {
@@ -692,6 +747,14 @@ class DbImpl {
this.monitorAlertConfig,
);
// Monitor Alert Config Monitors
this.addMonitorsToAlertConfig = this.monitorAlertConfig.addMonitorsToAlertConfig.bind(this.monitorAlertConfig);
this.removeAllMonitorsFromAlertConfig = this.monitorAlertConfig.removeAllMonitorsFromAlertConfig.bind(
this.monitorAlertConfig,
);
this.replaceAlertConfigMonitors = this.monitorAlertConfig.replaceAlertConfigMonitors.bind(this.monitorAlertConfig);
this.getAlertConfigMonitorTags = this.monitorAlertConfig.getAlertConfigMonitorTags.bind(this.monitorAlertConfig);
// Monitor Alerts V2
this.insertMonitorAlertV2 = this.monitorAlertConfig.insertMonitorAlertV2.bind(this.monitorAlertConfig);
this.updateMonitorAlertV2 = this.monitorAlertConfig.updateMonitorAlertV2.bind(this.monitorAlertConfig);
@@ -784,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: [],
@@ -7,6 +7,8 @@ import type {
MonitorAlertConfigTriggerRecord,
MonitorAlertConfigTriggerInsert,
MonitorAlertConfigWithTriggers,
MonitorAlertConfigMonitorRecord,
MonitorAlertConfigMonitorInsert,
TriggerRecord,
MonitorAlertV2Record,
MonitorAlertV2Insert,
@@ -29,7 +31,7 @@ export class MonitorAlertConfigRepository extends BaseRepository {
async insertMonitorAlertConfig(data: MonitorAlertConfigInsert): Promise<number> {
const dbType = GetDbType();
const insertData = {
monitor_tag: data.monitor_tag,
monitor_tag: data.monitor_tag || null,
alert_for: data.alert_for,
alert_value: data.alert_value,
failure_threshold: data.failure_threshold,
@@ -88,29 +90,35 @@ export class MonitorAlertConfigRepository extends BaseRepository {
* Get monitor alert configs with optional filtering
*/
async getMonitorAlertConfigs(filter: MonitorAlertConfigFilter): Promise<MonitorAlertConfigRecord[]> {
let query = this.knex("monitor_alerts_config").whereRaw("1=1");
let query = this.knex("monitor_alerts_config as mac").select("mac.*").whereRaw("1=1");
if (filter.id !== undefined) {
query = query.andWhere("id", filter.id);
query = query.andWhere("mac.id", filter.id);
}
if (filter.monitor_tag !== undefined) {
query = query.andWhere("monitor_tag", filter.monitor_tag);
query = query
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.andWhere("macm.monitor_tag", filter.monitor_tag);
}
if (filter.alert_for !== undefined) {
query = query.andWhere("alert_for", filter.alert_for);
query = query.andWhere("mac.alert_for", filter.alert_for);
}
if (filter.is_active !== undefined) {
query = query.andWhere("is_active", filter.is_active);
query = query.andWhere("mac.is_active", filter.is_active);
}
return await query.orderBy("id", "desc");
return await query.orderBy("mac.id", "desc");
}
/**
* Get all monitor alert configs for a specific monitor tag
*/
async getMonitorAlertConfigsByMonitorTag(monitorTag: string): Promise<MonitorAlertConfigRecord[]> {
return await this.knex("monitor_alerts_config").where({ monitor_tag: monitorTag }).orderBy("id", "desc");
return await this.knex("monitor_alerts_config as mac")
.select("mac.*")
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.where("macm.monitor_tag", monitorTag)
.orderBy("mac.id", "desc");
}
/**
@@ -124,15 +132,26 @@ export class MonitorAlertConfigRepository extends BaseRepository {
* Get all active monitor alert configs for a specific monitor
*/
async getActiveMonitorAlertConfigsByMonitorTag(monitorTag: string): Promise<MonitorAlertConfigRecord[]> {
return await this.knex("monitor_alerts_config")
.where({ monitor_tag: monitorTag, is_active: "YES" })
.orderBy("id", "desc");
return await this.knex("monitor_alerts_config as mac")
.select("mac.*")
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.where("macm.monitor_tag", monitorTag)
.andWhere("mac.is_active", "YES")
.orderBy("mac.id", "desc");
}
/**
* 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();
}
@@ -140,26 +159,55 @@ export class MonitorAlertConfigRepository extends BaseRepository {
* Delete all monitor alert configs for a specific monitor tag
*/
async deleteMonitorAlertConfigsByMonitorTag(monitorTag: string): Promise<number> {
return await this.knex("monitor_alerts_config").where({ monitor_tag: monitorTag }).del();
// Find all config IDs that have this monitor tag in the junction table
const configIds = await this.knex("monitor_alerts_config_monitors")
.select("monitor_alerts_id")
.where({ monitor_tag: monitorTag });
if (configIds.length === 0) return 0;
// 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
const ids = configIds.map((r: { monitor_alerts_id: number }) => r.monitor_alerts_id);
let deletedCount = 0;
for (const id of ids) {
const remainingMonitors = await this.knex("monitor_alerts_config_monitors")
.count("* as count")
.where({ monitor_alerts_id: id })
.first<CountResult>();
if (Number(remainingMonitors?.count) === 0) {
await this.deleteMonitorAlertConfig(id);
deletedCount++;
}
}
return deletedCount;
}
/**
* Count monitor alert configs with optional filtering
*/
async getMonitorAlertConfigsCount(filter: MonitorAlertConfigFilter): Promise<CountResult | undefined> {
let query = this.knex("monitor_alerts_config").count("* as count");
let query = this.knex("monitor_alerts_config as mac").count("* as count");
if (filter.id !== undefined) {
query = query.andWhere("id", filter.id);
query = query.andWhere("mac.id", filter.id);
}
if (filter.monitor_tag !== undefined) {
query = query.andWhere("monitor_tag", filter.monitor_tag);
query = query
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.andWhere("macm.monitor_tag", filter.monitor_tag);
}
if (filter.alert_for !== undefined) {
query = query.andWhere("alert_for", filter.alert_for);
query = query.andWhere("mac.alert_for", filter.alert_for);
}
if (filter.is_active !== undefined) {
query = query.andWhere("is_active", filter.is_active);
query = query.andWhere("mac.is_active", filter.is_active);
}
return await query.first<CountResult>();
@@ -174,29 +222,33 @@ export class MonitorAlertConfigRepository extends BaseRepository {
filter?: MonitorAlertConfigFilter,
): Promise<{ configs: MonitorAlertConfigRecord[]; total: number }> {
// Build count query
let countQuery = this.knex("monitor_alerts_config").count("* as count");
let countQuery = this.knex("monitor_alerts_config as mac").count("* as count");
if (filter?.monitor_tag) {
countQuery = countQuery.where("monitor_tag", filter.monitor_tag);
countQuery = countQuery
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.where("macm.monitor_tag", filter.monitor_tag);
}
if (filter?.is_active) {
countQuery = countQuery.andWhere("is_active", filter.is_active);
countQuery = countQuery.andWhere("mac.is_active", filter.is_active);
}
if (filter?.alert_for) {
countQuery = countQuery.andWhere("alert_for", filter.alert_for);
countQuery = countQuery.andWhere("mac.alert_for", filter.alert_for);
}
const totalResult = await countQuery.first<CountResult>();
const total = totalResult ? Number(totalResult.count) : 0;
// Build paginated query
let query = this.knex("monitor_alerts_config").orderBy("id", "desc");
let query = this.knex("monitor_alerts_config as mac").select("mac.*").orderBy("mac.id", "desc");
if (filter?.monitor_tag) {
query = query.where("monitor_tag", filter.monitor_tag);
query = query
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.where("macm.monitor_tag", filter.monitor_tag);
}
if (filter?.is_active) {
query = query.andWhere("is_active", filter.is_active);
query = query.andWhere("mac.is_active", filter.is_active);
}
if (filter?.alert_for) {
query = query.andWhere("alert_for", filter.alert_for);
query = query.andWhere("mac.alert_for", filter.alert_for);
}
const configs = await query.limit(limit).offset((page - 1) * limit);
@@ -278,6 +330,51 @@ export class MonitorAlertConfigRepository extends BaseRepository {
}
}
// ============ Monitor Alert Config Monitors CRUD ============
/**
* Add multiple monitors to an alert config
*/
async addMonitorsToAlertConfig(alertConfigId: number, monitorTags: string[]): Promise<void> {
if (monitorTags.length === 0) return;
const inserts = monitorTags.map((monitorTag) => ({
monitor_alerts_id: alertConfigId,
monitor_tag: monitorTag,
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
}));
await this.knex("monitor_alerts_config_monitors").insert(inserts);
}
/**
* Remove all monitors from an alert config
*/
async removeAllMonitorsFromAlertConfig(alertConfigId: number): Promise<number> {
return await this.knex("monitor_alerts_config_monitors").where({ monitor_alerts_id: alertConfigId }).del();
}
/**
* Replace all monitors for an alert config (remove old, add new)
*/
async replaceAlertConfigMonitors(alertConfigId: number, monitorTags: string[]): Promise<void> {
await this.removeAllMonitorsFromAlertConfig(alertConfigId);
if (monitorTags.length > 0) {
await this.addMonitorsToAlertConfig(alertConfigId, monitorTags);
}
}
/**
* Get monitor tags for an alert config
*/
async getAlertConfigMonitorTags(alertConfigId: number): Promise<string[]> {
const records = await this.knex("monitor_alerts_config_monitors")
.select("monitor_tag")
.where({ monitor_alerts_id: alertConfigId });
return records.map((r: { monitor_tag: string }) => r.monitor_tag);
}
// ============ Composite / Join Operations ============
/**
@@ -292,9 +389,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
.select("t.*")
.where("mact.monitor_alerts_id", id);
const monitorTags = await this.getAlertConfigMonitorTags(id);
return {
...config,
triggers: triggerRecords as TriggerRecord[],
monitor_tags: monitorTags,
};
}
@@ -311,9 +411,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
.select("t.*")
.where("mact.monitor_alerts_id", config.id);
const monitorTags = await this.getAlertConfigMonitorTags(config.id);
result.push({
...config,
triggers: triggerRecords as TriggerRecord[],
monitor_tags: monitorTags,
});
}
@@ -333,9 +436,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
.select("t.*")
.where("mact.monitor_alerts_id", config.id);
const monitorTags = await this.getAlertConfigMonitorTags(config.id);
result.push({
...config,
triggers: triggerRecords as TriggerRecord[],
monitor_tags: monitorTags,
});
}
@@ -373,6 +479,7 @@ export class MonitorAlertConfigRepository extends BaseRepository {
const dbType = GetDbType();
const insertData: Record<string, unknown> = {
config_id: data.config_id,
monitor_tag: data.monitor_tag || null,
incident_id: data.incident_id || null,
alert_status: data.alert_status,
created_at: this.knex.fn.now(),
@@ -432,6 +539,9 @@ export class MonitorAlertConfigRepository extends BaseRepository {
if (filter.config_id !== undefined) {
query = query.andWhere("config_id", filter.config_id);
}
if (filter.monitor_tag !== undefined) {
query = query.andWhere("monitor_tag", filter.monitor_tag);
}
if (filter.incident_id !== undefined) {
query = query.andWhere("incident_id", filter.incident_id);
}
+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);
}
}
+11
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 },
@@ -168,6 +170,15 @@ const seedSiteData = {
mode: "off",
urls: [],
},
globalMaintenanceNotificationSettings: {
event_types: {
created: false,
reminder: true,
started: true,
ended: true,
},
reminder_buffer_hours: 1,
},
};
export default seedSiteData;
+146 -14
View File
@@ -1,7 +1,8 @@
import dns2 from "dns2";
import dgram, { type Socket } from "dgram";
import tls from "tls";
import { Resolver as NodeResolver } from "node:dns/promises";
import { AllRecordTypes } from "../clientTools";
import { AllRecordTypes, IsValidHost } from "../clientTools";
interface DNSAnswer {
name: string;
@@ -25,13 +26,32 @@ interface DNSRecordResult {
data: unknown;
}
export interface DnsQueryOptions {
transport?: "UDP" | "TLS";
nameserverOverride?: string;
tlsPort?: number;
tlsServername?: string;
allowSelfSignedCert?: boolean;
timeoutMs?: number;
}
const DEFAULT_DOT_PORT = 853;
const DEFAULT_QUERY_TIMEOUT_MS = 3000;
class DNSResolver {
nameserver: string;
socket: Socket;
socket: Socket | null;
constructor() {
this.nameserver = "8.8.8.8";
this.socket = dgram.createSocket("udp4");
this.socket = null;
}
private getUdpSocket(): Socket {
if (!this.socket) {
this.socket = dgram.createSocket("udp4");
}
return this.socket;
}
createQuery(domain: string, type: string): InstanceType<typeof dns2.Packet> {
@@ -49,36 +69,127 @@ class DNSResolver {
return packet;
}
private resolveTlsServername(nameserver: string, tlsServername?: string): string | undefined {
const configuredServername = tlsServername?.trim();
if (configuredServername) {
return configuredServername;
}
if (IsValidHost(nameserver)) {
return nameserver;
}
return undefined;
}
async query(domain: string, recordType: string, nameserverOverride?: string): Promise<DNSResponse> {
const socket = this.getUdpSocket();
return new Promise((resolve, reject) => {
const query = this.createQuery(domain, recordType);
const buffer = query.toBuffer();
const targetNameserver = nameserverOverride || this.nameserver;
const timeoutMs = DEFAULT_QUERY_TIMEOUT_MS;
const onMessage = (message: Buffer) => {
clearTimeout(timeoutId);
// @ts-expect-error dns2 types are incomplete
const response = dns2.Packet.parse(message) as DNSResponse;
resolve(response);
socket.removeListener("message", onMessage);
try {
// @ts-expect-error dns2 types are incomplete
const response = dns2.Packet.parse(message) as DNSResponse;
resolve(response);
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)));
}
};
const timeoutId = setTimeout(() => {
this.socket.removeListener("message", onMessage);
socket.removeListener("message", onMessage);
socket.close();
if (this.socket === socket) {
this.socket = null;
}
reject(new Error(`DNS query timed out for ${domain} (${recordType}) via ${targetNameserver}`));
}, 3000);
}, timeoutMs);
this.socket.once("message", onMessage);
socket.once("message", onMessage);
this.socket.send(buffer, 0, buffer.length, 53, targetNameserver, (err: Error | null) => {
socket.send(buffer, 0, buffer.length, 53, targetNameserver, (err: Error | null) => {
if (err) {
clearTimeout(timeoutId);
this.socket.removeListener("message", onMessage);
socket.removeListener("message", onMessage);
reject(err);
}
});
});
}
async queryOverTls(
domain: string,
recordType: string,
nameserver: string,
options: Pick<DnsQueryOptions, "tlsPort" | "tlsServername" | "allowSelfSignedCert" | "timeoutMs"> = {},
): Promise<DNSResponse> {
const query = this.createQuery(domain, recordType);
const buffer = query.toBuffer();
const lengthPrefix = Buffer.alloc(2);
lengthPrefix.writeUInt16BE(buffer.length, 0);
const message = Buffer.concat([lengthPrefix, buffer]);
const port = options.tlsPort ?? DEFAULT_DOT_PORT;
const timeoutMs = options.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS;
const servername = this.resolveTlsServername(nameserver, options.tlsServername);
return new Promise((resolve, reject) => {
let responseBuffer = Buffer.alloc(0);
let expectedLength: number | null = null;
const socket = tls.connect({
host: nameserver,
port,
servername,
rejectUnauthorized: !options.allowSelfSignedCert,
});
const timeoutId = setTimeout(() => {
socket.destroy();
reject(new Error(`DNS-over-TLS query timed out for ${domain} (${recordType}) via ${nameserver}:${port}`));
}, timeoutMs);
const cleanup = () => {
clearTimeout(timeoutId);
};
socket.on("error", (err) => {
cleanup();
socket.destroy();
reject(err);
});
socket.on("data", (chunk: Buffer) => {
responseBuffer = Buffer.concat([responseBuffer, chunk]);
if (expectedLength === null && responseBuffer.length >= 2) {
expectedLength = responseBuffer.readUInt16BE(0);
}
if (expectedLength !== null && responseBuffer.length >= expectedLength + 2) {
cleanup();
const responseData = responseBuffer.subarray(2, 2 + expectedLength);
try {
// @ts-expect-error dns2 types are incomplete
const response = dns2.Packet.parse(responseData) as DNSResponse;
socket.destroy();
resolve(response);
} catch (error) {
socket.destroy();
reject(error instanceof Error ? error : new Error(String(error)));
}
}
});
socket.on("secureConnect", () => {
socket.write(message);
});
});
}
async getAuthoritativeNameServers(domain: string, resolverNameserver?: string): Promise<string[]> {
const resolver = new NodeResolver();
resolver.setServers([resolverNameserver || this.nameserver]);
@@ -140,12 +251,30 @@ class DNSResolver {
async getRecord(
domain: string,
recordType: string,
nameserverOverride?: string,
options: DnsQueryOptions = {},
): Promise<Record<string, DNSRecordResult[]>> {
const results: Record<string, DNSRecordResult[]> = {};
const transport = options.transport ?? "UDP";
const nameserverOverride = options.nameserverOverride?.trim() || undefined;
try {
const response = await this.queryAuthoritativeRecord(domain, recordType, nameserverOverride);
let response: DNSResponse;
if (transport === "TLS") {
if (!nameserverOverride) {
throw new Error("Name server is required for DNS-over-TLS queries");
}
response = await this.queryOverTls(domain, recordType, nameserverOverride, {
tlsPort: options.tlsPort,
tlsServername: options.tlsServername,
allowSelfSignedCert: options.allowSelfSignedCert,
timeoutMs: options.timeoutMs,
});
} else {
response = await this.queryAuthoritativeRecord(domain, recordType, nameserverOverride);
}
results[recordType] = response.answers.map((answer: DNSAnswer) => ({
name: answer.name,
type: recordType,
@@ -157,7 +286,10 @@ class DNSResolver {
console.error("Error querying DNS records:", error);
throw error;
} finally {
this.socket.close();
if (this.socket) {
this.socket.close();
this.socket = null;
}
}
}
}
@@ -10,9 +10,11 @@ export function alertToVariables(
config: MonitorAlertConfigRecord,
alert: MonitorAlertV2Record,
siteVars: SiteDataForNotification,
monitorTag?: string,
): AlertVariableMap {
const createdAtDate = alert.created_at instanceof Date ? alert.created_at : new Date(alert.created_at);
const alert_name = `Alert ${config.monitor_tag} for ${config.alert_for} ${config.alert_value} ${alert.alert_status} at ${createdAtDate.toISOString()}`;
const effectiveMonitorTag = monitorTag || config.monitor_tag || "unknown";
const alert_name = `Alert ${effectiveMonitorTag} for ${config.alert_for} ${config.alert_value} ${alert.alert_status} at ${createdAtDate.toISOString()}`;
return {
alert_id: alert.id,
@@ -24,7 +26,7 @@ export function alertToVariables(
alert_message: config.alert_description || "",
alert_source: GC.ALERT,
alert_timestamp: createdAtDate.toISOString(),
alert_cta_url: siteVars.site_url + "monitors/" + config.monitor_tag,
alert_cta_url: siteVars.site_url + "monitors/" + effectiveMonitorTag,
alert_cta_text: "Open Alert Details",
alert_incident_id: alert.incident_id ? alert.incident_id : undefined,
alert_incident_url: alert.incident_id ? siteVars.site_url + "incidents/" + alert.incident_id : undefined,
+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;
}
+14 -47
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,11 +33,8 @@ 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";
import subscriberQueue from "./subscriberQueue.js";
import type { SiteDataForNotification } from "../notification/types.js";
let alertingQueue: Queue | null = null;
let worker: Worker | null = null;
@@ -65,7 +61,6 @@ async function createNewIncident(
config: MonitorAlertConfigRecord,
monitorName: string,
monitorTag: string,
siteUrl: string = "",
): Promise<{ incident_id: number }> {
let startDateTime = getUnixTime(new Date(alert.created_at));
let incidentInput: IncidentInput = {
@@ -74,6 +69,8 @@ async function createNewIncident(
incident_source: "ALERT",
};
// Subscriber notification comes from AddIncidentComment (via
// CreateNewIncidentWithCommentAndMonitor) — do not push here too.
let update = IncidentCreateAlertMarkdown(alert, config, monitorName, monitorTag, GC.TRIGGERED);
let incidentCreated = await CreateNewIncidentWithCommentAndMonitor(
incidentInput,
@@ -82,23 +79,7 @@ async function createNewIncident(
config.alert_value,
);
const updateVariables: SubscriptionVariableMap = {
title: incidentInput.title,
cta_url: siteUrl + "incidents/" + incidentCreated.incident_id,
cta_text: "View Incident",
update_text: mdToHTML(update),
update_subject: `[#${incidentCreated.incident_id}:${GC.TRIGGERED}] ${incidentInput.title}`,
update_id: String(incidentCreated.incident_id),
event_type: "incidents",
};
subscriberQueue.push(updateVariables);
return incidentCreated;
/*
subscriberQueue.push(updateVariables);*/
}
async function closeIncident(
@@ -106,7 +87,6 @@ async function closeIncident(
config: MonitorAlertConfigRecord,
monitorName: string,
monitorTag: string,
siteUrl: string = "",
): Promise<void> {
//check if incident is already resolved
if (!alert.incident_id) {
@@ -124,16 +104,7 @@ async function closeIncident(
let incident_id = alert.incident_id;
const comment = ClosureCommentAlertMarkdown(alert, config, monitorName, monitorTag, GC.RESOLVED);
const updatedAt = getUnixTime(new Date(alert.updated_at));
const updateMessage: SubscriptionVariableMap = {
title: incident.title,
cta_url: `${siteUrl}incidents/${incident_id}`,
cta_text: "View Incident",
update_text: mdToHTML(comment),
update_subject: `[#${incident.id}:${GC.RESOLVED}] ${incident.title}`,
update_id: String(incident_id),
event_type: "incidents",
};
subscriberQueue.push(updateMessage);
// Subscriber notification comes from AddIncidentComment — do not push here too.
await AddIncidentComment(incident_id, comment, GC.RESOLVED, updatedAt);
}
@@ -141,8 +112,9 @@ async function sendAlertNotifications(
activeAlert: MonitorAlertV2Record,
monitor_alerts_configured: MonitorAlertConfigRecord,
templateSiteVars: SiteDataForNotification,
monitorTag?: string,
): Promise<void> {
const templateAlertVars = alertToVariables(monitor_alerts_configured, activeAlert, templateSiteVars);
const templateAlertVars = alertToVariables(monitor_alerts_configured, activeAlert, templateSiteVars, monitorTag);
const triggers = await GetTriggersByMonitorAlertConfigId(monitor_alerts_configured.id);
for (let i = 0; i < triggers.length; i++) {
@@ -233,9 +205,10 @@ const addWorker = () => {
return;
}
// Get existing alerts
// Get existing alerts for this specific monitor + config combination
let alertsExisting = await GetMonitorAlertsV2({
config_id: monitor_alerts_configured.id,
monitor_tag: monitor_tag,
alert_status: GC.TRIGGERED,
});
let activeAlert = null;
@@ -244,16 +217,15 @@ const addWorker = () => {
}
if (isAffected) {
// Trigger alert if not already active
// Trigger alert if not already active for this monitor
if (!activeAlert) {
activeAlert = await CreateMonitorAlertV2(monitor_alerts_configured.id);
activeAlert = await CreateMonitorAlertV2(monitor_alerts_configured.id, monitor_tag);
if (monitor_alerts_configured.create_incident === GC.YES) {
let newIncidentNumber = await createNewIncident(
activeAlert,
monitor_alerts_configured,
monitor_name,
monitor_tag,
templateSiteVars.site_url,
);
//update alert with incident number
if (newIncidentNumber && newIncidentNumber.incident_id > 0) {
@@ -261,7 +233,7 @@ const addWorker = () => {
}
}
// Send triggered alert notifications
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars);
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars, monitor_tag);
}
} else {
// Resolve any existing alert
@@ -292,17 +264,11 @@ const addWorker = () => {
// If alert has an incident, add closure comment
if (activeAlert.incident_id) {
await closeIncident(
activeAlert,
monitor_alerts_configured,
monitor_name,
monitor_tag,
templateSiteVars.site_url,
);
await closeIncident(activeAlert, monitor_alerts_configured, monitor_name, monitor_tag);
}
// Send resolution notifications
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars);
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars, monitor_tag);
}
}
} catch (error) {
@@ -348,6 +314,7 @@ export const push = async (monitor_tag: string, ts: number, status: string, opti
monitor_tag: monitor.tag,
is_active: GC.YES,
});
if (monitorAlertsConfigurations.length === 0) {
return;
}
+1 -1
View File
@@ -46,7 +46,7 @@ const addWorker = () => {
fromEmail,
templateTextBody,
);
console.log(`📧 Email sent to ${toEmails}`);
// console.log(`📧 Email sent to ${toEmails}`);
} catch (error) {
console.error(`Failed to send email to ${toEmails}:`, error);
throw error; // Re-throw to trigger retry
+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,9 +40,11 @@ const addWorker = () => {
latency: latency,
type: type,
error_message: error_message,
raw_status: raw_status,
});
if (!dbRes) {
console.error("Failed to insert monitoring data for monitorTag:", monitorTag, "timestamp:", ts);
throw new Error("Failed to insert monitoring data");
}
@@ -52,7 +55,6 @@ const addWorker = () => {
latency: latency,
type: type,
});
alertingQueue.push(monitorTag, ts, status);
return 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,
@@ -4,7 +4,7 @@ import db from "../db/db.js";
import { rrulestr } from "rrule";
import { addDays } from "date-fns";
import type { MaintenanceRecord, MaintenanceEventRecord } from "../types/db.js";
import { determineEventStatus } from "../controllers/maintenanceController.js";
import { determineEventStatus, CreateMaintenanceEventWithNotification } from "../controllers/maintenanceController.js";
let maintenanceSchedulerQueue: Queue | null = null;
let worker: Worker | null = null;
@@ -58,12 +58,13 @@ const generateEventsForMaintenance = async (maintenance: MaintenanceRecord): Pro
// Check if event already exists for this start time
if (!existingStartTimes.has(eventStart)) {
await db.createMaintenanceEvent({
maintenance_id: maintenance.id,
start_date_time: eventStart,
end_date_time: eventEnd,
status: determineEventStatus(eventStart, eventEnd),
});
await CreateMaintenanceEventWithNotification(
maintenance.id,
eventStart,
eventEnd,
maintenance.title,
maintenance.description || null,
);
eventsCreated++;
console.log(
`Created maintenance event for "${maintenance.title}" at ${new Date(eventStart * 1000).toISOString()}`,
+19 -7
View File
@@ -64,13 +64,18 @@ class ApiCall {
console.log(e);
}
}
const followRedirects =
this.monitor.type_data.follow_redirects ?? true;
const maxRedirects =
this.monitor.type_data.max_redirects ?? 5;
const options: AxiosRequestConfig = {
method: method,
headers: axiosHeaders,
timeout: timeout,
transformResponse: (r: string) => r,
maxRedirects: 5,
maxRedirects: followRedirects ? maxRedirects : 0,
validateStatus: () => true,
maxContentLength: Infinity,
maxBodyLength: Infinity,
@@ -154,25 +159,32 @@ class ApiCall {
}
}
if (!!!evalResp) {
if (!evalResp || typeof evalResp !== "object") {
evalResp = {
status: GC.DOWN,
latency: latency,
type: GC.ERROR,
};
} else if (
!!!evalResp.status ||
([GC.UP, GC.DOWN, GC.DEGRADED, GC.MAINTENANCE] as string[]).indexOf(evalResp.status) === -1
) {
errorMessage += " | Eval must return an object with 'status' and 'latency' fields, got no response";
} else if (evalResp.status === undefined) {
evalResp = {
status: GC.DOWN,
latency: latency,
type: GC.ERROR,
};
errorMessage += ` | Eval must return an object with a 'status' field (one of: ${GC.UP}, ${GC.DOWN}, ${GC.DEGRADED}, ${GC.MAINTENANCE}), but 'status' was missing`;
} else if (([GC.UP, GC.DOWN, GC.DEGRADED, GC.MAINTENANCE] as string[]).indexOf(evalResp.status) === -1) {
evalResp = {
status: GC.DOWN,
latency: latency,
type: GC.ERROR,
};
errorMessage += ` | Eval returned invalid 'status' value "${evalResp.status}". Must be one of: ${GC.UP}, ${GC.DOWN}, ${GC.DEGRADED}, ${GC.MAINTENANCE}`;
} else {
evalResp.type = GC.REALTIME;
// Ensure latency is a valid number
// Ensure latency is a valid number; fall back to measured latency
if (typeof evalResp.latency !== "number" || isNaN(evalResp.latency)) {
errorMessage += ` | Eval 'latency' must be a number, got ${JSON.stringify(evalResp.latency)}. Using measured latency instead`;
evalResp.latency = latency;
}
}
@@ -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 };
}
+15 -7
View File
@@ -32,10 +32,18 @@ class DnsCall {
let matchType = this.monitor.type_data.matchType;
let values = this.monitor.type_data.values;
const configuredNameServer = this.monitor.type_data.nameServer?.trim() || undefined;
const transport = this.monitor.type_data.transport ?? "UDP";
const transportLabel = transport === "TLS" ? "DNS-over-TLS" : "DNS";
const queryStartTime = performance.now();
try {
let dnsRes = await dnsResolver.getRecord(host, recordType, configuredNameServer);
let dnsRes = await dnsResolver.getRecord(host, recordType, {
transport,
nameserverOverride: configuredNameServer,
tlsPort: this.monitor.type_data.tlsPort,
tlsServername: this.monitor.type_data.tlsServername,
allowSelfSignedCert: this.monitor.type_data.allowSelfSignedCert,
});
let latency = Math.round(performance.now() - queryStartTime);
if (dnsRes[recordType] === undefined) {
@@ -43,7 +51,7 @@ class DnsCall {
status: GC.DOWN,
latency: latency,
type: GC.REALTIME,
error_message: `No DNS ${recordType} response found for ${host}`,
error_message: `No ${transportLabel} ${recordType} response found for ${host}`,
};
}
let data = dnsRes[recordType];
@@ -55,7 +63,7 @@ class DnsCall {
status: GC.DOWN,
latency: latency,
type: GC.REALTIME,
error_message: `No DNS ${recordType} records returned for ${host}`,
error_message: `No ${transportLabel} ${recordType} records returned for ${host}`,
};
}
@@ -66,7 +74,7 @@ class DnsCall {
status: GC.DOWN,
latency: latency,
type: GC.REALTIME,
error_message: `DNS ${recordType} mismatch for ${host}. Missing: ${missingValues.join(", ")}`,
error_message: `${transportLabel} ${recordType} mismatch for ${host}. Missing: ${missingValues.join(", ")}`,
};
}
return {
@@ -88,7 +96,7 @@ class DnsCall {
status: GC.DOWN,
latency: latency,
type: GC.REALTIME,
error_message: `DNS ${recordType} mismatch for ${host}. Got: ${dnsData.join(", ")}`,
error_message: `${transportLabel} ${recordType} mismatch for ${host}. Got: ${dnsData.join(", ")}`,
};
}
} catch (error) {
@@ -98,14 +106,14 @@ class DnsCall {
status: GC.DOWN,
latency,
type: GC.REALTIME,
error_message: `DNS query failed for ${host} (${recordType}): ${message}`,
error_message: `${transportLabel} query failed for ${host} (${recordType}): ${message}`,
};
}
return {
status: GC.DOWN,
latency: Math.round(performance.now() - queryStartTime),
type: GC.REALTIME,
error_message: `DNS ${recordType} check did not return a definitive result for ${host}`,
error_message: `${transportLabel} ${recordType} check did not return a definitive result for ${host}`,
};
}
}
+11 -2
View File
@@ -61,7 +61,7 @@ class GrpcCall {
}
async execute(): Promise<MonitoringResult> {
const { host, port, service, tls, timeout } = this.monitor.type_data;
const { host, port, service, tls, insecure, timeout } = this.monitor.type_data;
const timeoutMs = timeout || 10000;
const target = `${host}:${port}`;
@@ -72,7 +72,16 @@ class GrpcCall {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const healthService = (proto.grpc as any).health.v1.Health;
const credentials = tls ? grpc.credentials.createSsl() : grpc.credentials.createInsecure();
const credentials =
tls && insecure
? grpc.credentials.createSsl(null, null, null, {
rejectUnauthorized: false,
checkServerIdentity: () => undefined,
})
: tls
? grpc.credentials.createSsl()
: grpc.credentials.createInsecure();
const client = new healthService(target, credentials);
const deadline = new Date(Date.now() + timeoutMs);
+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;
}
}
+14 -1
View File
@@ -55,7 +55,7 @@ class TcpCall {
error_message: `Error in tcpEval: ${message}`,
};
}
if (!!!evalResp) {
if (!evalResp) {
const message = "tcpEval did not return a valid response.";
console.log(`Error in tcpEval for ${tag}:`, message);
return {
@@ -65,6 +65,19 @@ class TcpCall {
error_message: `Error in tcpEval: ${message}`,
};
}
//evalResp to be an object with status and latency
if (!("status" in evalResp) || !("latency" in evalResp)) {
const message = "tcpEval did not return status or latency.";
console.log(`Error in tcpEval for ${tag}:`, message);
return {
status: GC.DOWN,
latency: 0,
type: GC.ERROR,
error_message: `Error in tcpEval: ${message}`,
};
}
//reduce to get the status
return {
status: evalResp?.status || GC.DOWN,
+58 -7
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;
@@ -628,7 +660,7 @@ export type YesNoType = "YES" | "NO";
export interface MonitorAlertConfigRecord {
id: number;
monitor_tag: string;
monitor_tag: string | null;
alert_for: AlertForType;
alert_value: string;
failure_threshold: number;
@@ -642,7 +674,7 @@ export interface MonitorAlertConfigRecord {
}
export interface MonitorAlertConfigInsert {
monitor_tag: string;
monitor_tag?: string | null;
alert_for: AlertForType;
alert_value: string;
failure_threshold: number;
@@ -684,13 +716,27 @@ export interface MonitorAlertConfigTriggerInsert {
trigger_id: number;
}
// ============ monitor_alerts_config_monitors table ============
export interface MonitorAlertConfigMonitorRecord {
monitor_alerts_id: number;
monitor_tag: string;
created_at: Date;
updated_at: Date;
}
export interface MonitorAlertConfigMonitorInsert {
monitor_alerts_id: number;
monitor_tag: string;
}
// ============ Composite types for monitor_alerts_config ============
export interface MonitorAlertConfigWithTriggers extends MonitorAlertConfigRecord {
triggers: TriggerRecord[];
monitor_tags: string[];
}
export interface MonitorAlertConfigCreateInput {
monitor_tag: string;
monitor_tags: string[];
alert_for: AlertForType;
alert_value: string;
failure_threshold: number;
@@ -704,6 +750,7 @@ export interface MonitorAlertConfigCreateInput {
export interface MonitorAlertConfigUpdateInput {
id: number;
monitor_tags?: string[];
alert_for?: AlertForType;
alert_value?: string;
failure_threshold?: number;
@@ -721,6 +768,7 @@ export type MonitorAlertStatusType = "TRIGGERED" | "RESOLVED";
export interface MonitorAlertV2Record {
id: number;
config_id: number;
monitor_tag: string | null;
incident_id: number | null;
alert_status: MonitorAlertStatusType;
created_at: Date;
@@ -729,11 +777,13 @@ export interface MonitorAlertV2Record {
export interface MonitorAlertV2Insert {
config_id: number;
monitor_tag?: string | null;
incident_id?: number | null;
alert_status: MonitorAlertStatusType;
}
export interface MonitorAlertV2Update {
monitor_tag?: string | null;
incident_id?: number | null;
alert_status?: MonitorAlertStatusType;
}
@@ -741,6 +791,7 @@ export interface MonitorAlertV2Update {
export interface MonitorAlertV2Filter {
id?: number;
config_id?: number;
monitor_tag?: string;
incident_id?: number;
alert_status?: MonitorAlertStatusType;
}
+11 -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;
@@ -23,6 +26,8 @@ export interface ApiMonitorTypeData {
timeout?: number;
eval?: string;
allowSelfSignedCert?: boolean;
follow_redirects?: boolean;
max_redirects?: number;
}
export interface DnsMonitorTypeData {
@@ -31,6 +36,10 @@ export interface DnsMonitorTypeData {
lookupRecord: string;
matchType: "ALL" | "ANY";
values: string[];
transport?: "UDP" | "TLS";
tlsPort?: number;
tlsServername?: string;
allowSelfSignedCert?: boolean;
}
export type { PingHost, PingMonitorTypeData };
@@ -83,6 +92,7 @@ export interface GrpcMonitorTypeData {
port: number;
service?: string;
tls?: boolean;
insecure?: boolean;
timeout?: number;
}
@@ -28,7 +28,12 @@
}
gtag("js", new Date());
gtag("config", "{{id}}");
var gtagConfig = {};
var transportUrl = "{{transport_url}}";
if (!!transportUrl) {
gtagConfig.transport_url = transportUrl;
}
gtag("config", "{{id}}", gtagConfig);
window.addEventListener("analyticsEvent", function (e) {
// Extract event name and data from the custom event
@@ -43,6 +48,7 @@
//on dom ready
document.addEventListener("DOMContentLoaded", function () {
loadJS("https://www.googletagmanager.com/gtag/js?id={{id}}", initJS, document.getElementsByTagName("head")[0]);
var scriptHost = "{{script_host}}" || "https://www.googletagmanager.com/gtag/js?id={{id}}";
loadJS(scriptHost, initJS, document.getElementsByTagName("head")[0]);
});
})();
+70 -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,7 +119,9 @@ export interface CreateMonitorRequest {
type_data?: MonitorTypeData | null;
include_degraded_in_downtime?: string;
is_hidden?: string;
confirmation_threshold?: number | null;
monitor_settings_json?: MonitorSettings | null;
external_url?: string | null;
}
export interface CreateMonitorResponse {
@@ -136,13 +140,19 @@ export interface UpdateMonitorRequest {
type_data?: MonitorTypeData | null;
include_degraded_in_downtime?: string;
is_hidden?: string;
confirmation_threshold?: number | null;
monitor_settings_json?: MonitorSettings | null;
external_url?: string | null;
}
export interface UpdateMonitorResponse {
monitor: MonitorRecordTyped;
}
export interface DeleteMonitorResponse {
message: string;
}
// Monitoring Data API types
export interface MonitoringDataPoint {
monitor_tag: string;
@@ -198,6 +208,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 +310,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 +363,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 +378,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 +414,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 +466,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 +508,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 +538,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 +552,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;
}

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