Compare commits

..

90 Commits

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

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

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

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

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

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

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

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

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

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

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

Fixes #737

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

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

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

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

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

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

Fixes #723

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:55:35 +05:30
Raj Nandan Sharma a8fbac1b69 Merge pull request #741 from rajnandan1/fix/694
chore: update documentation and enhance monitor group functionality, …
2026-06-06 13:05:15 +05:30
Raj Nandan Sharma cfc99e2f14 chore: update documentation and enhance monitor group functionality, fixes #694 2026-06-06 13:04:13 +05:30
Raj Nandan Sharma 31ba10f434 Merge pull request #740 from rajnandan1/fix/707
refactor: implement absolute URL resolution for social media meta tags
2026-06-06 12:26:31 +05:30
Raj Nandan Sharma 1750e2a341 refactor: implement absolute URL resolution for social media meta tags 2026-06-06 12:25:42 +05:30
Raj Nandan Sharma a12df92b94 Merge pull request #734 from Kukks/fix/sticky-header-backdrop
Fix overlapping header sections on the public page
2026-06-06 11:32:38 +05:30
Andrew Camilleri 5d86084138 fix(public): add frosted backdrop behind fixed nav and sticky theme-plus bar
The sticky theme-plus bar has no background, and the gap between it and the
fixed nav stays transparent, so page content shows through both sections as
it scrolls. Paint one blurred layer behind both (above content, below nav
z-10 and bar z-20).
2026-05-29 10:30:27 +02:00
Raj Nandan Sharma 7050f780a3 Merge pull request #702 from smeeckaert/feature/change-alert-body-description
Update alert body description
2026-04-09 10:21:50 +05:30
Raj Nandan Sharma cb93089dcc Merge pull request #703 from LoveGmod/patch-1
Update french locale syntax
2026-04-09 10:21:12 +05:30
LoveGmod fcd05e1d68 Update fr.json
french syntax changes
2026-04-05 23:54:33 +02:00
Martin SMEECKAERT db9d7807e0 update alert body description 2026-04-03 08:05:54 +02:00
Raj Nandan Sharma b7e0756c54 Merge pull request #699 from toporivskiy/main 2026-04-02 14:20:05 +05:30
Andrew Toporivskiy b920d2f9bc Ukrainian translate 2026-04-02 10:35:54 +03:00
github-actions[bot] 560c87219b chore(release): bump version to 4.0.23 2026-04-01 03:18:39 +00:00
Raj Nandan Sharma 94e24eec04 Include allPerms.ts in the Docker build process 2026-03-31 23:02:22 +05:30
Raj Nandan Sharma 15680a58aa Merge pull request #695 from rajnandan1/rbac-1-2
Implement role and permission management system with seeding scripts …
2026-03-31 22:08:34 +05:30
Raj Nandan Sharma 59f0eaef27 Refactor layout component to streamline dynamic styles and improve color handling 2026-03-31 22:08:08 +05:30
Raj Nandan Sharma 8362a73058 Implement indexing for users_roles table and enhance roles seeding logic to prevent FK constraint errors 2026-03-31 21:58:43 +05:30
Raj Nandan Sharma 52f8c50f50 Implement v4.0.23 changelog with new features, improvements, and breaking changes 2026-03-31 21:37:54 +05:30
Raj Nandan Sharma 60868d55ca Implement role validation in login action to ensure users have active roles assigned 2026-03-31 21:04:21 +05:30
Raj Nandan Sharma f7e657ee95 Implement role backfilling logic in down migration and remove unused vault permission from ROUTE_PERMISSION_MAP 2026-03-31 20:48:41 +05:30
Raj Nandan Sharma 63e5ec2886 Refactor role validation to ensure all role_ids are active and update role badge logic to prioritize active roles 2026-03-31 20:11:11 +05:30
Raj Nandan Sharma 2aef97c1ed Implement role migration and enforce role permissions in user management 2026-03-31 19:10:37 +05:30
Raj Nandan Sharma 51b2da97e0 Implement role and permission management system with seeding scripts for permissions and roles, including user role migration and UI components for role management. 2026-03-31 16:55:40 +05:30
Raj Nandan Sharma 50bddcd9a3 Merge pull request #691 from p-klassen/p-klassen-patch-1 2026-03-28 18:10:31 +05:30
p-klassen bd36533b05 Update de.json
Update wording and sentences (depending in their context) to be more natural and closer to  how they are actually commonly used in standard German.
2026-03-28 12:58:03 +01:00
github-actions[bot] 17500a0b43 chore(release): bump version to 4.0.22 2026-03-28 06:02:11 +00:00
Raj Nandan Sharma 0050cd810b Merge pull request #690 from rajnandan1/fix/sqlite-migration
Fix/sqlite migration
2026-03-28 11:23:34 +05:30
121 changed files with 6384 additions and 2618 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.
+47 -3
View File
@@ -7,13 +7,45 @@ const databaseURLParts = databaseURL.split("://");
const databaseType = databaseURLParts[0];
const databasePath = databaseURLParts[1];
const intFromEnv = (name: string, fallback: number): number => {
const raw = process.env[name];
if (raw === undefined) return fallback;
const parsed = parseInt(raw, 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
};
// TCP keepalive on pooled connections, on by default. Cloud networks (Railway,
// Docker Swarm overlays, k8s) silently drop idle TCP connections; without
// keepalive the pool keeps handing out dead sockets after an idle period or a
// database restart. See docs/adr/0003-fail-fast-self-healing-db-pool.md.
const keepAliveEnabled = process.env.DATABASE_KEEPALIVE !== "false";
// Pool defaults deviate from knex's on purpose:
// - min 0: knex's min 2 connections are never reaped, so they are exactly the
// ones that go stale and wedge the app until a manual restart
// - 15s acquire/create timeouts: fail fast instead of hanging requests for
// knex's default 60s during a database outage
// Tarn requires max >= 1 and min <= max; clamp so a bad env value can not
// produce a pool that fails every acquire
const poolMax = Math.max(1, intFromEnv("DATABASE_POOL_MAX", 10));
const poolMin = Math.min(intFromEnv("DATABASE_POOL_MIN", 0), poolMax);
const pool = {
min: poolMin,
max: poolMax,
idleTimeoutMillis: intFromEnv("DATABASE_IDLE_TIMEOUT_MS", 30000),
createTimeoutMillis: intFromEnv("DATABASE_CREATE_TIMEOUT_MS", 15000),
};
const acquireConnectionTimeout = intFromEnv("DATABASE_ACQUIRE_TIMEOUT_MS", 15000);
interface KnexConfig {
migrations: { directory: string };
seeds: { directory: string };
databaseType: string;
client?: string;
connection?: string | { filename: string };
connection?: string | { filename: string } | Record<string, unknown>;
useNullAsDefault?: boolean;
pool?: typeof pool;
acquireConnectionTimeout?: number;
}
const knexOb: KnexConfig = {
@@ -25,6 +57,7 @@ const knexOb: KnexConfig = {
},
databaseType,
};
console.log(`Configuring database with type ${databaseType}`);
if (databaseType === "sqlite") {
knexOb.client = "better-sqlite3";
knexOb.connection = {
@@ -33,10 +66,21 @@ if (databaseType === "sqlite") {
knexOb.useNullAsDefault = true;
} else if (databaseType === "postgresql") {
knexOb.client = "pg";
knexOb.connection = databaseURL;
knexOb.connection = {
connectionString: databaseURL,
keepAlive: keepAliveEnabled,
};
knexOb.pool = pool;
knexOb.acquireConnectionTimeout = acquireConnectionTimeout;
} else if (databaseType === "mysql") {
knexOb.client = "mysql2";
knexOb.connection = databaseURL;
knexOb.connection = {
uri: databaseURL,
enableKeepAlive: keepAliveEnabled,
keepAliveInitialDelay: 10000,
};
knexOb.pool = pool;
knexOb.acquireConnectionTimeout = acquireConnectionTimeout;
} else {
console.error("Invalid database type");
process.exit(1);
@@ -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");
});
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "kener",
"version": "4.0.21",
"version": "4.0.23",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kener",
"version": "4.0.21",
"version": "4.0.23",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kener",
"version": "4.0.21",
"version": "4.1.0",
"type": "module",
"private": false,
"license": "MIT",
+110 -70
View File
@@ -6,6 +6,7 @@ import Startup from "../src/lib/server/startup.ts";
import shutdownSchedulers from "../src/lib/server/schedulers/shutdown.ts";
import shutdownQueues from "../src/lib/server/queues/shutdown.ts";
import dbInstance from "../src/lib/server/db/db.ts";
import { redisConnection } from "../src/lib/server/redisConnector.ts";
import knex from "knex";
import knexOb from "../knexfile.js";
@@ -13,89 +14,128 @@ const PORT = process.env.PORT || 3000;
const base = process.env.KENER_BASE_PATH || "";
async function start() {
// Dynamic import so BODY_SIZE_LIMIT from .env is available
// before the handler reads it at module top-level
const { handler } = await import("../build/handler.js");
// Dynamic import so BODY_SIZE_LIMIT from .env is available
// before the handler reads it at module top-level
const { handler } = await import("../build/handler.js");
const app: any = express();
const db = knex(knexOb);
const app: any = express();
const db = knex(knexOb);
app.get(base + "/healthcheck", (req: any, res: any) => {
res.end("ok");
});
// Caps a health probe at 2s so a wedged dependency can not hang the
// endpoint. A probe is healthy unless it throws, times out, or resolves false.
const probe = async (check: () => Promise<unknown>): Promise<boolean> => {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const result = await Promise.race([
check(),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("health probe timeout")), 2000);
}),
]);
return result !== false;
} catch {
return false;
} finally {
clearTimeout(timer);
}
};
app.use(handler);
// Reports component health. Always 200 so healthcheck-driven restarters do
// not bounce the app while a dependency is down (a restart can not fix a
// dead database); pass ?strict=1 to get 503 when any component is down.
app.get(base + "/healthcheck", async (req: any, res: any) => {
const [dbOk, redisOk] = await Promise.all([
probe(() => dbInstance.ping()),
// Guard on status before PING: the shared ioredis client has
// maxRetriesPerRequest null, so commands sent while disconnected would
// queue forever and accumulate across healthcheck polls
probe(async () => {
const redis = redisConnection();
if (redis.status !== "ready") return false;
return await redis.ping();
}),
]);
const healthy = dbOk && redisOk;
const strict = req.query.strict === "1";
res.status(strict && !healthy ? 503 : 200).json({
status: healthy ? "ok" : "degraded",
db: dbOk,
redis: redisOk,
});
});
//migrations
async function runMigrations() {
try {
// Rename old .js migration entries to .ts in the knex_migrations table
// so Knex can find the renamed files on disk
const hasTable = await db.schema.hasTable("knex_migrations");
if (hasTable) {
const oldJsMigrations = await db("knex_migrations").where("name", "like", "%.js");
for (const row of oldJsMigrations) {
const newName = row.name.replace(/\.js$/, ".ts");
await db("knex_migrations").where("id", row.id).update({ name: newName });
console.log(`Renamed migration record: ${row.name} -> ${newName}`);
}
}
app.use(handler);
console.log("Running migrations...");
await db.migrate.latest(); // Runs migrations to the latest state
console.log("Migrations completed successfully!");
} catch (err) {
console.error("Error running migrations:", err);
}
}
//migrations
async function runMigrations() {
try {
// Rename old .js migration entries to .ts in the knex_migrations table
// so Knex can find the renamed files on disk
const hasTable = await db.schema.hasTable("knex_migrations");
if (hasTable) {
const oldJsMigrations = await db("knex_migrations").where("name", "like", "%.js");
for (const row of oldJsMigrations) {
const newName = row.name.replace(/\.js$/, ".ts");
await db("knex_migrations").where("id", row.id).update({ name: newName });
console.log(`Renamed migration record: ${row.name} -> ${newName}`);
}
}
//seed
async function runSeed() {
try {
console.log("Running seed...");
await db.seed.run(); // Runs seed to the latest state
console.log("Seed completed successfully!");
} catch (err) {
console.error("Error running seed:", err);
}
}
console.log("Running migrations...");
await db.migrate.latest(); // Runs migrations to the latest state
console.log("Migrations completed successfully!");
} catch (err) {
console.error("Error running migrations:", err);
}
}
app.listen(PORT, async () => {
await runMigrations();
await runSeed();
await db.destroy();
Startup();
console.log("Kener is running on port " + PORT + "!");
});
//seed
async function runSeed() {
try {
console.log("Running seed...");
await db.seed.run(); // Runs seed to the latest state
console.log("Seed completed successfully!");
} catch (err) {
console.error("Error running seed:", err);
}
}
// Graceful shutdown handler
async function gracefulShutdown(signal: string) {
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
app.listen(PORT, async () => {
await runMigrations();
await runSeed();
await db.destroy();
Startup();
console.log("Kener is running on port " + PORT + "!");
});
try {
console.log("Shutting down schedulers...");
await shutdownSchedulers();
console.log("Schedulers shut down successfully.");
// Graceful shutdown handler
async function gracefulShutdown(signal: string) {
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
console.log("Shutting down queues...");
await shutdownQueues();
console.log("Queues shut down successfully.");
try {
console.log("Shutting down schedulers...");
await shutdownSchedulers();
console.log("Schedulers shut down successfully.");
console.log("Closing database connection...");
await dbInstance.close();
console.log("Database connection closed successfully.");
console.log("Shutting down queues...");
await shutdownQueues();
console.log("Queues shut down successfully.");
console.log("Graceful shutdown completed.");
process.exit(0);
} catch (err) {
console.error("Error during graceful shutdown:", err);
process.exit(1);
}
}
console.log("Closing database connection...");
await dbInstance.close();
console.log("Database connection closed successfully.");
// Handle termination signals
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
console.log("Graceful shutdown completed.");
process.exit(0);
} catch (err) {
console.error("Error during graceful shutdown:", err);
process.exit(1);
}
}
// Handle termination signals
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
}
start();
+28
View File
@@ -0,0 +1,28 @@
import type { Knex } from "knex";
import { permissions } from "../src/lib/allPerms.ts";
export async function seed(knex: Knex): Promise<void> {
const permissionIds = new Set(permissions.map((p) => p.id));
// Get all existing permissions
const existing: Array<{ id: string }> = await knex("permissions").select("id");
const existingIds = new Set(existing.map((e) => e.id));
// Insert missing permissions
for (const perm of permissions) {
if (!existingIds.has(perm.id)) {
await knex("permissions").insert({
id: perm.id,
permission_name: perm.permission_name,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
}
// Delete permissions that are no longer in the seed list
const toDelete = existing.filter((e) => !permissionIds.has(e.id)).map((e) => e.id);
if (toDelete.length > 0) {
await knex("permissions").whereIn("id", toDelete).del();
}
}
+106
View File
@@ -0,0 +1,106 @@
import type { Knex } from "knex";
import { permissions } from "../src/lib/allPerms.ts";
/**
* Seeds the three readonly roles (admin, editor, member),
* assigns permissions to each role in roles_permissions,
* and migrates existing users.role → users_roles.
*
* Permission mapping derived from src/routes/(manage)/manage/api/+server.ts:
*
* admin → all permissions
* editor → all except api_keys.delete (AdminCan-only)
* member → all .read permissions only
*/
const readonlyRoles = [
{ id: "admin", role_name: "Administrator" },
{ id: "editor", role_name: "Editor" },
{ id: "member", role_name: "Member" },
];
const allPermissionIds = permissions.map((p) => p.id);
const readPermissionIds = allPermissionIds.filter((id) => id.endsWith(".read"));
const rolePermissions: Record<string, string[]> = {
admin: allPermissionIds,
editor: allPermissionIds.filter((id) => id !== "api_keys.delete"),
member: readPermissionIds,
};
export async function seed(knex: Knex): Promise<void> {
// 1. Ensure readonly roles exist
for (const role of readonlyRoles) {
const existing = await knex("roles").where("id", role.id).first();
if (!existing) {
await knex("roles").insert({
id: role.id,
role_name: role.role_name,
readonly: 1,
status: "ACTIVE",
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
}
// 2. Seed roles_permissions for readonly roles
// Only insert permissions that actually exist in the permissions table
// to avoid FK constraint errors if permissions seed hasn't run yet.
const existingPermRows: Array<{ id: string }> = await knex("permissions").select("id");
const existingPermIds = new Set(existingPermRows.map((p) => p.id));
for (const [roleId, permissionIds] of Object.entries(rolePermissions)) {
const validPermissionIds = permissionIds.filter((id) => existingPermIds.has(id));
const existingPerms: Array<{ permissions_id: string }> = await knex("roles_permissions")
.where("roles_id", roleId)
.select("permissions_id");
const existingSet = new Set(existingPerms.map((e) => e.permissions_id));
// Insert missing permissions
for (const permId of validPermissionIds) {
if (!existingSet.has(permId)) {
await knex("roles_permissions").insert({
roles_id: roleId,
permissions_id: permId,
status: "ACTIVE",
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
}
// Remove permissions no longer assigned to this role
const desiredSet = new Set(validPermissionIds);
const toRemove = existingPerms.filter((e) => !desiredSet.has(e.permissions_id)).map((e) => e.permissions_id);
if (toRemove.length > 0) {
await knex("roles_permissions").where("roles_id", roleId).whereIn("permissions_id", toRemove).del();
}
}
// 3. Migrate existing users: read users.role → insert into users_roles
const hasRoleColumn = await knex.schema.hasColumn("users", "role");
if (hasRoleColumn) {
const users: Array<{ id: number; role: string }> = await knex("users").select("id", "role");
for (const user of users) {
if (!user.role) continue;
// Only migrate if a matching role exists
const roleExists = await knex("roles").where("id", user.role).first();
if (!roleExists) continue;
// Skip if already assigned
const existing = await knex("users_roles").where({ roles_id: user.role, users_id: user.id }).first();
if (!existing) {
await knex("users_roles").insert({
roles_id: user.role,
users_id: user.id,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
}
}
}
+73
View File
@@ -0,0 +1,73 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="refresh" content="30" />
<title>%sveltekit.status% — Status page temporarily unavailable</title>
<style>
:root {
color-scheme: light dark;
--bg: #ffffff;
--fg: #09090b;
--muted: #71717a;
--border: #e4e4e7;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #09090b;
--fg: #fafafa;
--muted: #a1a1aa;
--border: #27272a;
}
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
color: var(--fg);
font-family:
ui-sans-serif,
system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
}
.card {
max-width: 28rem;
margin: 1rem;
padding: 2rem;
border: 1px solid var(--border);
border-radius: 1rem;
text-align: center;
}
h1 {
font-size: 1.25rem;
margin: 0 0 0.5rem;
}
p {
color: var(--muted);
font-size: 0.875rem;
line-height: 1.5;
margin: 0.25rem 0;
}
.code {
color: var(--muted);
font-size: 0.75rem;
margin-top: 1.25rem;
}
</style>
</head>
<body>
<div class="card">
<h1>This status page is temporarily unavailable</h1>
<p>We are having trouble serving this page right now. It usually resolves on its own.</p>
<p>This page will retry automatically in 30 seconds.</p>
<div class="code">%sveltekit.status%</div>
</div>
</body>
</html>
+17 -1
View File
@@ -4,6 +4,7 @@ import { VerifyAPIKey } from "$lib/server/controllers/apiController";
import db from "$lib/server/db/db";
import type { UnauthorizedResponse, NotFoundResponse } from "$lib/types/api";
import { GetMonitorsParsed } from "$lib/server/controllers/monitorsController";
import GC from "$lib/global-constants";
const API_PATH_PREFIX = "/api/";
@@ -119,6 +120,18 @@ const apiAuthHandle: Handle = async ({ event, resolve }) => {
return json(errorResponse, { status: 401 });
}
// API consumers must always get JSON; without this, an /api/ path with no
// matching route falls through to SvelteKit's HTML error page
if (event.route.id === null) {
const errorResponse: NotFoundResponse = {
error: {
code: "NOT_FOUND",
message: `No API route matches '${pathname}'`,
},
};
return json(errorResponse, { status: 404 });
}
// Validate monitor tag exists for /api/(vX/)?monitors/:monitor_tag/* routes
const monitorTag = extractMonitorTag(pathname);
if (monitorTag) {
@@ -173,7 +186,10 @@ const apiAuthHandle: Handle = async ({ event, resolve }) => {
// Validate page_path exists for /api/(vX/)?pages/:page_path/* routes
const pagePath = extractPagePath(pathname);
if (pagePath) {
const page = await db.getPageByPath(pagePath);
// The home page has an empty page_path, unreachable as a URL segment;
// the ~home token addresses it instead
const lookupPath = pagePath === GC.HOME_PAGE_TOKEN ? "" : pagePath;
const page = await db.getPageByPath(lookupPath);
if (!page) {
const errorResponse: NotFoundResponse = {
error: {
+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",
};
+36
View File
@@ -31,3 +31,39 @@ export default function urlResolve(resolve: ResolveFn, path: string, params?: Re
}
return resolve(path);
}
/**
* Resolves a path to an absolute URL by prefixing the site URL.
* Required for meta tags like og:image and twitter:image that need absolute URLs.
* @param resolve - The resolve function from $app/paths
* @param siteUrl - The site URL (e.g., "https://status.example.com")
* @param path - The route path or absolute URL
* @param params - Optional parameters for dynamic route segments
* @returns An absolute URL, or the resolved relative URL if siteUrl is empty
*
* @example
* ```ts
* absoluteResolve(resolve, "https://status.example.com", "/uploads/preview.png")
* // => "https://status.example.com/uploads/preview.png"
* ```
*/
export function absoluteResolve(
resolve: ResolveFn,
siteUrl: string,
path: string,
params?: Record<string, string>
): string {
// Normalize relative paths like "./assets/..." to "/assets/..." so the
// final URL doesn't contain "/./" segments (crawlers don't normalize these)
const normalizedPath = path.startsWith("./") ? path.slice(1) : path;
const resolved = urlResolve(resolve, normalizedPath, params);
// Already absolute, return as-is
if (resolved.startsWith("http://") || resolved.startsWith("https://")) {
return resolved;
}
if (!siteUrl) {
return resolved;
}
const trimmedSiteUrl = siteUrl.replace(/\/+$/, "");
return trimmedSiteUrl + (resolved.startsWith("/") ? resolved : "/" + resolved);
}
+42 -34
View File
@@ -1,5 +1,5 @@
import type { TimestampStatusCount } from "$lib/server/types/db";
import { PAGE_STATUS_MESSAGES } from "$lib/global-constants";
import GC, { PAGE_STATUS_MESSAGES, type StatusType } from "$lib/global-constants";
function ParseLatency(latencyMs: number): string {
if (!!!latencyMs) {
@@ -316,46 +316,53 @@ interface GameItem {
function GetGameFromId(list: GameItem[], id: string): GameItem | undefined {
return list.find((game: GameItem) => game.id === id);
}
type StatusCounts = Pick<TimestampStatusCount, "countOfUp" | "countOfDown" | "countOfDegraded" | "countOfMaintenance">;
// Canonical Overall Status collapse: the worst state wins, and maintenance
// never masks an active problem. See docs/adr/0007-problem-first-overall-status.md.
function CollapseStatusCounts(counts: StatusCounts): StatusType {
const total = counts.countOfUp + counts.countOfDown + counts.countOfDegraded + counts.countOfMaintenance;
if (total === 0) return GC.NO_DATA;
if (counts.countOfDown > 0) return GC.DOWN;
if (counts.countOfDegraded > 0) return GC.DEGRADED;
if (counts.countOfMaintenance > 0) return GC.MAINTENANCE;
return GC.UP;
}
function GetStatusSummary(item: TimestampStatusCount): string {
const total = item.countOfUp + item.countOfDown + item.countOfDegraded + item.countOfMaintenance;
if (total === 0) return PAGE_STATUS_MESSAGES.NO_DATA;
const maintenancePercent = (item.countOfMaintenance / total) * 100;
const downPercent = (item.countOfDown / total) * 100;
const degradedPercent = (item.countOfDegraded / total) * 100;
if (maintenancePercent > 0) {
return PAGE_STATUS_MESSAGES.UNDER_MAINTENANCE;
} else if (downPercent >= 75) {
return PAGE_STATUS_MESSAGES.MAJOR_OUTAGE;
} else if (downPercent >= 50) {
return PAGE_STATUS_MESSAGES.PARTIAL_OUTAGE;
} else if (item.countOfDown > 0) {
return PAGE_STATUS_MESSAGES.PARTIAL_OUTAGE;
} else if (degradedPercent >= 75) {
return PAGE_STATUS_MESSAGES.DEGRADED_PERFORMANCE;
} else if (degradedPercent >= 50) {
return PAGE_STATUS_MESSAGES.PARTIAL_DEGRADED;
} else if (item.countOfDegraded > 0) {
return PAGE_STATUS_MESSAGES.PARTIAL_DEGRADED;
} else if (item.countOfUp === total) {
return PAGE_STATUS_MESSAGES.ALL_OPERATIONAL;
switch (CollapseStatusCounts(item)) {
case GC.DOWN:
return (item.countOfDown / total) * 100 >= 75
? PAGE_STATUS_MESSAGES.MAJOR_OUTAGE
: PAGE_STATUS_MESSAGES.PARTIAL_OUTAGE;
case GC.DEGRADED:
return (item.countOfDegraded / total) * 100 >= 75
? PAGE_STATUS_MESSAGES.DEGRADED_PERFORMANCE
: PAGE_STATUS_MESSAGES.PARTIAL_DEGRADED;
case GC.MAINTENANCE:
return PAGE_STATUS_MESSAGES.UNDER_MAINTENANCE;
case GC.UP:
return PAGE_STATUS_MESSAGES.ALL_OPERATIONAL;
default:
return PAGE_STATUS_MESSAGES.NO_DATA;
}
return PAGE_STATUS_MESSAGES.NO_DATA;
}
function GetStatusColor(item: TimestampStatusCount): string {
const total = item.countOfUp + item.countOfDown + item.countOfDegraded + item.countOfMaintenance;
if (total === 0) return "text-muted-foreground";
const maintenancePercent = (item.countOfMaintenance / total) * 100;
const downPercent = (item.countOfDown / total) * 100;
if (maintenancePercent > 0) return "text-maintenance";
if (downPercent > 0) return "text-down";
if (item.countOfDegraded > 0) return "text-degraded";
return "text-up";
switch (CollapseStatusCounts(item)) {
case GC.DOWN:
return "text-down";
case GC.DEGRADED:
return "text-degraded";
case GC.MAINTENANCE:
return "text-maintenance";
case GC.UP:
return "text-up";
default:
return "text-muted-foreground";
}
}
function GetStatusBgColor(item: TimestampStatusCount): string {
@@ -378,6 +385,7 @@ export {
IsValidNameServer,
IsValidURL,
IsValidPort,
CollapseStatusCounts,
GetStatusSummary,
GetStatusColor,
GetStatusBgColor,
+7 -3
View File
@@ -12,6 +12,8 @@
import clientResolver from "$lib/client/resolver.js";
import { GetInitials } from "$lib/clientTools.js";
import type { MaintenanceEventsMonitorList } from "$lib/server/types/db";
import { SveltePurify } from "@humanspeak/svelte-purify";
import mdToHTML from "$lib/marked";
import { page } from "$app/state";
interface Props {
@@ -42,9 +44,11 @@
</div>
{#if maintenance.description}
<p class="text-muted-foreground mt-1 text-sm">
{maintenance.description}
</p>
<div
class="prose prose-sm dark:prose-invert text-muted-foreground mt-1 max-w-none min-w-0 overflow-x-auto text-sm wrap-break-word"
>
<SveltePurify html={mdToHTML(maintenance.description)} />
</div>
{/if}
{#if maintenance.monitors && maintenance.monitors.length > 0 && !hideMonitors}
+98
View File
@@ -0,0 +1,98 @@
<script lang="ts">
import * as Command from "$lib/components/ui/command/index.js";
import * as Popover from "$lib/components/ui/popover/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import type { MonitorRecord } from "$lib/server/types/db.js";
import CheckIcon from "@lucide/svelte/icons/check";
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
import ListPlusIcon from "@lucide/svelte/icons/list-plus";
import clientResolver from "$lib/client/resolver.js";
import { resolve } from "$app/paths";
let {
monitors = [],
selectedTags = [],
onToggle,
onAddMany,
placeholder = "Search monitors to add..."
}: {
monitors: MonitorRecord[];
selectedTags: string[];
onToggle: (tag: string) => void;
onAddMany?: (tags: string[]) => void;
placeholder?: string;
} = $props();
let open = $state(false);
let search = $state("");
// Own filtering (shouldFilter={false}) so "Add all matching" counts stay
// consistent with what the list shows. Case-insensitive over name + tag.
const filteredMonitors = $derived.by(() => {
const query = search.trim().toLowerCase();
if (!query) return monitors;
return monitors.filter((m) => m.name.toLowerCase().includes(query) || m.tag.toLowerCase().includes(query));
});
const unselectedMatches = $derived(filteredMonitors.filter((m) => !selectedTags.includes(m.tag)));
const showAddAll = $derived(!!search.trim() && unselectedMatches.length > 0 && !!onAddMany);
function addAllMatching() {
onAddMany?.(unselectedMatches.map((m) => m.tag));
}
</script>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
role="combobox"
aria-expanded={open}
class="w-full justify-between font-normal"
>
<span class="text-muted-foreground">{placeholder}</span>
<ChevronsUpDownIcon class="text-muted-foreground size-4 shrink-0" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[var(--bits-popover-trigger-width)] p-0" align="start">
<Command.Root shouldFilter={false}>
<Command.Input {placeholder} bind:value={search} />
<Command.List class="max-h-64">
<Command.Empty>No monitors found.</Command.Empty>
<Command.Group>
{#each filteredMonitors as monitor (monitor.tag)}
{@const selected = selectedTags.includes(monitor.tag)}
<Command.Item value={monitor.tag} onSelect={() => onToggle(monitor.tag)}>
<CheckIcon class="size-4 {selected ? 'opacity-100' : 'opacity-0'}" />
{#if monitor.image}
<img
src={clientResolver(resolve, monitor.image)}
alt={monitor.name}
class="size-5 rounded object-cover"
/>
{:else}
<div class="bg-muted flex size-5 items-center justify-center rounded text-[10px] font-medium">
{monitor.name.charAt(0).toUpperCase()}
</div>
{/if}
<span class="truncate">{monitor.name}</span>
<span class="text-muted-foreground ml-auto truncate text-xs">{monitor.tag}</span>
</Command.Item>
{/each}
</Command.Group>
{#if showAddAll}
<Command.Separator />
<Command.Group>
<Command.Item value="__add-all-matching__" onSelect={addAllMatching}>
<ListPlusIcon class="size-4" />
Add all {unselectedMatches.length} matching
</Command.Item>
</Command.Group>
{/if}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
+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,
+22 -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",
@@ -103,35 +103,35 @@
"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"
}
}
+4 -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,7 +93,7 @@
"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",
+138
View File
@@ -0,0 +1,138 @@
{
"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": "ВИРІШЕНО",
"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-значний код на"
}
}
@@ -21,10 +21,10 @@ import type { LayoutServerData } from "./layoutController.js";
// 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 {
@@ -7,8 +7,8 @@ import {
GetLoggedInSession,
GetLocaleFromCookie,
GetUsersCount,
HasRequiredEnv,
IsEmailSetup,
IsSetupComplete,
} from "./controller.js";
import type { EventDisplaySettings, GlobalPageVisibilitySettings, SiteDateTimeFormat } from "$lib/types/site.js";
@@ -86,7 +86,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;
@@ -486,18 +486,75 @@ export const UpdateMaintenanceEvent = async (
return await db.updateMaintenanceEvent(id, data);
};
export const UpdateMaintenanceEventStatus = async (id: number, status: string): Promise<number> => {
const validStatuses = ["SCHEDULED", "IN_PROGRESS", "COMPLETED", "CANCELLED"];
if (!validStatuses.includes(status)) {
throw new Error(`Invalid status: ${status}`);
/**
* Manually transition a maintenance event to a terminal status.
* Allowed transitions: ONGOING → COMPLETED, SCHEDULED/READY/ONGOING → CANCELLED.
* An event that already started has its end_date_time moved to the moment it was
* ended (the record reflects what actually happened); an event that never started
* keeps its planned window. See docs/adr/0006-manual-maintenance-event-transitions.md
*/
export const UpdateMaintenanceEventStatus = async (id: number, status: string): Promise<MaintenanceEventRecord> => {
if (status !== GC.COMPLETED && status !== GC.CANCELLED) {
throw new Error(`Invalid status: ${status}. Allowed values are ${GC.COMPLETED} and ${GC.CANCELLED}`);
}
const targetStatus = status as "COMPLETED" | "CANCELLED";
const existing = await db.getMaintenanceEventById(id);
if (!existing) {
throw new Error(`Maintenance event with id ${id} does not exist`);
}
return await db.updateMaintenanceEventStatus(id, status);
const allowedFrom: string[] = targetStatus === GC.COMPLETED ? [GC.ONGOING] : [GC.SCHEDULED, GC.READY, GC.ONGOING];
if (!allowedFrom.includes(existing.status)) {
throw new Error(`Cannot transition event from ${existing.status} to ${targetStatus}`);
}
if (existing.status === GC.ONGOING) {
// Ended now, but never before its first minute nor after its planned end
const endDateTime = Math.min(
existing.end_date_time,
Math.max(GetMinuteStartNowTimestampUTC(), existing.start_date_time + 60),
);
await db.updateMaintenanceEvent(id, { status: targetStatus, end_date_time: endDateTime });
} else {
await db.updateMaintenanceEventStatus(id, targetStatus);
}
const updated = await db.getMaintenanceEventById(id);
if (!updated) {
throw new Error(`Maintenance event with id ${id} does not exist`);
}
try {
const siteData = await GetAllSiteData();
const notificationSettings =
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
if (notificationSettings.event_types.ended) {
const siteVars = siteDataToVariables(siteData);
const siteUrl = siteVars.site_url;
const maintenance = await db.getMaintenanceById(updated.maintenance_id);
const monitors = await db.getMonitorsByMaintenanceId(updated.maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const eventDetailed: MaintenanceEventRecordDetailed = {
...updated,
title: maintenance?.title || "",
description: maintenance?.description || null,
};
const update = maintenanceToVariables(
eventDetailed,
monitorNames,
targetStatus === GC.COMPLETED ? "**has been completed**" : "**has been cancelled**",
targetStatus === GC.COMPLETED ? "completed" : "cancelled",
targetStatus === GC.COMPLETED ? "Maintenance Completed" : "Maintenance Cancelled",
siteUrl,
);
await subscriberQueue.push(update);
}
} catch (err) {
console.error(`Error sending ${targetStatus} notification for maintenance event ${id}:`, err);
}
return updated;
};
export const DeleteMaintenanceEvent = async (id: number): Promise<number> => {
@@ -328,7 +328,8 @@ export async function DeleteMonitorAlertConfig(id: number): Promise<boolean> {
throw new Error(`Monitor alert config with id '${id}' not found`);
}
// Triggers will be deleted automatically due to CASCADE
// The repository deletes trigger/monitor junctions and v2 alerts explicitly;
// FK cascades are not enforced on SQLite
const deleted = await db.deleteMonitorAlertConfig(id);
return deleted > 0;
}
@@ -21,11 +21,11 @@ import type {
import type { MonitorFilter } from "../db/repositories/base.js";
import db from "../db/db.js";
import type { PaginationInput } from "../../types/common.js";
import type { DayWiseStatus, NumberWithChange } from "../../types/monitor.js";
import GC, { getBadgeStyle, type BadgeStyle } from "../../global-constants.js";
import { makeBadge } from "badge-maker";
import { ErrorSvg } from "../../anywhere.js";
import { GetLastMonitoringValue, SetLastHeartbeat, DeleteMonitorCaches } from "../cache/setGet.js";
import { CollapseStatusCounts } from "../../clientTools.js";
import { translate, isLocaleAvailable } from "../i18n.js";
import type { HeartbeatMonitor, GroupMonitorTypeData } from "../types/monitor.js";
@@ -92,6 +92,7 @@ interface MonitoringDataInput {
latency?: number;
type: string;
error_message?: string | null;
raw_status?: string | null;
}
interface InterpolatedDataEntry {
@@ -112,6 +113,7 @@ export const InsertMonitoringData = async (data: MonitoringDataInput): Promise<M
latency: data.latency || 0,
type: data.type,
error_message: data.error_message,
raw_status: data.raw_status,
});
};
@@ -264,6 +266,7 @@ export const CloneMonitor = async ({ sourceTag, newTag, newName }: CloneMonitorI
type_data: source.type_data,
day_degraded_minimum_count: source.day_degraded_minimum_count,
day_down_minimum_count: source.day_down_minimum_count,
confirmation_threshold: source.confirmation_threshold,
include_degraded_in_downtime: source.include_degraded_in_downtime,
is_hidden: source.is_hidden,
monitor_settings_json: source.monitor_settings_json,
@@ -290,7 +293,7 @@ export const GetLatestMonitoringData = async (monitor_tag: string): Promise<Moni
};
export const GetLatestStatusActiveAll = async (): Promise<{ status: string }> => {
//get all the active not hidden monitor tags
const monitors = await db.getMonitors({ status: "ACTIVE", is_hidden: "NO" });
const monitors = await db.getMonitors({ status: GC.ACTIVE, is_hidden: GC.NO });
const monitor_tags = monitors.map((m) => m.tag);
const latestData: MonitoringData[] = [];
@@ -302,19 +305,20 @@ export const GetLatestStatusActiveAll = async (): Promise<{ status: string }> =>
}
}
let status: string = GC.NO_DATA;
for (let i = 0; i < latestData.length; i++) {
//if any status is down then status = down, if any is degraded then status = degraded, down > degraded > up
if (latestData[i].status === GC.DOWN) {
status = GC.DOWN;
} else if (latestData[i].status === GC.DEGRADED && status !== GC.DOWN) {
status = GC.DEGRADED;
} else if (latestData[i].status === GC.UP && status !== GC.DOWN && status !== GC.DEGRADED) {
status = GC.UP;
const counts = { countOfUp: 0, countOfDown: 0, countOfDegraded: 0, countOfMaintenance: 0 };
for (const data of latestData) {
if (data.status === GC.UP) {
counts.countOfUp++;
} else if (data.status === GC.DOWN) {
counts.countOfDown++;
} else if (data.status === GC.DEGRADED) {
counts.countOfDegraded++;
} else if (data.status === GC.MAINTENANCE) {
counts.countOfMaintenance++;
}
}
return {
status: status,
status: CollapseStatusCounts(counts),
};
};
@@ -419,6 +423,7 @@ async function removeTagFromGroupMonitors(tag: string): Promise<void> {
type_data: JSON.stringify(typeData),
day_degraded_minimum_count: group.day_degraded_minimum_count,
day_down_minimum_count: group.day_down_minimum_count,
confirmation_threshold: group.confirmation_threshold,
include_degraded_in_downtime: group.include_degraded_in_downtime,
is_hidden: group.is_hidden,
monitor_settings_json:
@@ -436,6 +441,7 @@ export const DeleteMonitorCompletelyUsingTag = async (tag: string): Promise<numb
await db.deleteMonitorDataByTag(tag);
await db.deleteIncidentMonitorsByTag(tag);
await db.deleteMonitorAlertsByTag(tag);
await db.deleteMonitorAlertConfigsByMonitorTag(tag);
await db.deletePageMonitorsByTag(tag);
await db.deleteMaintenanceMonitorsByTag(tag);
await removeTagFromGroupMonitors(tag);
@@ -461,9 +467,6 @@ export const GetAllAlertsPaginated = async (
export const GetMonitoringData = async (tag: string, since: number, now: number): Promise<MonitoringData[]> => {
return await db.getMonitoringData(tag, since, now);
};
export const GetMonitoringDataAll = async (tags: string[], since: number, now: number): Promise<MonitoringData[]> => {
return await db.getMonitoringDataAll(tags, since, now);
};
export const InsertNewAlert = async (data: MonitorAlertInsert): Promise<MonitorAlert | undefined> => {
if (await db.alertExists(data.monitor_tag, data.monitor_status, data.alert_status)) {
@@ -547,7 +550,7 @@ export const GetBadge = async (badgeType: BadgeType, params: BadgeParams): Promi
lastObj = await GetLatestStatusActiveAll();
} else {
// Single monitor status
const monitors = await GetMonitorsParsed({ tag, status: "ACTIVE", is_hidden: "NO" });
const monitors = await GetMonitorsParsed({ tag, status: GC.ACTIVE, is_hidden: GC.NO });
if (monitors.length === 0) {
return new Response(ErrorSvg, {
headers: { "Content-Type": "image/svg+xml" },
@@ -635,14 +638,14 @@ export const GetBadge = async (badgeType: BadgeType, params: BadgeParams): Promi
const siteData = await db.getSiteDataByKey("siteName");
const siteName = siteData?.value as string | undefined;
name = siteName || "All Monitors";
const goodMonitors = await GetMonitorsParsed({ status: "ACTIVE", is_hidden: "NO" });
const goodMonitors = await GetMonitorsParsed({ status: GC.ACTIVE, is_hidden: GC.NO });
const activeTags = goodMonitors.map((monitor) => monitor.tag);
stats = await db.getStatusCountsByInterval(activeTags, since, now - since, 1);
uptimeData = UptimeCalculator(stats);
} else {
// Single monitor badge
const monitors = await GetMonitorsParsed({ tag });
const monitors = await GetMonitorsParsed({ tag, status: GC.ACTIVE, is_hidden: GC.NO });
if (monitors.length === 0) {
return new Response(ErrorSvg, {
headers: { "Content-Type": "image/svg+xml" },
@@ -751,3 +754,6 @@ export const GetStatusCountsByIntervalGroupedByMonitor = async (
await setCache(cacheKey, result, 60);
return result;
};
export const GetLastKnownStatus = async (monitor_tag: string): Promise<MonitoringData | undefined> => {
return await db.getLastKnownStatus(monitor_tag);
};
@@ -108,6 +108,22 @@ export const GetLocaleFromCookie = (site: SiteDataTransformed, cookies: Cookies)
return selectedLang;
};
/**
* Returns the site URL used for building absolute public URLs, without a trailing slash.
* Prefers the configured siteURL and falls back to the ORIGIN env var; only absolute
* http(s) values are returned. Returns an empty string when neither is usable, in which
* case callers degrade to a relative path.
*/
export const GetSiteURL = async (): Promise<string> => {
const siteURL = await GetSiteDataByKey("siteURL");
for (const candidate of [siteURL, process.env.ORIGIN]) {
if (typeof candidate === "string" && /^https?:\/\//i.test(candidate)) {
return candidate.replace(/\/+$/, "");
}
}
return "";
};
export const GetSiteLogoURL = async (siteURL: string, logo: string, base: string): Promise<string> => {
if (logo.startsWith("http")) {
return logo;
@@ -138,14 +154,17 @@ export const GetSiteDataByKey = async (key: string): Promise<unknown> => {
return data.value;
};
/** Checks the env vars required for setup, without touching the database. */
export const HasRequiredEnv = (): boolean => {
return (
process.env.KENER_SECRET_KEY !== undefined &&
process.env.ORIGIN !== undefined &&
process.env.REDIS_URL !== undefined
);
};
export const IsSetupComplete = async (): Promise<boolean> => {
if (process.env.KENER_SECRET_KEY === undefined) {
return false;
}
if (process.env.ORIGIN === undefined) {
return false;
}
if (process.env.REDIS_URL === undefined) {
if (!HasRequiredEnv()) {
return false;
}
let data = await db.getAllSiteData();
+282 -61
View File
@@ -2,7 +2,7 @@ import db from "../db/db.js";
import type { PaginationInput } from "$lib/types/common";
import { GenerateToken, HashPassword, ValidatePassword, VerifyToken } from "./commonController.js";
import type { Cookies } from "@sveltejs/kit";
import type { UserRecordPublic, UserRecordDashboard } from "../types/db.js";
import type { UserRecordPublic, UserRecordDashboard, RoleRecord } from "../types/db.js";
import { GetAllSiteData } from "./controller.js";
import { siteDataToVariables } from "../notification/notification_utils.js";
import sendEmail from "../notification/email_notification.js";
@@ -16,7 +16,7 @@ export interface UserUpdateInput {
interface ManualUserUpdateInput {
updateType: string;
role?: string;
role_ids?: string[];
is_active?: number;
password?: string;
passwordPlain?: string;
@@ -32,7 +32,7 @@ interface NewUserInput {
name: string;
password: string;
plainPassword: string;
role: string;
role_ids: string[];
}
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -65,12 +65,18 @@ const validateNameOrThrow = (name: string): string => {
return normalizedName;
};
export const GetAllUsersPaginated = async (data: PaginationInput): Promise<UserRecordPublic[]> => {
return await db.getUsersPaginated(data.page, data.limit);
export const GetAllUsersPaginated = async (
data: PaginationInput,
filter?: { is_active?: number },
): Promise<UserRecordPublic[]> => {
return await db.getUsersPaginated(data.page, data.limit, filter);
};
export const GetAllUsersPaginatedDashboard = async (data: PaginationInput): Promise<UserRecordDashboard[]> => {
const users = await db.getUsersPaginated(data.page, data.limit);
export const GetAllUsersPaginatedDashboard = async (
data: PaginationInput,
filter?: { is_active?: number },
): Promise<UserRecordDashboard[]> => {
const users = await db.getUsersPaginated(data.page, data.limit, filter);
if (users.length === 0) return [];
// Batch fetch password statuses for all users
@@ -88,8 +94,8 @@ export const GetAllUsers = async () => {
return await db.getAllUsers();
};
export const GetUsersCount = async () => {
return await db.getUsersCount();
export const GetUsersCount = async (filter?: { is_active?: number }) => {
return await db.getTotalUsers(filter);
};
export const GetUserPasswordHashById = async (id: number) => {
@@ -145,14 +151,20 @@ export const UpdateUserData = async (data: UserUpdateInput): Promise<number> =>
}
};
export const CreateNewUser = async (currentUser: { role: string }, data: NewUserInput): Promise<number[]> => {
let acceptedRoles = ["member", "editor"];
if (!acceptedRoles.includes(data.role)) {
throw new Error("Invalid role");
export const CreateNewUser = async (data: NewUserInput): Promise<number[]> => {
if (!data.role_ids || data.role_ids.length === 0) {
throw new Error("At least one role is required");
}
if (currentUser.role === "member") {
throw new Error("Only admins and editors can create new users");
// Validate all role_ids exist and are active
for (const roleId of data.role_ids) {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" does not exist`);
}
if (role.status !== "ACTIVE") {
throw new Error(`Role "${roleId}" is not active`);
}
}
const normalizedEmail = validateEmailOrThrow(data.email);
@@ -163,11 +175,6 @@ export const CreateNewUser = async (currentUser: { role: string }, data: NewUser
throw new Error("Password cannot be empty");
}
//if data.role empty, throw error
if (!!!data.role) {
throw new Error("Role cannot be empty");
}
//if data.password not equal to data.plainPassword, throw error
if (data.password !== data.plainPassword) {
throw new Error("Passwords do not match");
@@ -182,7 +189,7 @@ export const CreateNewUser = async (currentUser: { role: string }, data: NewUser
email: normalizedEmail,
password_hash: await HashPassword(data.password),
name: normalizedName,
role: data.role,
role_ids: data.role_ids,
};
return await db.insertUser(user);
};
@@ -202,7 +209,7 @@ export const CreateFirstUser = async (data: { email: string; name: string; passw
email: normalizedEmail,
password_hash: await HashPassword(data.password),
name: normalizedName,
role: "admin",
role_ids: ["admin"],
is_owner: "YES",
};
return await db.insertUser(user);
@@ -229,33 +236,34 @@ export const UpdatePassword = async (data: PasswordUpdateInput): Promise<number>
});
};
const VALID_ROLES = ["admin", "editor", "member"] as const;
export const ManualUpdateUserData = async (
byUser: { id: number; role: string; is_owner: string },
forUserId: number,
data: ManualUserUpdateInput,
): Promise<number | undefined> => {
export const ManualUpdateUserData = async (forUserId: number, data: ManualUserUpdateInput): Promise<number | void> => {
let forUser = await db.getUserById(forUserId);
if (!forUser) {
throw new Error("User not found");
}
//only admins can update
if (byUser.role !== "admin") {
throw new Error("You do not have permission to update user");
}
// non-owner admins cannot modify other admins (self-updates are allowed)
if (forUser.role === "admin" && byUser.is_owner !== "YES" && forUser.id !== byUser.id) {
throw new Error("Only the owner can modify other admins");
}
if (data.updateType == "role") {
if (!data.role) throw new Error("Role is required");
if (!VALID_ROLES.includes(data.role as (typeof VALID_ROLES)[number])) {
throw new Error(`Invalid role. Must be one of: ${VALID_ROLES.join(", ")}`);
if (!data.role_ids || data.role_ids.length === 0) throw new Error("At least one role is required");
// Owner must always retain the admin role
if (forUser.is_owner === "YES" && !data.role_ids.includes("admin")) {
throw new Error("Owner must retain the admin role");
}
return await db.updateUserRole(forUser.id, data.role);
// Validate all role_ids exist and are active
for (const roleId of data.role_ids) {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" does not exist`);
}
if (role.status !== "ACTIVE") {
throw new Error(`Role "${roleId}" is not active`);
}
}
return await db.updateUserRoles(forUser.id, data.role_ids);
} else if (data.updateType == "is_active") {
if (data.is_active === undefined) throw new Error("is_active is required");
// Owner cannot be deactivated
if (forUser.is_owner === "YES" && data.is_active === 0) {
throw new Error("Owner account cannot be deactivated");
}
return await db.updateUserIsActive(forUser.id, data.is_active);
} else if (data.updateType == "password") {
if (!data.password || !data.passwordPlain) throw new Error("Password is required");
@@ -297,15 +305,20 @@ export const GetTotalUserPages = async (limit: number): Promise<number> => {
};
//send invitation email to user for account creation
export const SendInvitationEmail = async (email: string, role: string, name: string, currentUserRole: string) => {
if (currentUserRole === "member") {
throw new Error("Only admins and editors can create new users");
export const SendInvitationEmail = async (email: string, role_ids: string[], name: string) => {
if (!role_ids || role_ids.length === 0) {
throw new Error("At least one role is required");
}
// Admins can add admin, editor, member; Editors can only add editor, member
const acceptedRoles = currentUserRole === "admin" ? ["admin", "editor", "member"] : ["editor", "member"];
if (!acceptedRoles.includes(role)) {
throw new Error("Invalid role");
// Validate all role_ids exist and are active
for (const roleId of role_ids) {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" does not exist`);
}
if (role.status !== "ACTIVE") {
throw new Error(`Role "${roleId}" is not active`);
}
}
const normalizedEmail = validateEmailOrThrow(email);
@@ -323,7 +336,7 @@ export const SendInvitationEmail = async (email: string, role: string, name: str
email: normalizedEmail,
password_hash: "",
name: normalizedName,
role,
role_ids: role_ids,
is_active: 0,
});
} catch (error: unknown) {
@@ -364,11 +377,7 @@ export const SendInvitationEmail = async (email: string, role: string, name: str
};
//resend invitation email to existing user with blank password
export const ResendInvitationEmail = async (email: string, currentUserRole: string) => {
if (currentUserRole === "member") {
throw new Error("Only admins and editors can resend invitations");
}
export const ResendInvitationEmail = async (email: string) => {
const normalizedEmail = validateEmailOrThrow(email);
const user = await db.getUserByEmail(normalizedEmail);
@@ -410,17 +419,11 @@ export const ResendInvitationEmail = async (email: string, currentUserRole: stri
};
// send verification email with verification link
export const SendVerificationEmail = async (toUserId: number, currentUser: { id: number; role: string }) => {
export const SendVerificationEmail = async (toUserId: number, currentUserId: number) => {
if (!toUserId) {
throw new Error("User ID is required");
}
// Only admins/editors can send verification to other users.
// Members can only send verification email to themselves.
if (currentUser.role === "member" && currentUser.id !== toUserId) {
throw new Error("You do not have permission to send verification email for this user");
}
const user = await db.getUserById(toUserId);
if (!user) {
throw new Error("User not found");
@@ -458,3 +461,221 @@ export const SendVerificationEmail = async (toUserId: number, currentUser: { id:
template.template_text_body || "",
);
};
const RESTRICTED_ROLE_IDS = ["admin", "editor", "member"];
const ROLE_ID_REGEX = /^[a-z0-9_-]+$/;
const normalizeRoleId = (id: string): string => {
return id.trim().toLowerCase().replace(/\s+/g, "_");
};
export const CreateRole = async (data: { role_id: string; name: string }): Promise<RoleRecord> => {
const roleId = normalizeRoleId(data.role_id || "");
const roleName = data.name?.trim();
if (!roleId) {
throw new Error("Role ID is required");
}
if (!ROLE_ID_REGEX.test(roleId)) {
throw new Error("Role ID can only contain lowercase letters, numbers, underscores, and hyphens");
}
if (!roleName) {
throw new Error("Role name is required");
}
if (RESTRICTED_ROLE_IDS.includes(roleId)) {
throw new Error(`Role ID "${roleId}" is restricted and cannot be used`);
}
const existing = await db.getRoleById(roleId);
if (existing) {
throw new Error(`Role with ID "${roleId}" already exists`);
}
await db.insertRole({ id: roleId, role_name: roleName });
const created = await db.getRoleById(roleId);
if (!created) {
throw new Error("Failed to create role");
}
return created;
};
export const UpdateRole = async (roleId: string, data: { name?: string; status?: string }): Promise<RoleRecord> => {
if (!roleId) {
throw new Error("Role ID is required");
}
const existing = await db.getRoleById(roleId);
if (!existing) {
throw new Error(`Role "${roleId}" not found`);
}
if (existing.readonly === 1) {
throw new Error("Readonly roles cannot be updated");
}
const updates: { role_name?: string; status?: string } = {};
if (data.name !== undefined) {
const trimmed = data.name.trim();
if (!trimmed) {
throw new Error("Role name cannot be empty");
}
updates.role_name = trimmed;
}
if (data.status !== undefined) {
if (data.status !== "ACTIVE" && data.status !== "INACTIVE") {
throw new Error("Status must be ACTIVE or INACTIVE");
}
updates.status = data.status;
}
if (Object.keys(updates).length === 0) {
throw new Error("No valid fields to update");
}
await db.updateRole(roleId, updates);
const updated = await db.getRoleById(roleId);
if (!updated) {
throw new Error("Failed to retrieve updated role");
}
return updated;
};
export const DeleteRole = async (
roleId: string,
options: { action: "migrate"; targetRoleId: string } | { action: "remove" },
): Promise<{ success: true }> => {
if (!roleId) {
throw new Error("Role ID is required");
}
const existing = await db.getRoleById(roleId);
if (!existing) {
throw new Error(`Role "${roleId}" not found`);
}
if (existing.readonly === 1) {
throw new Error("Readonly roles cannot be deleted");
}
if (options.action === "migrate") {
const targetRoleId = options.targetRoleId?.trim();
if (!targetRoleId) {
throw new Error("Target role ID is required for migration");
}
if (targetRoleId === roleId) {
throw new Error("Target role cannot be the same as the role being deleted");
}
const targetRole = await db.getRoleById(targetRoleId);
if (!targetRole) {
throw new Error(`Target role "${targetRoleId}" not found`);
}
if (targetRole.status !== "ACTIVE") {
throw new Error("Cannot migrate users to an inactive role");
}
await db.migrateUsersRole(roleId, targetRoleId);
}
// CASCADE on FK will clean up users_roles and roles_permissions
await db.deleteRole(roleId);
return { success: true };
};
export const GetAllRoles = async (): Promise<RoleRecord[]> => {
return await db.getAllRoles();
};
export const GetAllPermissions = async () => {
return await db.getAllPermissions();
};
export const GetRolePermissions = async (roleId: string) => {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" not found`);
}
return await db.getRolePermissions(roleId);
};
export const UpdateRolePermissions = async (roleId: string, permissionIds: string[]) => {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" not found`);
}
if (role.readonly === 1) {
throw new Error("Readonly roles cannot have their permissions modified");
}
// Get current permissions
const current = await db.getRolePermissions(roleId);
const currentIds = new Set(current.map((p) => p.permissions_id));
const desiredIds = new Set(permissionIds);
// Add new permissions
for (const pid of permissionIds) {
if (!currentIds.has(pid)) {
await db.addRolePermission(roleId, pid);
}
}
// Remove old permissions
for (const pid of currentIds) {
if (!desiredIds.has(pid)) {
await db.removeRolePermission(roleId, pid);
}
}
return await db.getRolePermissions(roleId);
};
export const GetRoleUsers = async (roleId: string) => {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" not found`);
}
return await db.getUsersByRoleId(roleId);
};
export const AddUserToRole = async (roleId: string, userId: number) => {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" not found`);
}
if (role.status !== "ACTIVE") {
throw new Error(`Role "${roleId}" is not active`);
}
// Check if user already in role
const users = await db.getUsersByRoleId(roleId);
if (users.some((u) => u.id === userId)) {
throw new Error("User is already assigned to this role");
}
await db.addUserToRole(roleId, userId);
return { success: true };
};
export const RemoveUserFromRole = async (roleId: string, userId: number) => {
if (roleId === "admin") {
const user = await db.getUserById(userId);
if (user && user.is_owner === "YES") {
throw new Error("The owner cannot be removed from the admin role");
}
}
await db.removeUserFromRole(roleId, userId);
return { success: true };
};
export const GetUserPermissions = async (userId: number): Promise<Set<string>> => {
const permissionIds = await db.getUserPermissionIds(userId);
return new Set(permissionIds);
};
export const RequirePermission = (userPermissions: Set<string>, permissionId: string): void => {
if (!userPermissions.has(permissionId)) {
throw new Error("You do not have permission to perform this action");
}
};
+56 -4
View File
@@ -48,7 +48,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 +64,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 +118,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"];
@@ -390,7 +411,6 @@ class DbImpl {
private bindMonitoringMethods(): void {
this.insertMonitoringData = this.monitoring.insertMonitoringData.bind(this.monitoring);
this.getMonitoringData = this.monitoring.getMonitoringData.bind(this.monitoring);
this.getMonitoringDataAll = this.monitoring.getMonitoringDataAll.bind(this.monitoring);
this.getLatestMonitoringData = this.monitoring.getLatestMonitoringData.bind(this.monitoring);
this.getLatestMonitoringDataN = this.monitoring.getLatestMonitoringDataN.bind(this.monitoring);
this.getMonitoringDataPaginated = this.monitoring.getMonitoringDataPaginated.bind(this.monitoring);
@@ -407,6 +427,9 @@ class DbImpl {
this.consecutivelyStatusFor = this.monitoring.consecutivelyStatusFor.bind(this.monitoring);
this.consecutivelyLatencyGreaterThan = this.monitoring.consecutivelyLatencyGreaterThan.bind(this.monitoring);
this.consecutivelyLatencyLessThan = this.monitoring.consecutivelyLatencyLessThan.bind(this.monitoring);
this.getRecentSamplesForConfirmation = this.monitoring.getRecentSamplesForConfirmation.bind(this.monitoring);
this.getLastObservedStatus = this.monitoring.getLastObservedStatus.bind(this.monitoring);
this.backfillConfirmedStatus = this.monitoring.backfillConfirmedStatus.bind(this.monitoring);
this.updateMonitoringData = this.monitoring.updateMonitoringData.bind(this.monitoring);
this.deleteMonitorDataByTag = this.monitoring.deleteMonitorDataByTag.bind(this.monitoring);
this.getStatusCountsByInterval = this.monitoring.getStatusCountsByInterval.bind(this.monitoring);
@@ -414,6 +437,7 @@ class DbImpl {
this.monitoring,
);
this.getStatusCountsForLastN = this.monitoring.getStatusCountsForLastN.bind(this.monitoring);
this.getLastKnownStatus = this.monitoring.getLastKnownStatus.bind(this.monitoring);
}
private bindMonitorsMethods(): void {
@@ -460,7 +484,7 @@ class DbImpl {
this.getUsersPaginated = this.users.getUsersPaginated.bind(this.users);
this.getTotalUsers = this.users.getTotalUsers.bind(this.users);
this.updateUserName = this.users.updateUserName.bind(this.users);
this.updateUserRole = this.users.updateUserRole.bind(this.users);
this.updateUserRoles = this.users.updateUserRoles.bind(this.users);
this.updateUserIsActive = this.users.updateUserIsActive.bind(this.users);
this.updateUserPasswordById = this.users.updateUserPasswordById.bind(this.users);
this.updateIsVerified = this.users.updateIsVerified.bind(this.users);
@@ -469,6 +493,24 @@ class DbImpl {
this.deleteApiKey = this.users.deleteApiKey.bind(this.users);
this.getApiKeyByHashedKey = this.users.getApiKeyByHashedKey.bind(this.users);
this.getAllApiKeys = this.users.getAllApiKeys.bind(this.users);
// Roles
this.getRoleById = this.users.getRoleById.bind(this.users);
this.getAllRoles = this.users.getAllRoles.bind(this.users);
this.insertRole = this.users.insertRole.bind(this.users);
this.updateRole = this.users.updateRole.bind(this.users);
this.deleteRole = this.users.deleteRole.bind(this.users);
this.getUsersCountByRoleId = this.users.getUsersCountByRoleId.bind(this.users);
this.migrateUsersRole = this.users.migrateUsersRole.bind(this.users);
this.getRolePermissions = this.users.getRolePermissions.bind(this.users);
this.getAllPermissions = this.users.getAllPermissions.bind(this.users);
this.addRolePermission = this.users.addRolePermission.bind(this.users);
this.removeRolePermission = this.users.removeRolePermission.bind(this.users);
this.getUsersByRoleId = this.users.getUsersByRoleId.bind(this.users);
this.addUserToRole = this.users.addUserToRole.bind(this.users);
this.removeUserFromRole = this.users.removeUserFromRole.bind(this.users);
this.getUserPermissionIds = this.users.getUserPermissionIds.bind(this.users);
this.getUserRoleIds = this.users.getUserRoleIds.bind(this.users);
}
private bindSiteDataMethods(): void {
@@ -798,6 +840,16 @@ class DbImpl {
async init(): Promise<void> {}
/** 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();
}
@@ -141,9 +141,17 @@ export class MonitorAlertConfigRepository extends BaseRepository {
}
/**
* Delete a monitor alert config by ID
* Delete a monitor alert config by ID, including all child rows.
*
* Child rows are removed explicitly even though FK cascades are declared:
* SQLite never enforces them (foreign_keys pragma is off), so relying on
* CASCADE orphans children on the default deployment. See
* docs/adr/0008-explicit-deletes-over-fk-cascades.md.
*/
async deleteMonitorAlertConfig(id: number): Promise<number> {
await this.knex("monitor_alerts_v2").where({ config_id: id }).del();
await this.knex("monitor_alerts_config_triggers").where({ monitor_alerts_id: id }).del();
await this.knex("monitor_alerts_config_monitors").where({ monitor_alerts_id: id }).del();
return await this.knex("monitor_alerts_config").where({ id }).del();
}
@@ -158,7 +166,11 @@ export class MonitorAlertConfigRepository extends BaseRepository {
if (configIds.length === 0) return 0;
// Remove the monitor from the junction table
// Remove the monitor from the junction table, along with its per-monitor
// alert state — a shared config survives the detach, but its v2 rows for
// this tag would otherwise dangle (see deleteMonitorAlertConfig on why
// FK cascades can't be relied on)
await this.knex("monitor_alerts_v2").where({ monitor_tag: monitorTag }).del();
await this.knex("monitor_alerts_config_monitors").where({ monitor_tag: monitorTag }).del();
// Delete any configs that now have zero monitors
@@ -170,7 +182,7 @@ export class MonitorAlertConfigRepository extends BaseRepository {
.where({ monitor_alerts_id: id })
.first<CountResult>();
if (Number(remainingMonitors?.count) === 0) {
await this.knex("monitor_alerts_config").where({ id }).del();
await this.deleteMonitorAlertConfig(id);
deletedCount++;
}
}
+136 -27
View File
@@ -10,18 +10,42 @@ import type {
TimestampStatusCountByMonitor,
} from "../../types/db.js";
/**
* Sample types alert evaluation can see (see docs/adr/0005-alerts-evaluate-alert-visible-samples.md).
* Exactly the types written by flows that enqueue alert evaluation: scheduler checks
* (REALTIME/ERROR/TIMEOUT), default-status fill (DEFAULT_STATUS), and data-API pushes (MANUAL).
* SIGNAL rows (raw heartbeat receipts) and INCIDENT/MAINTENANCE overlays stay invisible, so the
* alert window freezes during manual overlays instead of triggering or resolving on them.
*/
const ALERT_VISIBLE_TYPES = [GC.REALTIME, GC.ERROR, GC.TIMEOUT, GC.MANUAL, GC.DEFAULT_STATUS];
/**
* Scheduled-check sample types that count toward a monitor's Confirmation Threshold
* (issue #712). Intentionally narrower than ALERT_VISIBLE_TYPES: MANUAL pushes
* and DEFAULT_STATUS fill stay transparent to threshold counting.
*/
const OBSERVED_CHECK_TYPES = [GC.REALTIME, GC.TIMEOUT, GC.ERROR];
/**
* Overlay sample types that FREEZE Confirmation Threshold counting (issue #712):
* while one is active the count does not advance, and it acts as a hard boundary the
* pending run cannot cross. Included in the confirmation lookback (unlike MANUAL/DEFAULT,
* which stay transparent) so the resolver can detect the boundary.
*/
const OVERLAY_TYPES = [GC.INCIDENT, GC.MAINTENANCE];
/**
* Repository for monitoring data operations
*/
export class MonitoringRepository extends BaseRepository {
async insertMonitoringData(data: MonitoringDataInsert): Promise<MonitoringData | null> {
const { monitor_tag, timestamp, status, latency, type, error_message } = data;
const { monitor_tag, timestamp, status, latency, type, error_message, raw_status } = data;
// Perform insert/update - works across PostgreSQL, MySQL, and SQLite
await this.knex("monitoring_data")
.insert({ monitor_tag, timestamp, status, latency, type, error_message })
.insert({ monitor_tag, timestamp, status, latency, type, error_message, raw_status })
.onConflict(["monitor_tag", "timestamp"])
.merge({ status, latency, type, error_message });
.merge({ status, latency, type, error_message, raw_status });
// Query and return the inserted/updated record (works consistently across all databases)
const record = await this.knex("monitoring_data")
@@ -40,27 +64,6 @@ export class MonitoringRepository extends BaseRepository {
.orderBy("timestamp", "asc");
}
// Groups by timestamp and applies priority: DOWN > DEGRADED > UP
async getMonitoringDataAll(monitor_tags: string[], start: number, end: number): Promise<MonitoringData[]> {
return await this.knex("monitoring_data")
.select(
"timestamp",
this.knex.raw(`
CASE
WHEN MAX(CASE WHEN status = 'DOWN' THEN 1 ELSE 0 END) = 1 THEN 'DOWN'
WHEN MAX(CASE WHEN status = 'DEGRADED' THEN 1 ELSE 0 END) = 1 THEN 'DEGRADED'
ELSE 'UP'
END as status
`),
)
.whereIn("monitor_tag", monitor_tags)
.where("timestamp", ">=", start)
.where("timestamp", "<=", end)
.whereNotNull("status")
.groupBy("timestamp")
.orderBy("timestamp", "asc");
}
async getLatestMonitoringData(monitor_tag: string): Promise<MonitoringData | undefined> {
return await this.knex("monitoring_data")
.where("monitor_tag", monitor_tag)
@@ -262,7 +265,7 @@ export class MonitoringRepository extends BaseRepository {
qb.select("*")
.from("monitoring_data")
.where("monitor_tag", monitor_tag)
.andWhere("type", "=", GC.REALTIME)
.whereIn("type", ALERT_VISIBLE_TYPES)
.orderBy("timestamp", "desc")
.limit(lastX);
})
@@ -288,7 +291,7 @@ export class MonitoringRepository extends BaseRepository {
qb.select("*")
.from("monitoring_data")
.where("monitor_tag", monitor_tag)
.andWhere("type", "=", GC.REALTIME)
.whereIn("type", ALERT_VISIBLE_TYPES)
.orderBy("timestamp", "desc")
.limit(lastX);
})
@@ -310,7 +313,7 @@ export class MonitoringRepository extends BaseRepository {
qb.select("*")
.from("monitoring_data")
.where("monitor_tag", monitor_tag)
.andWhere("type", "=", GC.REALTIME)
.whereIn("type", ALERT_VISIBLE_TYPES)
.orderBy("timestamp", "desc")
.limit(lastX);
})
@@ -326,6 +329,107 @@ export class MonitoringRepository extends BaseRepository {
return result.is_recovered === 1;
}
/**
* Recent samples the Confirmation Threshold resolver needs, newest first: scheduled-check
* observations (REALTIME/TIMEOUT/ERROR) plus incident/maintenance overlays. MANUAL pushes
* and DEFAULT fill are excluded — they stay transparent to the counter. Returns `type` so
* the resolver can stop at overlay rows (freeze). Observations whose status is NO_DATA are
* excluded entirely (neutral — they neither advance nor reset the count and must not consume lookback slots).
*/
async getRecentSamplesForConfirmation(
monitor_tag: string,
beforeTs: number,
limit: number,
): Promise<Array<{ timestamp: number; status: string | null; raw_status: string | null; type: string | null }>> {
return await this.knex("monitoring_data")
.select("timestamp", "status", "raw_status", "type")
.where("monitor_tag", monitor_tag)
.where("timestamp", "<", beforeTs)
.whereIn("type", [...OBSERVED_CHECK_TYPES, ...OVERLAY_TYPES])
.whereNot("status", GC.NO_DATA)
.orderBy("timestamp", "desc")
.limit(limit);
}
/**
* The committed status of the most recent real scheduled-check observation before `beforeTs`
* — the Confirmation Threshold "anchor" (the side currently shown). Looks past overlays,
* MANUAL/DEFAULT, and NO_DATA so a long incident/maintenance window can never hide the anchor
* (issue #712). Returns null when there is no prior observation (cold start).
*/
async getLastObservedStatus(monitor_tag: string, beforeTs: number): Promise<string | null> {
const row = await this.knex("monitoring_data")
.select("status")
.where("monitor_tag", monitor_tag)
.where("timestamp", "<", beforeTs)
.whereIn("type", OBSERVED_CHECK_TYPES)
.whereNot("status", GC.NO_DATA)
.orderBy("timestamp", "desc")
.limit(1)
.first();
return row ? (row.status ?? null) : null;
}
/**
* Backfill a confirmed status flip: set each row's committed status to its observed raw_status.
* `confirmThreshold` is the number of consecutive checks that confirmed the flip — when it is a
* number the run resolved to an unhealthy side and a per-row note ("Down"/"Degraded confirmed
* after N consecutive checks", matching each row's own severity) is appended to the existing
* error text; when it is null the run resolved to UP (recovery) and the error text is cleared.
*/
async backfillConfirmedStatus(
monitor_tag: string,
timestamps: number[],
confirmThreshold: number | null,
): Promise<number> {
if (timestamps.length === 0) return 0;
// Recovery (confirmed UP): rows become the UP side — clear any held error text in one update.
if (confirmThreshold === null) {
return await this.knex("monitoring_data")
.where("monitor_tag", monitor_tag)
.whereIn("timestamp", timestamps)
.whereNotNull("raw_status")
.update({
status: this.knex.ref("raw_status"),
error_message: null,
});
}
// Confirmed unhealthy: set each row's status from its observed raw_status and APPEND a
// severity-matched confirmation note to the existing error text (preserving the observed
// failure reason). Done per-row for portable string concatenation (|| vs CONCAT differ across
// SQLite/PG/MySQL), per-row severity wording, and idempotency if the backfill is replayed.
// The whole read+update window runs in one transaction — a confirmation flip is one logical
// write, so it must not leave the window half-confirmed/half-held if a row update fails.
return await this.knex.transaction(async (trx: KnexType.Transaction) => {
const rows = await trx("monitoring_data")
.select("timestamp", "error_message", "raw_status")
.where("monitor_tag", monitor_tag)
.whereIn("timestamp", timestamps)
.whereNotNull("raw_status");
let updated = 0;
for (const row of rows) {
const severity = row.raw_status === GC.DEGRADED ? "Degraded" : "Down";
const note = `${severity} confirmed after ${confirmThreshold} consecutive checks`;
const existing: string | null = row.error_message;
let nextMessage: string;
if (!existing) {
nextMessage = note;
} else if (existing.indexOf(note) !== -1) {
nextMessage = existing; // already appended — keep idempotent
} else {
nextMessage = `${existing} | ${note}`;
}
updated += await trx("monitoring_data")
.where({ monitor_tag, timestamp: row.timestamp })
.update({ status: row.raw_status, error_message: nextMessage });
}
return updated;
});
}
async updateMonitoringData(
monitor_tag: string,
start: number,
@@ -587,4 +691,9 @@ export class MonitoringRepository extends BaseRepository {
minLatency: Number(result?.min_latency) || 0,
};
}
//get the last known status for a monitor
async getLastKnownStatus(monitor_tag: string): Promise<MonitoringData | undefined> {
return await this.knex("monitoring_data").where("monitor_tag", monitor_tag).orderBy("timestamp", "desc").first();
}
}
@@ -2,6 +2,17 @@ import type { Knex as KnexType } from "knex";
import { BaseRepository, type MonitorFilter, type CountResult } from "./base.js";
import type { MonitorRecord, MonitorRecordInsert } from "../../types/db.js";
/**
* Clamp the Confirmation Threshold to its 160 invariant at the data layer, so the bound holds
* for every app write path (v4 API, manage API, clone, group), not only the v4 API validator.
* A non-finite/missing value defaults to 1 (off).
*/
function clampConfirmationThreshold(value: number | null | undefined): number {
const n = Math.round(Number(value));
if (!Number.isFinite(n)) return 1;
return Math.min(60, Math.max(1, n));
}
/**
* Repository for monitors CRUD operations
*/
@@ -28,6 +39,7 @@ export class MonitorsRepository extends BaseRepository {
type_data: data.type_data,
day_degraded_minimum_count: data.day_degraded_minimum_count,
day_down_minimum_count: data.day_down_minimum_count,
confirmation_threshold: clampConfirmationThreshold(data.confirmation_threshold),
include_degraded_in_downtime: data.include_degraded_in_downtime,
is_hidden: data.is_hidden || "NO",
monitor_settings_json: data.monitor_settings_json,
@@ -51,6 +63,7 @@ export class MonitorsRepository extends BaseRepository {
type_data: data.type_data,
day_degraded_minimum_count: data.day_degraded_minimum_count,
day_down_minimum_count: data.day_down_minimum_count,
confirmation_threshold: clampConfirmationThreshold(data.confirmation_threshold),
include_degraded_in_downtime: data.include_degraded_in_downtime,
is_hidden: data.is_hidden,
monitor_settings_json: data.monitor_settings_json,
+238 -20
View File
@@ -1,5 +1,14 @@
import { BaseRepository, type CountResult } from "./base.js";
import type { UserRecordInsert, UserRecordPublic, ApiKeyRecord, ApiKeyRecordInsert } from "../../types/db.js";
import type {
UserRecordInsert,
UserRecordPublic,
ApiKeyRecord,
ApiKeyRecordInsert,
RoleRecord,
RolePermissionRecord,
UserRoleRecord,
} from "../../types/db.js";
import { GetDbType } from "../../tool.js";
/**
* Repository for users, API keys operations
@@ -11,11 +20,46 @@ export class UsersRepository extends BaseRepository {
return await this.knex("users").count("* as count").first<CountResult>();
}
private readonly userColumns = [
"id",
"email",
"name",
"is_active",
"is_verified",
"is_owner",
"created_at",
"updated_at",
] as const;
private async enrichWithRoleIds(user: Record<string, unknown>): Promise<UserRecordPublic> {
const roleIds = await this.getUserRoleIds(user.id as number);
return { ...user, role_ids: roleIds } as UserRecordPublic;
}
private async enrichManyWithRoleIds(users: Record<string, unknown>[]): Promise<UserRecordPublic[]> {
if (users.length === 0) return [];
const userIds = users.map((u) => u.id as number);
const roleRows = await this.knex("users_roles")
.join("roles", "users_roles.roles_id", "roles.id")
.whereIn("users_roles.users_id", userIds)
.where("roles.status", "ACTIVE")
.select("users_roles.users_id as users_id", "roles.id as role_id");
const roleMap = new Map<number, string[]>();
for (const row of roleRows) {
const list = roleMap.get(row.users_id) || [];
list.push(row.role_id);
roleMap.set(row.users_id, list);
}
return users.map((u) => ({ ...u, role_ids: roleMap.get(u.id as number) || [] }) as UserRecordPublic);
}
async getUserByEmail(email: string): Promise<UserRecordPublic | undefined> {
return await this.knex("users")
.select("id", "email", "name", "is_active", "is_verified", "is_owner", "role", "created_at", "updated_at")
const row = await this.knex("users")
.select(...this.userColumns)
.where("email", email)
.first();
if (!row) return undefined;
return await this.enrichWithRoleIds(row);
}
async getUserPasswordHashById(id: number): Promise<{ password_hash: string } | undefined> {
@@ -28,22 +72,43 @@ export class UsersRepository extends BaseRepository {
}
async getUserById(id: number): Promise<UserRecordPublic | undefined> {
return await this.knex("users")
.select("id", "email", "name", "is_active", "is_verified", "is_owner", "role", "created_at", "updated_at")
const row = await this.knex("users")
.select(...this.userColumns)
.where("id", id)
.first();
if (!row) return undefined;
return await this.enrichWithRoleIds(row);
}
async insertUser(data: UserRecordInsert): Promise<number[]> {
return await this.knex("users").insert({
const dbType = GetDbType();
const insertData = {
email: data.email,
name: data.name,
password_hash: data.password_hash,
role: data.role,
is_owner: data.is_owner || "NO",
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
});
};
let userId: number;
if (dbType === "postgresql") {
const [row] = await this.knex("users").insert(insertData).returning("id");
userId = typeof row === "object" ? (row as { id: number }).id : (row as number);
} else {
const result = await this.knex("users").insert(insertData);
userId = result[0];
}
if (data.role_ids && data.role_ids.length > 0) {
const roleInserts = data.role_ids.map((roleId) => ({
users_id: userId,
roles_id: roleId,
}));
await this.knex("users_roles").insert(roleInserts);
}
return [userId];
}
async updateUserPassword(data: { id: number; password_hash: string }): Promise<number> {
@@ -54,21 +119,31 @@ export class UsersRepository extends BaseRepository {
}
async getAllUsers(): Promise<UserRecordPublic[]> {
return await this.knex("users")
.select("id", "email", "name", "role", "is_active", "is_verified", "is_owner", "created_at", "updated_at")
const rows = await this.knex("users")
.select(...this.userColumns)
.orderBy("created_at", "desc");
return await this.enrichManyWithRoleIds(rows);
}
async getUsersPaginated(page: number, limit: number): Promise<UserRecordPublic[]> {
return await this.knex("users")
.select("id", "email", "name", "role", "is_active", "is_verified", "is_owner", "created_at", "updated_at")
async getUsersPaginated(page: number, limit: number, filter?: { is_active?: number }): Promise<UserRecordPublic[]> {
const query = this.knex("users")
.select(...this.userColumns)
.orderBy("created_at", "desc")
.limit(limit)
.offset((page - 1) * limit);
if (filter?.is_active !== undefined) {
query.where("is_active", filter.is_active);
}
const rows = await query;
return await this.enrichManyWithRoleIds(rows);
}
async getTotalUsers(): Promise<CountResult | undefined> {
return await this.knex("users").count("* as count").first<CountResult>();
async getTotalUsers(filter?: { is_active?: number }): Promise<CountResult | undefined> {
const query = this.knex("users").count("* as count");
if (filter?.is_active !== undefined) {
query.where("is_active", filter.is_active);
}
return await query.first<CountResult>();
}
async updateUserName(id: number, name: string): Promise<number> {
@@ -78,11 +153,18 @@ export class UsersRepository extends BaseRepository {
});
}
async updateUserRole(id: number, role: string): Promise<number> {
return await this.knex("users").where({ id }).update({
role,
updated_at: this.knex.fn.now(),
});
async updateUserRoles(id: number, roleIds: string[]): Promise<void> {
await this.knex("users_roles").where("users_id", id).delete();
if (roleIds.length > 0) {
const inserts = roleIds.map((roleId) => ({
users_id: id,
roles_id: roleId,
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
}));
await this.knex("users_roles").insert(inserts);
}
await this.knex("users").where({ id }).update({ updated_at: this.knex.fn.now() });
}
async updateUserIsActive(id: number, is_active: number): Promise<number> {
@@ -138,4 +220,140 @@ export class UsersRepository extends BaseRepository {
}
// ============ Invitations ============
// ============ Roles ============
async getRoleById(id: string): Promise<RoleRecord | undefined> {
return await this.knex("roles").where("id", id).first();
}
async getAllRoles(): Promise<RoleRecord[]> {
return await this.knex("roles").orderBy("created_at", "asc");
}
async insertRole(data: { id: string; role_name: string; readonly?: number }): Promise<void> {
await this.knex("roles").insert({
id: data.id,
role_name: data.role_name,
readonly: data.readonly ?? 0,
status: "ACTIVE",
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
});
}
async updateRole(id: string, data: { role_name?: string; status?: string }): Promise<number> {
const updateData: Record<string, unknown> = { updated_at: this.knex.fn.now() };
if (data.role_name !== undefined) updateData.role_name = data.role_name;
if (data.status !== undefined) updateData.status = data.status;
return await this.knex("roles").where("id", id).update(updateData);
}
async deleteRole(id: string): Promise<number> {
return await this.knex("roles").where("id", id).delete();
}
async getUsersCountByRoleId(roleId: string): Promise<number> {
const result = await this.knex("users_roles").where("roles_id", roleId).count("* as count").first<CountResult>();
return result ? Number(result.count) : 0;
}
async migrateUsersRole(fromRoleId: string, toRoleId: string): Promise<void> {
// Find users who already have the target role to avoid duplicate PK
const usersWithTarget = this.knex("users_roles").where("roles_id", toRoleId).select("users_id");
// Update users who don't already have the target role
await this.knex("users_roles").where("roles_id", fromRoleId).whereNotIn("users_id", usersWithTarget).update({
roles_id: toRoleId,
updated_at: this.knex.fn.now(),
});
// Delete remaining assignments (users who already had the target role)
await this.knex("users_roles").where("roles_id", fromRoleId).delete();
}
// ============ Role Permissions ============
async getRolePermissions(roleId: string): Promise<RolePermissionRecord[]> {
return await this.knex("roles_permissions").where("roles_id", roleId);
}
async getAllPermissions(): Promise<Array<{ id: string; permission_name: string }>> {
return await this.knex("permissions").select("id", "permission_name").orderBy("id", "asc");
}
async addRolePermission(roleId: string, permissionId: string): Promise<void> {
await this.knex("roles_permissions").insert({
roles_id: roleId,
permissions_id: permissionId,
status: "ACTIVE",
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
});
}
async removeRolePermission(roleId: string, permissionId: string): Promise<number> {
return await this.knex("roles_permissions").where({ roles_id: roleId, permissions_id: permissionId }).delete();
}
// ============ Role Users ============
async getUsersByRoleId(roleId: string): Promise<Array<UserRecordPublic & { roles_id: string }>> {
const rows = await this.knex("users_roles")
.join("users", "users_roles.users_id", "users.id")
.where("users_roles.roles_id", roleId)
.select(
"users.id",
"users.email",
"users.name",
"users.is_active",
"users.is_verified",
"users.is_owner",
"users.created_at",
"users.updated_at",
"users_roles.roles_id",
);
const enriched = await this.enrichManyWithRoleIds(rows);
return enriched.map((u, i) => ({ ...u, roles_id: rows[i].roles_id }));
}
async addUserToRole(roleId: string, userId: number): Promise<void> {
await this.knex("users_roles").insert({
roles_id: roleId,
users_id: userId,
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
});
}
async removeUserFromRole(roleId: string, userId: number): Promise<number> {
return await this.knex("users_roles").where({ roles_id: roleId, users_id: userId }).delete();
}
async getUserRoleIds(userId: number): Promise<string[]> {
const rows = await this.knex("users_roles")
.join("roles", function () {
this.on("users_roles.roles_id", "roles.id");
})
.where("users_roles.users_id", userId)
.where("roles.status", "ACTIVE")
.distinct("roles.id as id")
.select();
return rows.map((r: { id: string }) => r.id);
}
async getUserPermissionIds(userId: number): Promise<string[]> {
const knex = this.knex;
const rows = await knex("users_roles")
.join("roles", function () {
this.on("users_roles.roles_id", "roles.id").andOn("roles.status", knex.raw("?", ["ACTIVE"]));
})
.join("roles_permissions", function () {
this.on("roles_permissions.roles_id", "roles.id").andOn("roles_permissions.status", knex.raw("?", ["ACTIVE"]));
})
.where("users_roles.users_id", userId)
.distinct("roles_permissions.permissions_id as id")
.select();
return rows.map((r: { id: string }) => r.id);
}
}
+344
View File
@@ -0,0 +1,344 @@
import type { PageSettings, PageSettingsPatch } from "$lib/types/api";
import GC from "$lib/global-constants";
// Stored page_settings_json keys differ from the API contract for the meta
// fields: the manage UI writes camelCase (metaPageTitle, metaPageDescription,
// socialPagePreviewImage) while the v4 API exposes snake_case. The mapping
// lives here, at the storage boundary.
interface StoredPageSettings {
incidents?: unknown;
include_maintenances?: unknown;
monitor_status_history_days?: { desktop?: number; mobile?: number };
monitor_layout_style?: string;
metaPageTitle?: string;
metaPageDescription?: string;
socialPagePreviewImage?: string;
[key: string]: unknown;
}
const HISTORY_DAYS_MIN = GC.STATUS_HISTORY_DAYS_MIN;
const HISTORY_DAYS_MAX = GC.STATUS_HISTORY_DAYS_MAX;
export function getDefaultPageSettings(): PageSettings {
return {
incidents: {
enabled: true,
ongoing: { show: true },
resolved: { show: true, max_count: 5, days_in_past: 7 },
},
include_maintenances: {
enabled: true,
ongoing: {
show: true,
past: { show: true, max_count: 5, days_in_past: 7 },
upcoming: { show: true, max_count: 5, days_in_future: 30 },
},
},
monitor_status_history_days: {
desktop: GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP,
mobile: GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE,
},
monitor_layout_style: GC.DEFAULT_MONITOR_LAYOUT_STYLE,
};
}
function parseStored(storedJson: string | null | undefined): StoredPageSettings {
if (!storedJson) return {};
try {
const parsed = JSON.parse(storedJson);
return typeof parsed === "object" && parsed !== null ? (parsed as StoredPageSettings) : {};
} catch {
return {};
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
// Recursively merges patch into base: objects merge key-by-key, everything
// else replaces. Keys absent from the patch — including ones this module does
// not know about — are left untouched.
function deepMerge(base: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) continue;
const current = result[key];
result[key] = isPlainObject(current) && isPlainObject(value) ? deepMerge(current, value) : value;
}
return result;
}
export function mergePageSettings(defaults: PageSettings, partial?: PageSettingsPatch): PageSettings {
if (!partial) {
return defaults;
}
const merged: PageSettings = {
incidents: {
enabled: partial.incidents?.enabled ?? defaults.incidents.enabled,
ongoing: {
show: partial.incidents?.ongoing?.show ?? defaults.incidents.ongoing.show,
},
resolved: {
show: partial.incidents?.resolved?.show ?? defaults.incidents.resolved.show,
max_count: partial.incidents?.resolved?.max_count ?? defaults.incidents.resolved.max_count,
days_in_past: partial.incidents?.resolved?.days_in_past ?? defaults.incidents.resolved.days_in_past,
},
},
include_maintenances: {
enabled: partial.include_maintenances?.enabled ?? defaults.include_maintenances.enabled,
ongoing: {
show: partial.include_maintenances?.ongoing?.show ?? defaults.include_maintenances.ongoing.show,
past: {
show: partial.include_maintenances?.ongoing?.past?.show ?? defaults.include_maintenances.ongoing.past.show,
max_count:
partial.include_maintenances?.ongoing?.past?.max_count ??
defaults.include_maintenances.ongoing.past.max_count,
days_in_past:
partial.include_maintenances?.ongoing?.past?.days_in_past ??
defaults.include_maintenances.ongoing.past.days_in_past,
},
upcoming: {
show:
partial.include_maintenances?.ongoing?.upcoming?.show ??
defaults.include_maintenances.ongoing.upcoming.show,
max_count:
partial.include_maintenances?.ongoing?.upcoming?.max_count ??
defaults.include_maintenances.ongoing.upcoming.max_count,
days_in_future:
partial.include_maintenances?.ongoing?.upcoming?.days_in_future ??
defaults.include_maintenances.ongoing.upcoming.days_in_future,
},
},
},
monitor_status_history_days: {
desktop: partial.monitor_status_history_days?.desktop ?? defaults.monitor_status_history_days.desktop,
mobile: partial.monitor_status_history_days?.mobile ?? defaults.monitor_status_history_days.mobile,
},
monitor_layout_style: partial.monitor_layout_style ?? defaults.monitor_layout_style,
};
const metaPageTitle = partial.meta_page_title ?? defaults.meta_page_title;
const metaPageDescription = partial.meta_page_description ?? defaults.meta_page_description;
const socialPagePreviewImage = partial.social_page_preview_image ?? defaults.social_page_preview_image;
if (metaPageTitle !== undefined) merged.meta_page_title = metaPageTitle;
if (metaPageDescription !== undefined) merged.meta_page_description = metaPageDescription;
if (socialPagePreviewImage !== undefined) merged.social_page_preview_image = socialPagePreviewImage;
return merged;
}
function isValidHistoryDays(value: unknown): boolean {
return Number.isInteger(value) && (value as number) >= HISTORY_DAYS_MIN && (value as number) <= HISTORY_DAYS_MAX;
}
const boolOrUndefined = (value: unknown): boolean | undefined => (typeof value === "boolean" ? value : undefined);
const countOrUndefined = (value: unknown): number | undefined =>
Number.isInteger(value) && (value as number) >= 0 ? (value as number) : undefined;
// Read-side sanitizers: keep only correctly-typed leaves from stored event
// branches so wrong-typed values (e.g. enabled: "yes" from manual edits or
// older versions) never override defaults in API responses
function sanitizeStoredIncidents(value: unknown): PageSettingsPatch["incidents"] {
if (!isPlainObject(value)) return undefined;
const ongoing = isPlainObject(value.ongoing) ? value.ongoing : {};
const resolved = isPlainObject(value.resolved) ? value.resolved : {};
return {
enabled: boolOrUndefined(value.enabled),
ongoing: { show: boolOrUndefined(ongoing.show) },
resolved: {
show: boolOrUndefined(resolved.show),
max_count: countOrUndefined(resolved.max_count),
days_in_past: countOrUndefined(resolved.days_in_past),
},
};
}
function sanitizeStoredMaintenances(value: unknown): PageSettingsPatch["include_maintenances"] {
if (!isPlainObject(value)) return undefined;
const ongoing = isPlainObject(value.ongoing) ? value.ongoing : {};
const past = isPlainObject(ongoing.past) ? ongoing.past : {};
const upcoming = isPlainObject(ongoing.upcoming) ? ongoing.upcoming : {};
return {
enabled: boolOrUndefined(value.enabled),
ongoing: {
show: boolOrUndefined(ongoing.show),
past: {
show: boolOrUndefined(past.show),
max_count: countOrUndefined(past.max_count),
days_in_past: countOrUndefined(past.days_in_past),
},
upcoming: {
show: boolOrUndefined(upcoming.show),
max_count: countOrUndefined(upcoming.max_count),
days_in_future: countOrUndefined(upcoming.days_in_future),
},
},
};
}
function isValidLayoutStyle(value: unknown): value is PageSettings["monitor_layout_style"] {
return (GC.MONITOR_LAYOUT_STYLES as readonly string[]).includes(value as string);
}
/**
* Builds the API view of stored settings: defaults overlaid with stored
* values. Stored values that violate the API contract (unknown layout style,
* out-of-range days — e.g. from manual edits or older versions) are ignored
* so responses stay schema-compliant.
*/
export function toApiPageSettings(storedJson: string | null | undefined): PageSettings {
const stored = parseStored(storedJson);
const storedDays = isPlainObject(stored.monitor_status_history_days) ? stored.monitor_status_history_days : {};
const fromStore: PageSettingsPatch = {
incidents: sanitizeStoredIncidents(stored.incidents),
include_maintenances: sanitizeStoredMaintenances(stored.include_maintenances),
monitor_status_history_days: {
desktop: isValidHistoryDays(storedDays.desktop) ? (storedDays.desktop as number) : undefined,
mobile: isValidHistoryDays(storedDays.mobile) ? (storedDays.mobile as number) : undefined,
},
monitor_layout_style: isValidLayoutStyle(stored.monitor_layout_style) ? stored.monitor_layout_style : undefined,
meta_page_title: typeof stored.metaPageTitle === "string" ? stored.metaPageTitle : undefined,
meta_page_description: typeof stored.metaPageDescription === "string" ? stored.metaPageDescription : undefined,
social_page_preview_image:
typeof stored.socialPagePreviewImage === "string" ? stored.socialPagePreviewImage : undefined,
};
return mergePageSettings(getDefaultPageSettings(), fromStore);
}
/**
* Deep-merges a partial API payload into the stored settings JSON and returns
* the new JSON string. Only keys present in the patch are written; everything
* else in the stored JSON — including nested keys and top-level keys this
* module does not know about — is preserved, so an API write can never wipe
* settings written by other parts of the app, and clients may persist extra
* keys (the schema allows additional properties).
*/
export function applyPageSettingsPatch(
storedJson: string | null | undefined,
patch: PageSettingsPatch | undefined,
): string {
const stored = parseStored(storedJson);
if (!patch) {
return JSON.stringify(stored);
}
// Map the API's snake_case meta fields to their stored camelCase keys; all
// other keys are stored under their API names
const { meta_page_title, meta_page_description, social_page_preview_image, ...rest } = patch;
const mappedPatch: Record<string, unknown> = { ...rest };
if (meta_page_title !== undefined) mappedPatch.metaPageTitle = meta_page_title;
if (meta_page_description !== undefined) mappedPatch.metaPageDescription = meta_page_description;
if (social_page_preview_image !== undefined) mappedPatch.socialPagePreviewImage = social_page_preview_image;
return JSON.stringify(deepMerge(stored, mappedPatch));
}
/**
* Validates a partial page_settings payload from the API. Returns an error
* message, or null when valid. Bounds mirror the manage UI (history days
* 1-365, layout style one of the four shipped styles).
*/
export function validatePageSettings(partial: unknown): string | null {
if (partial === undefined) return null;
if (typeof partial !== "object" || partial === null || Array.isArray(partial)) {
return "page_settings must be an object";
}
const settings = partial as PageSettingsPatch;
// The event display branches and their known sub-objects must be objects;
// anything else would be deep-merged into storage as-is
if (settings.incidents !== undefined) {
if (!isPlainObject(settings.incidents)) {
return "incidents must be an object";
}
for (const key of ["ongoing", "resolved"] as const) {
if (settings.incidents[key] !== undefined && !isPlainObject(settings.incidents[key])) {
return `incidents.${key} must be an object`;
}
}
}
if (settings.include_maintenances !== undefined) {
if (!isPlainObject(settings.include_maintenances)) {
return "include_maintenances must be an object";
}
const ongoing = settings.include_maintenances.ongoing;
if (ongoing !== undefined) {
if (!isPlainObject(ongoing)) {
return "include_maintenances.ongoing must be an object";
}
for (const key of ["past", "upcoming"] as const) {
if (ongoing[key] !== undefined && !isPlainObject(ongoing[key])) {
return `include_maintenances.ongoing.${key} must be an object`;
}
}
}
}
// Leaf types inside the event branches must match the schema
const leafChecks: Array<{ path: readonly string[]; kind: "boolean" | "count" }> = [
{ path: ["incidents", "enabled"], kind: "boolean" },
{ path: ["incidents", "ongoing", "show"], kind: "boolean" },
{ path: ["incidents", "resolved", "show"], kind: "boolean" },
{ path: ["incidents", "resolved", "max_count"], kind: "count" },
{ path: ["incidents", "resolved", "days_in_past"], kind: "count" },
{ path: ["include_maintenances", "enabled"], kind: "boolean" },
{ path: ["include_maintenances", "ongoing", "show"], kind: "boolean" },
{ path: ["include_maintenances", "ongoing", "past", "show"], kind: "boolean" },
{ path: ["include_maintenances", "ongoing", "past", "max_count"], kind: "count" },
{ path: ["include_maintenances", "ongoing", "past", "days_in_past"], kind: "count" },
{ path: ["include_maintenances", "ongoing", "upcoming", "show"], kind: "boolean" },
{ path: ["include_maintenances", "ongoing", "upcoming", "max_count"], kind: "count" },
{ path: ["include_maintenances", "ongoing", "upcoming", "days_in_future"], kind: "count" },
];
for (const { path, kind } of leafChecks) {
let value: unknown = settings;
for (const key of path) {
if (!isPlainObject(value)) {
value = undefined;
break;
}
value = value[key];
}
if (value === undefined) continue;
if (kind === "boolean" && typeof value !== "boolean") {
return `${path.join(".")} must be a boolean`;
}
if (kind === "count" && !(Number.isInteger(value) && (value as number) >= 0)) {
return `${path.join(".")} must be a non-negative integer`;
}
}
if (settings.monitor_layout_style !== undefined) {
if (!GC.MONITOR_LAYOUT_STYLES.includes(settings.monitor_layout_style)) {
return `monitor_layout_style must be one of: ${GC.MONITOR_LAYOUT_STYLES.join(", ")}`;
}
}
if (settings.monitor_status_history_days !== undefined) {
const days = settings.monitor_status_history_days;
if (typeof days !== "object" || days === null || Array.isArray(days)) {
return "monitor_status_history_days must be an object";
}
for (const key of ["desktop", "mobile"] as const) {
const value = days[key];
if (value !== undefined) {
if (!Number.isInteger(value) || value < HISTORY_DAYS_MIN || value > HISTORY_DAYS_MAX) {
return `monitor_status_history_days.${key} must be an integer between ${HISTORY_DAYS_MIN} and ${HISTORY_DAYS_MAX}`;
}
}
}
}
for (const key of ["meta_page_title", "meta_page_description", "social_page_preview_image"] as const) {
const value = settings[key];
if (value !== undefined && typeof value !== "string") {
return `${key} must be a string`;
}
}
return null;
}
-2
View File
@@ -24,7 +24,6 @@ import {
UpdateMonitorAlertV2Status,
} from "../controllers/monitorAlertConfigController.js";
import type { IncidentInput } from "../controllers/incidentController.js";
import { InsertNewAlert } from "../controllers/controller.js";
import { GetMonitorAlertsV2 } from "../controllers/monitorAlertConfigController.js";
import db from "../db/db.js";
import { getUnixTime, differenceInSeconds } from "date-fns";
@@ -34,7 +33,6 @@ import sendEmail from "../notification/email_notification.js";
import sendWebhook from "$lib/server/notification/webhook_notification.js";
import sendSlack from "$lib/server/notification/slack_notification.js";
import sendDiscord from "$lib/server/notification/discord_notification.js";
import serverResolver from "../resolver.js";
import type { SiteDataForNotification, SubscriptionVariableMap } from "../notification/types.js";
import mdToHTML from "../../marked.js";
+57 -7
View File
@@ -7,6 +7,7 @@ import { GetMinuteStartNowTimestampUTC } from "../tool.js";
import db from "../db/db.js";
import monitorResponseQueue from "./monitorResponseQueue";
import GC from "../../global-constants.js";
import { resolveConfirmedStatus } from "../services/confirmationThreshold.js";
let monitorExecuteQueue: Queue | null = null;
let worker: Worker | null = null;
@@ -25,8 +26,13 @@ const getQueue = () => {
return monitorExecuteQueue;
};
async function manualMaintenance(monitor: MonitorRecordTyped): Promise<{ [timestamp: number]: MonitoringResult }> {
let startTs = GetMinuteStartNowTimestampUTC();
async function manualMaintenance(
monitor: MonitorRecordTyped,
ts?: number,
): Promise<{ [timestamp: number]: MonitoringResult }> {
// Key by the job's `ts` (already a minute-start) so the overlay aligns with the realtime/default
// rows and the freeze gate; fall back to "now" only when called without a ts.
let startTs = ts !== undefined ? ts : GetMinuteStartNowTimestampUTC();
let maintenanceArr = await db.getMaintenancesByMonitorTagRealtime(monitor.tag, startTs);
let impact = "";
@@ -66,8 +72,13 @@ async function manualMaintenance(monitor: MonitorRecordTyped): Promise<{ [timest
return manualData;
}
async function manualIncident(monitor: MonitorRecordTyped): Promise<{ [timestamp: number]: MonitoringResult }> {
let startTs = GetMinuteStartNowTimestampUTC();
async function manualIncident(
monitor: MonitorRecordTyped,
ts?: number,
): Promise<{ [timestamp: number]: MonitoringResult }> {
// Key by the job's `ts` (already a minute-start) so the overlay aligns with the realtime/default
// rows and the freeze gate; fall back to "now" only when called without a ts.
let startTs = ts !== undefined ? ts : GetMinuteStartNowTimestampUTC();
let incidentArr = await db.getIncidentsByMonitorTagRealtime(monitor.tag, startTs);
let impact = "";
@@ -115,13 +126,43 @@ const addWorker = () => {
const exeResult = await serviceClient.execute(ts);
// Fetch overlays AFTER the check runs so a maintenance/incident that starts mid-check is still
// detected, and key them by the job's `ts` so the freeze gate (incidentData[ts]) is
// timestamp-safe even if the job is delayed or retried (#756).
let incidentData: MonitoringResultTS = await manualIncident(monitor, ts);
let maintenanceData: MonitoringResultTS = await manualMaintenance(monitor, ts);
let realtimeData: MonitoringResultTS = {};
if (exeResult) {
realtimeData[ts] = exeResult;
}
// Always record what the check actually observed (forensics + grace counting).
realtimeData[ts].raw_status = exeResult.status;
let incidentData: MonitoringResultTS = await manualIncident(monitor);
let maintenanceData: MonitoringResultTS = await manualMaintenance(monitor);
// Confirmation Threshold damping (#712): scheduled checks only.
const threshold = Number(monitor.confirmation_threshold ?? 1);
const isScheduledCheck = ([GC.REALTIME, GC.TIMEOUT, GC.ERROR] as string[]).includes(exeResult.type);
// Confirmation Threshold freezes while an incident/maintenance overlay is active for this
// minute: the overlay wins display and the count must neither advance nor backfill (#756).
const overlayActive = incidentData[ts] !== undefined || maintenanceData[ts] !== undefined;
if (threshold > 1 && isScheduledCheck && !overlayActive) {
const resolved = await resolveConfirmedStatus({
monitor_tag: monitor.tag,
ts,
rawStatus: exeResult.status,
threshold,
});
realtimeData[ts].status = resolved.status;
if (resolved.pendingHold) {
// Hold the confirmed side for display, but PRESERVE the observed latency and error text —
// no diagnostic info is discarded. Tag the row to record that the status is being held
// during the grace period; on confirmation the backfill appends the confirmation note (#756).
const observedError = realtimeData[ts].error_message;
realtimeData[ts].error_message = observedError
? `${observedError} | Status held during grace period`
: "Status held during grace period";
}
}
}
let defaultData: MonitoringResultTS = {};
let mergedData: MonitoringResultTS = {};
@@ -186,6 +227,15 @@ const addWorker = () => {
}
}
// Preserve raw_status from realtime monitoring (overlays replace the merged object wholesale,
// so re-attach the observed value the resolver recorded).
for (const timestamp in mergedData) {
const ts = parseInt(timestamp);
if (realtimeData[ts]?.raw_status !== undefined) {
mergedData[ts].raw_status = realtimeData[ts].raw_status;
}
}
for (const timestamp in mergedData) {
monitorResponseQueue.push(monitor.tag, parseInt(timestamp), mergedData[timestamp]);
}
@@ -17,6 +17,7 @@ interface JobData {
monitorTag: string;
ts: number;
error_message?: string | null;
raw_status?: string | null;
}
const getQueue = () => {
@@ -30,7 +31,7 @@ const addWorker = () => {
if (worker) return worker;
worker = q.createWorker(getQueue(), async (job: Job): Promise<MonitoringData | null> => {
const { monitorTag, ts, status, latency, type, error_message } = job.data as JobData;
const { monitorTag, ts, status, latency, type, error_message, raw_status } = job.data as JobData;
const dbRes = await InsertMonitoringData({
monitor_tag: monitorTag,
@@ -39,6 +40,7 @@ const addWorker = () => {
latency: latency,
type: type,
error_message: error_message,
raw_status: raw_status,
});
if (!dbRes) {
+1 -1
View File
@@ -47,7 +47,7 @@ const getRetentionPolicy = async (): Promise<DataRetentionPolicy> => {
const runDailyCleanup = async (): Promise<DailyCleanupResult> => {
const policy = await getRetentionPolicy();
const retentionDays = Math.max(1, Math.floor(policy.retentionDays || defaultPolicy.retentionDays));
console.log(`Data retention policy: enabled=${policy.enabled}, retentionDays=${retentionDays}`);
if (!policy.enabled) {
return {
skipped: true,
@@ -0,0 +1,107 @@
import GC from "../../global-constants.js";
import db from "../db/db.js";
export type Side = "UP" | "DOWN" | null;
/** Binary side: UP is healthy; DOWN/DEGRADED are unhealthy; everything else (NO_DATA) is neutral. */
export function sideOf(status: string | null | undefined): Side {
if (status === GC.UP) return "UP";
if (status === GC.DOWN || status === GC.DEGRADED) return "DOWN";
return null;
}
export interface ResolveInput {
monitor_tag: string;
ts: number;
rawStatus: string;
threshold: number; // >= 2 to damp; 1 behaves as off (any opposite observation confirms instantly)
}
export interface ResolveResult {
/** Effective status to commit for this minute. */
status: string;
/**
* True while the displayed status is the held confirmed side (pending confirmation) rather
* than the observed side. The caller keeps the observed latency and error text (tagging the
* row) — it does not blank them.
*/
pendingHold: boolean;
}
/** Minimal data access this resolver needs; defaults to the db singleton, injectable for tests. */
export interface ConfirmationDeps {
getRecentSamplesForConfirmation: typeof db.getRecentSamplesForConfirmation;
getLastObservedStatus: typeof db.getLastObservedStatus;
backfillConfirmedStatus: typeof db.backfillConfirmedStatus;
}
// Overlay sample types that freeze the count (must match OVERLAY_TYPES in the monitoring repository).
const OVERLAY_TYPES: string[] = [GC.INCIDENT, GC.MAINTENANCE];
// Extra lookback rows beyond the threshold for the pending-run scan: headroom for interleaved
// overlay rows. NO_DATA observations are excluded by the query, and the anchor is fetched
// separately, so the buffer need not scale with NO_DATA density or overlay-window length.
const LOOKBACK_BUFFER = 10;
/**
* Resolve the status to commit for one scheduled-check observation under Confirmation
* Threshold damping (issue #712).
*
* IMPORTANT ordering contract: this MUST be called BEFORE the current row at `ts` is
* persisted, and only when no incident/maintenance overlay is active for `ts` (the caller
* gates that — overlays freeze the count). It anchors on the most recent stored observation
* (timestamp < ts).
*
* Neutral (`NO_DATA`) observations are excluded from the count (neither advance nor reset).
* Overlay rows act as a hard boundary: the pending run never crosses one, so monitoring
* resumes with a fresh count after an incident/maintenance window.
*/
export async function resolveConfirmedStatus(
input: ResolveInput,
deps: ConfirmationDeps = db,
): Promise<ResolveResult> {
const { monitor_tag, ts, rawStatus, threshold } = input;
const observedSide = sideOf(rawStatus);
// Neutral observation (NO_DATA): pass through untouched — written honestly as grey.
if (observedSide === null) {
return { status: rawStatus, pendingHold: false };
}
// Anchor = the side currently shown = the most recent real observation's committed status.
// Fetched with a dedicated query (not from the windowed scan) so a long incident/maintenance
// window can never push the anchor out of range and bypass damping.
const confirmedStatus = await deps.getLastObservedStatus(monitor_tag, ts);
const confirmedSide = sideOf(confirmedStatus);
// Cold start / no usable anchor / same side: commit immediately.
if (confirmedStatus === null || confirmedSide === null || observedSide === confirmedSide) {
return { status: rawStatus, pendingHold: false };
}
// Opposite side: count the trailing pending run (incl. current = 1), stopping at an overlay
// boundary (freeze) or at a confirmed-side observation. NO_DATA rows are excluded by the query.
const recent = await deps.getRecentSamplesForConfirmation(monitor_tag, ts, threshold + LOOKBACK_BUFFER);
let pendingRun = 1;
const pendingTimestamps: number[] = [];
for (const row of recent) {
if (row.type !== null && OVERLAY_TYPES.includes(row.type)) break; // freeze boundary
const rawSide = sideOf(row.raw_status);
if (rawSide === null) continue; // NO_DATA: neutral (excluded by the query; this is a defensive guard)
if (rawSide === observedSide && sideOf(row.status) === confirmedSide) {
pendingRun++;
pendingTimestamps.push(row.timestamp);
} else {
break; // hit a confirmed-side observation (the anchor)
}
}
if (pendingRun >= threshold) {
// Unhealthy confirm passes the count so the backfill can write a per-row severity note
// ("Down"/"Degraded confirmed after N…"); recovery passes null (clears the held error text).
await deps.backfillConfirmedStatus(monitor_tag, pendingTimestamps, observedSide === "DOWN" ? threshold : null);
return { status: rawStatus, pendingHold: false };
}
// Still pending: hold the confirmed side.
return { status: confirmedStatus, pendingHold: true };
}
+20 -2
View File
@@ -1,5 +1,6 @@
import DNSResolver from "../dns.js";
import { GetLastKnownStatus } from "../controllers/monitorsController.js";
import type { NoneMonitor, MonitoringResult } from "../types/monitor.js";
import GC from "../../global-constants.js";
class NoneCall {
monitor: NoneMonitor;
@@ -8,7 +9,24 @@ class NoneCall {
this.monitor = monitor;
}
async execute(): Promise<null> {
async execute(): Promise<MonitoringResult | null> {
let overrideWithLastKnownStatus = this.monitor.type_data.overrideWithLastKnownStatus;
if (!!overrideWithLastKnownStatus) {
//get the last known status
let lastKnownStatus = await GetLastKnownStatus(this.monitor.tag);
if (
!!lastKnownStatus &&
!!lastKnownStatus.status &&
!!lastKnownStatus.type &&
lastKnownStatus.type === GC.MANUAL
) {
return {
status: lastKnownStatus.status,
latency: lastKnownStatus.latency || 0,
type: lastKnownStatus.type,
};
}
}
return null;
}
}
+35 -4
View File
@@ -1,5 +1,6 @@
// Server-only database types (based on migrations schema)
import type { Knex } from "knex";
import type { PageMonitorLayoutStyle } from "$lib/types/api";
// ============ monitoring_data table ============
export interface MonitoringData {
@@ -9,6 +10,7 @@ export interface MonitoringData {
latency: number | null;
type: string | null;
error_message?: string | null;
raw_status?: string | null;
}
export interface MonitoringDataInsert {
@@ -18,6 +20,7 @@ export interface MonitoringDataInsert {
latency: number;
type: string;
error_message?: string | null;
raw_status?: string | null;
}
export interface AggregatedMonitoringData {
@@ -77,6 +80,7 @@ export interface MonitorRecord {
external_url?: string | null;
day_degraded_minimum_count?: number | null;
day_down_minimum_count?: number | null;
confirmation_threshold?: number | null;
include_degraded_in_downtime?: string;
is_hidden: string;
monitor_settings_json: string | null;
@@ -134,6 +138,7 @@ export interface MonitorRecordTyped {
type_data: Record<string, unknown> | null;
day_degraded_minimum_count?: number | null;
day_down_minimum_count?: number | null;
confirmation_threshold?: number | null;
include_degraded_in_downtime?: string;
is_hidden: string;
monitor_settings_json: MonitorSettings | null;
@@ -157,6 +162,7 @@ export interface MonitorRecordInsert {
type_data?: string | null;
day_degraded_minimum_count?: number | null;
day_down_minimum_count?: number | null;
confirmation_threshold?: number | null;
include_degraded_in_downtime?: string;
is_hidden?: string;
monitor_settings_json?: string | null;
@@ -236,7 +242,7 @@ export interface UserRecord {
password_hash: string;
is_active: number;
is_verified: number;
role: string;
role_ids: string[]; // Array of role IDs
created_at: Date;
updated_at: Date;
}
@@ -245,9 +251,9 @@ export interface UserRecordInsert {
email: string;
name: string;
password_hash: string;
role_ids: string[]; // Array of role IDs
is_active?: number;
is_verified?: number;
role?: string;
is_owner?: string;
}
@@ -258,7 +264,7 @@ export interface UserRecordPublic {
is_active: number;
is_verified: number;
is_owner: string;
role: string;
role_ids: string[];
created_at: Date;
updated_at: Date;
}
@@ -266,6 +272,31 @@ export interface UserRecordDashboard extends UserRecordPublic {
has_password: boolean;
}
// ============ roles table ============
export interface RoleRecord {
id: string;
role_name: string;
readonly: number;
status: string;
created_at: Date;
updated_at: Date;
}
export interface RolePermissionRecord {
roles_id: string;
permissions_id: string;
status: string;
created_at: Date;
updated_at: Date;
}
export interface UserRoleRecord {
roles_id: string;
users_id: number;
created_at: Date;
updated_at: Date;
}
// ============ api_keys table ============
export interface ApiKeyRecord {
id: number;
@@ -443,7 +474,7 @@ export interface PageSettingsType {
desktop: number;
mobile: number;
};
monitor_layout_style: "default-list" | "default-grid" | "compact-list" | "compact-grid";
monitor_layout_style: PageMonitorLayoutStyle;
metaPageTitle?: string;
metaPageDescription?: string;
socialPagePreviewImage?: string;
+4 -1
View File
@@ -8,13 +8,16 @@ export interface MonitoringResult {
latency: number;
type: string;
error_message?: string;
raw_status?: string;
}
export interface MonitoringResultTS {
[timestamp: number]: MonitoringResult;
}
export interface NoneMonitorTypeData {}
export interface NoneMonitorTypeData {
overrideWithLastKnownStatus: boolean;
}
export interface ApiMonitorTypeData {
url: string;
body?: string;
+68 -4
View File
@@ -3,6 +3,7 @@
import type { MonitorRecordTyped } from "$lib/server/types/db";
import type { MonitorPublicView } from "$lib/types/monitor";
import type GC from "$lib/global-constants";
export type ApiError = {
code: string;
@@ -91,6 +92,7 @@ export interface MonitorResponse {
type_data: MonitorTypeData | null;
include_degraded_in_downtime: string;
is_hidden: string;
confirmation_threshold?: number | null;
monitor_settings_json: MonitorSettings | null;
created_at: string;
updated_at: string;
@@ -117,6 +119,7 @@ export interface CreateMonitorRequest {
type_data?: MonitorTypeData | null;
include_degraded_in_downtime?: string;
is_hidden?: string;
confirmation_threshold?: number | null;
monitor_settings_json?: MonitorSettings | null;
}
@@ -136,6 +139,7 @@ export interface UpdateMonitorRequest {
type_data?: MonitorTypeData | null;
include_degraded_in_downtime?: string;
is_hidden?: string;
confirmation_threshold?: number | null;
monitor_settings_json?: MonitorSettings | null;
}
@@ -143,6 +147,10 @@ export interface UpdateMonitorResponse {
monitor: MonitorRecordTyped;
}
export interface DeleteMonitorResponse {
message: string;
}
// Monitoring Data API types
export interface MonitoringDataPoint {
monitor_tag: string;
@@ -198,6 +206,8 @@ export interface IncidentResponse {
monitors: IncidentMonitor[];
created_at: string;
updated_at: string;
/** Absolute URL of the public incident page */
url: string;
}
export interface IncidentDetailResponse extends IncidentResponse {
@@ -298,6 +308,13 @@ export interface MaintenanceResponse {
monitors: MaintenanceMonitor[];
created_at: string;
updated_at: string;
/**
* Absolute URL of the public page for this maintenance.
* Note: the public /maintenances/<id> route is keyed by maintenance EVENT id
* by default, so this URL carries ?type=maintenance. Link via this field,
* never by concatenating `id` onto a path. See docs/adr/0002.
*/
url: string;
}
export interface GetMaintenancesListResponse {
@@ -344,6 +361,8 @@ export interface MaintenanceEventResponse {
status: "SCHEDULED" | "READY" | "ONGOING" | "COMPLETED" | "CANCELLED";
created_at: string;
updated_at: string;
/** Absolute URL of the public page for this maintenance event */
url: string;
}
export interface GetMaintenanceEventsListResponse {
@@ -357,8 +376,15 @@ export interface GetMaintenanceEventResponse {
}
export interface UpdateMaintenanceEventRequest {
start_date_time: number;
end_date_time: number;
/** Window edit mode: both times required. Cannot be combined with `status`. */
start_date_time?: number;
end_date_time?: number;
/**
* Transition mode: COMPLETED (from ONGOING) or CANCELLED (from SCHEDULED/READY/ONGOING).
* Cannot be combined with time fields. Transitioning an ONGOING event moves its
* end_date_time to the moment of the transition.
*/
status?: "COMPLETED" | "CANCELLED";
}
export interface UpdateMaintenanceEventResponse {
@@ -386,6 +412,8 @@ export interface MaintenanceEventDetailResponse {
maintenance_rrule: string;
maintenance_duration_seconds: number;
monitors: MaintenanceMonitor[];
/** Absolute URL of the public page for this maintenance event */
url: string;
}
export interface GetMaintenanceEventsDetailListResponse {
@@ -436,9 +464,40 @@ export interface PageSettingsMaintenances {
ongoing: PageSettingsMaintenancesOngoing;
}
export interface PageSettingsHistoryDays {
desktop: number;
mobile: number;
}
/**
* Recursive partial, so patch payloads can update any subset of nested fields.
* Recursion applies only to plain object maps; arrays and other special object
* types pass through unchanged.
*/
export type DeepPartial<T> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[K in keyof T]?: T[K] extends (infer U)[] ? U[] : T[K] extends Record<string, any> ? DeepPartial<T[K]> : T[K];
};
/**
* Patch payload for page_settings: any subset of nested fields. Provided
* fields are deep-merged into the current settings; omitted fields are left
* untouched.
*/
export type PageSettingsPatch = DeepPartial<PageSettings>;
export type PageMonitorLayoutStyle = (typeof GC.MONITOR_LAYOUT_STYLES)[number];
export interface PageSettings {
incidents: PageSettingsIncidents;
include_maintenances: PageSettingsMaintenances;
/** Days of status history shown on the page, per device class (1-365). */
monitor_status_history_days: PageSettingsHistoryDays;
monitor_layout_style: PageMonitorLayoutStyle;
/** Per-page meta/social overrides; stored as camelCase keys internally. */
meta_page_title?: string;
meta_page_description?: string;
social_page_preview_image?: string;
}
export interface PageMonitorResponse {
@@ -447,6 +506,11 @@ export interface PageMonitorResponse {
export interface PageResponse {
id: number;
/**
* The page's path segment. The home page (stored path is empty) renders as
* the addressable token `~home`; its public URL is the site root.
* See docs/adr/0004-home-page-api-token.md.
*/
page_path: string;
page_title: string;
page_header: string;
@@ -472,7 +536,7 @@ export interface CreatePageRequest {
page_header: string;
page_subheader?: string | null;
page_logo?: string | null;
page_settings?: Partial<PageSettings>;
page_settings?: PageSettingsPatch;
monitors?: string[];
}
@@ -486,7 +550,7 @@ export interface UpdatePageRequest {
page_header?: string;
page_subheader?: string | null;
page_logo?: string | null;
page_settings?: Partial<PageSettings>;
page_settings?: PageSettingsPatch;
monitors?: string[];
}
+27 -8
View File
@@ -4,15 +4,9 @@
import { ModeWatcher } from "mode-watcher";
import { resolve } from "$app/paths";
import { Toaster } from "$lib/components/ui/sonner/index.js";
let base = resolve("/");
import clientResolver from "$lib/client/resolver.js";
let { children, data } = $props();
const colorUp = $derived(data.siteStatusColors.UP);
const colorDegraded = $derived(data.siteStatusColors.DEGRADED);
const colorDown = $derived(data.siteStatusColors.DOWN);
const colorMaintenance = $derived(data.siteStatusColors.MAINTENANCE);
import KenerNav from "$lib/components/KenerNav.svelte";
</script>
@@ -21,7 +15,32 @@
<svelte:head>
<meta name="robots" content="noindex, nofollow" />
{@html `<style>:root{--up:${colorUp};--degraded:${colorDegraded};--down:${colorDown};--maintenance:${colorMaintenance};}</style>`}
<link rel="icon" href={data.favicon} />
{#if data.font?.cssSrc}
<link rel="stylesheet" href={data.font.cssSrc} />
{/if}
{@html `
<style id="dynamic-styles">
body {
--up: ${data.siteStatusColors.UP};
--degraded: ${data.siteStatusColors.DEGRADED};
--down: ${data.siteStatusColors.DOWN};
--maintenance: ${data.siteStatusColors.MAINTENANCE};
--accent: ${data.siteStatusColors.ACCENT || "#f4f4f5"};
--accent-foreground: ${data.siteStatusColors.ACCENT_FOREGROUND || data.siteStatusColors.ACCENT || "#e96e2d"};
${data.font?.family ? `--font-family:'${data.font.family}', sans-serif;` : ""}
}
:is(.dark) body {
--up: ${data.siteStatusColorsDark.UP};
--degraded: ${data.siteStatusColorsDark.DEGRADED};
--down: ${data.siteStatusColorsDark.DOWN};
--maintenance: ${data.siteStatusColorsDark.MAINTENANCE};
--accent: ${data.siteStatusColorsDark.ACCENT || "#27272a"};
--accent-foreground: ${data.siteStatusColorsDark.ACCENT_FOREGROUND || data.siteStatusColorsDark.ACCENT || "#e96e2d"};
}
${data.customCSS || ""}
</style>`}
<script src={clientResolver(resolve, "/capture.js")}></script>
</svelte:head>
<main>
<!-- Nav -->
@@ -59,6 +59,13 @@ export const actions: Actions = {
});
}
if (!userDB.role_ids || userDB.role_ids.length === 0) {
return fail(403, {
error: "Your account has no active roles assigned. Please contact an administrator.",
values: { email },
});
}
const token = await GenerateToken(userDB);
const cookieConfig = CookieConfig();
cookies.set(cookieConfig.name, token, {
@@ -10,6 +10,8 @@ import type {
} from "$lib/types/api";
import GC from "$lib/global-constants";
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
import serverResolver from "$lib/server/resolver";
function formatDateToISO(date: Date | string): string {
if (date instanceof Date) {
@@ -56,6 +58,7 @@ export const GET: RequestHandler = async ({ url }) => {
}
// Build response with monitors for each incident
const siteUrl = await GetSiteURL();
const incidents: IncidentResponse[] = [];
for (const incident of rawIncidents) {
const monitors = await db.getIncidentMonitorsByIncidentID(incident.id);
@@ -72,6 +75,7 @@ export const GET: RequestHandler = async ({ url }) => {
})),
created_at: formatDateToISO(incident.created_at),
updated_at: formatDateToISO(incident.updated_at),
url: siteUrl + serverResolver(`/incidents/${incident.id}`),
});
}
@@ -207,6 +211,7 @@ export const POST: RequestHandler = async ({ request }) => {
})),
created_at: formatDateToISO(createdIncident.created_at),
updated_at: formatDateToISO(createdIncident.updated_at),
url: (await GetSiteURL()) + serverResolver(`/incidents/${createdIncident.id}`),
},
};
@@ -9,6 +9,8 @@ import type {
BadRequestResponse,
} from "$lib/types/api";
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
import serverResolver from "$lib/server/resolver";
function formatDateToISO(date: Date | string): string {
if (date instanceof Date) {
@@ -43,6 +45,7 @@ async function buildIncidentResponse(incidentId: number): Promise<IncidentDetail
})),
created_at: formatDateToISO(incident.created_at),
updated_at: formatDateToISO(incident.updated_at),
url: (await GetSiteURL()) + serverResolver(`/incidents/${incident.id}`),
};
}
@@ -14,6 +14,8 @@ import {
GenerateMaintenanceEvents,
isOneTimeRrule,
} from "$lib/server/controllers/maintenanceController";
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
import serverResolver from "$lib/server/resolver";
import { rrulestr } from "rrule";
function formatDateToISO(date: Date | string): string {
@@ -67,6 +69,7 @@ export const GET: RequestHandler = async ({ url }) => {
}
// Build response with monitors for each maintenance
const siteUrl = await GetSiteURL();
const maintenances: MaintenanceResponse[] = [];
for (const maintenance of rawMaintenances) {
const monitors = await db.getMaintenanceMonitors(maintenance.id);
@@ -84,6 +87,7 @@ export const GET: RequestHandler = async ({ url }) => {
})),
created_at: formatDateToISO(maintenance.created_at),
updated_at: formatDateToISO(maintenance.updated_at),
url: siteUrl + serverResolver(`/maintenances/${maintenance.id}?type=maintenance`),
});
}
@@ -267,6 +271,7 @@ export const POST: RequestHandler = async ({ request }) => {
})),
created_at: formatDateToISO(maintenance.created_at),
updated_at: formatDateToISO(maintenance.updated_at),
url: (await GetSiteURL()) + serverResolver(`/maintenances/${maintenance.id}?type=maintenance`),
};
const response: CreateMaintenanceResponse = {
@@ -10,6 +10,8 @@ import type {
} from "$lib/types/api";
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
import { GenerateMaintenanceEvents, isOneTimeRrule } from "$lib/server/controllers/maintenanceController";
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
import serverResolver from "$lib/server/resolver";
import { rrulestr } from "rrule";
function formatDateToISO(date: Date | string): string {
@@ -55,6 +57,7 @@ async function buildMaintenanceResponse(maintenanceId: number): Promise<Maintena
})),
created_at: formatDateToISO(maintenance.created_at),
updated_at: formatDateToISO(maintenance.updated_at),
url: (await GetSiteURL()) + serverResolver(`/maintenances/${maintenance.id}?type=maintenance`),
};
}
@@ -1,6 +1,8 @@
import { json, type RequestHandler } from "@sveltejs/kit";
import db from "$lib/server/db/db";
import type { GetMaintenanceEventsListResponse, MaintenanceEventResponse } from "$lib/types/api";
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
import serverResolver from "$lib/server/resolver";
function formatDateToISO(date: Date | string): string {
if (date instanceof Date) {
@@ -31,14 +33,16 @@ export const GET: RequestHandler = async ({ locals, url }) => {
const paginatedEvents = allEvents.slice(offset, offset + limit);
// Build response
const siteUrl = await GetSiteURL();
const events: MaintenanceEventResponse[] = paginatedEvents.map((event) => ({
id: event.id,
maintenance_id: event.maintenance_id,
start_date_time: event.start_date_time,
end_date_time: event.end_date_time,
status: event.status as "SCHEDULED" | "ONGOING" | "COMPLETED" | "CANCELLED",
status: event.status as MaintenanceEventResponse["status"],
created_at: formatDateToISO(event.created_at),
updated_at: formatDateToISO(event.updated_at),
url: siteUrl + serverResolver(`/maintenances/${event.id}`),
}));
const response: GetMaintenanceEventsListResponse = {
@@ -10,6 +10,10 @@ import type {
BadRequestResponse,
} from "$lib/types/api";
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
import { UpdateMaintenanceEventStatus } from "$lib/server/controllers/maintenanceController";
import GC from "$lib/global-constants";
import serverResolver from "$lib/server/resolver";
function formatDateToISO(date: Date | string): string {
if (date instanceof Date) {
@@ -20,7 +24,7 @@ function formatDateToISO(date: Date | string): string {
return parsed.toISOString();
}
function buildEventResponse(event: {
async function buildEventResponse(event: {
id: number;
maintenance_id: number;
start_date_time: number;
@@ -28,15 +32,16 @@ function buildEventResponse(event: {
status: string;
created_at: Date | string;
updated_at: Date | string;
}): MaintenanceEventResponse {
}): Promise<MaintenanceEventResponse> {
return {
id: event.id,
maintenance_id: event.maintenance_id,
start_date_time: event.start_date_time,
end_date_time: event.end_date_time,
status: event.status as "SCHEDULED" | "ONGOING" | "COMPLETED" | "CANCELLED",
status: event.status as MaintenanceEventResponse["status"],
created_at: formatDateToISO(event.created_at),
updated_at: formatDateToISO(event.updated_at),
url: (await GetSiteURL()) + serverResolver(`/maintenances/${event.id}`),
};
}
@@ -67,7 +72,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
}
const response: GetMaintenanceEventResponse = {
event: buildEventResponse(event),
event: await buildEventResponse(event),
};
return json(response);
@@ -113,7 +118,46 @@ export const PATCH: RequestHandler = async ({ locals, params, request }) => {
return json(errorResponse, { status: 400 });
}
// Validate required fields - both are required for event update
// Transition mode: `status` alone, mutually exclusive with window edits
if (body.status !== undefined) {
if (body.start_date_time !== undefined || body.end_date_time !== undefined) {
const errorResponse: BadRequestResponse = {
error: {
code: "BAD_REQUEST",
message: "Cannot update status and start/end times in the same request",
},
};
return json(errorResponse, { status: 400 });
}
if (body.status !== GC.COMPLETED && body.status !== GC.CANCELLED) {
const errorResponse: BadRequestResponse = {
error: {
code: "BAD_REQUEST",
message: `status must be ${GC.COMPLETED} or ${GC.CANCELLED}`,
},
};
return json(errorResponse, { status: 400 });
}
try {
const updatedEvent = await UpdateMaintenanceEventStatus(eventId, body.status);
const response: UpdateMaintenanceEventResponse = {
event: await buildEventResponse(updatedEvent),
};
return json(response);
} catch (err) {
const errorResponse: BadRequestResponse = {
error: {
code: "BAD_REQUEST",
message: err instanceof Error ? err.message : "Failed to update event status",
},
};
return json(errorResponse, { status: 400 });
}
}
// Window edit mode: both times required
if (body.start_date_time === undefined || body.start_date_time === null) {
const errorResponse: BadRequestResponse = {
error: {
@@ -182,7 +226,7 @@ export const PATCH: RequestHandler = async ({ locals, params, request }) => {
}
const response: UpdateMaintenanceEventResponse = {
event: buildEventResponse(updatedEvent),
event: await buildEventResponse(updatedEvent),
};
return json(response);
@@ -6,6 +6,8 @@ import type {
MaintenanceMonitor,
} from "$lib/types/api";
import { GetNowTimestampUTC, GetMinuteStartTimestampUTC } from "$lib/server/tool";
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
import serverResolver from "$lib/server/resolver";
const VALID_EVENT_STATUSES = ["SCHEDULED", "ONGOING", "COMPLETED", "CANCELLED", "READY"];
@@ -70,6 +72,7 @@ export const GET: RequestHandler = async ({ url }) => {
});
// For each event, get the monitors for that maintenance
const siteUrl = await GetSiteURL();
const events: MaintenanceEventDetailResponse[] = [];
for (const event of rawEvents) {
const monitors = await db.getMaintenanceMonitors(event.maintenance_id);
@@ -83,13 +86,14 @@ export const GET: RequestHandler = async ({ url }) => {
event_id: event.event_id,
event_start_date_time: event.event_start_date_time,
event_end_date_time: event.event_end_date_time,
event_status: event.event_status as "SCHEDULED" | "ONGOING" | "COMPLETED" | "CANCELLED",
event_status: event.event_status as MaintenanceEventDetailResponse["event_status"],
maintenance_title: event.maintenance_title,
maintenance_description: event.maintenance_description,
maintenance_status: event.maintenance_status as "ACTIVE" | "INACTIVE",
maintenance_rrule: event.maintenance_rrule,
maintenance_duration_seconds: event.maintenance_duration_seconds,
monitors: monitorList,
url: siteUrl + serverResolver(`/maintenances/${event.event_id}`),
});
}
@@ -101,6 +101,19 @@ export const POST: RequestHandler = async ({ request }) => {
return json(errorResponse, { status: 400 });
}
// Validate confirmation_threshold
let confirmationThreshold = 1;
if (body.confirmation_threshold !== undefined && body.confirmation_threshold !== null) {
const ct = Number(body.confirmation_threshold);
if (!Number.isInteger(ct) || ct < 1 || ct > 60) {
const errorResponse: BadRequestResponse = {
error: { code: "BAD_REQUEST", message: "confirmation_threshold must be an integer between 1 and 60" },
};
return json(errorResponse, { status: 400 });
}
confirmationThreshold = ct;
}
// Prepare monitor data for insertion
const monitorData = {
tag: body.tag.trim(),
@@ -115,6 +128,7 @@ export const POST: RequestHandler = async ({ request }) => {
type_data: body.type_data ? JSON.stringify(body.type_data) : null,
include_degraded_in_downtime: body.include_degraded_in_downtime ?? "NO",
is_hidden: body.is_hidden ?? "NO",
confirmation_threshold: confirmationThreshold,
monitor_settings_json: body.monitor_settings_json ? JSON.stringify(body.monitor_settings_json) : null,
};
@@ -1,11 +1,12 @@
import { json, type RequestHandler } from "@sveltejs/kit";
import db from "$lib/server/db/db";
import { GetMonitorsParsed } from "$lib/server/controllers/monitorsController";
import { GetMonitorsParsed, DeleteMonitorCompletelyUsingTag } from "$lib/server/controllers/monitorsController";
import type {
GetMonitorResponse,
MonitorResponse,
UpdateMonitorRequest,
UpdateMonitorResponse,
DeleteMonitorResponse,
BadRequestResponse,
} from "$lib/types/api";
@@ -79,6 +80,22 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
updateData.is_hidden = body.is_hidden !== undefined ? body.is_hidden : existingMonitor.is_hidden;
if (body.confirmation_threshold === null) {
// Explicit null resets the grace period to the default (1 = off); undefined keeps the existing value.
updateData.confirmation_threshold = 1;
} else if (body.confirmation_threshold !== undefined) {
const ct = Number(body.confirmation_threshold);
if (!Number.isInteger(ct) || ct < 1 || ct > 60) {
const errorResponse: BadRequestResponse = {
error: { code: "BAD_REQUEST", message: "confirmation_threshold must be an integer between 1 and 60" },
};
return json(errorResponse, { status: 400 });
}
updateData.confirmation_threshold = ct;
} else {
updateData.confirmation_threshold = existingMonitor.confirmation_threshold ?? 1;
}
// Handle JSON fields - merge with existing data instead of replacing
if (body.type_data !== undefined) {
if (body.type_data === null) {
@@ -143,3 +160,20 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
return json(response);
};
export const DELETE: RequestHandler = async ({ locals }) => {
// Monitor is validated by middleware and available in locals
const monitor = locals.monitor!;
// Removes the monitor and everything keyed to its tag: monitoring data,
// incident/maintenance/page links, alerts, alert configs, group
// memberships (with weight rebalancing), and caches. The scheduler drops
// the orphaned BullMQ job on its next reconcile.
await DeleteMonitorCompletelyUsingTag(monitor.tag);
const response: DeleteMonitorResponse = {
message: `Monitor '${monitor.tag}' deleted successfully`,
};
return json(response);
};
@@ -11,6 +11,7 @@ import GC from "$lib/global-constants";
import { UpdateMonitoringData } from "$lib/server/controllers/monitorsController";
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
import { SetLastMonitoringValue } from "$lib/server/cache/setGet";
import alertingQueue from "$lib/server/queues/alertingQueue";
export const GET: RequestHandler = async ({ locals, url }) => {
// Monitor is validated by middleware and available in locals
@@ -169,6 +170,18 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
await SetLastMonitoringValue(monitorTag, latestData);
}
// MANUAL samples are alert-visible (docs/adr/0005), so re-evaluate alerts once for the
// last written sample — for NONE monitors nothing else would ever trigger evaluation.
// UpdateMonitoringData floors both bounds to minute starts and writes through the floored
// end inclusive, so the last stored row is always at GetMinuteStartTimestampUTC(end_ts).
// Best-effort: the rows are already committed; a queue outage must not fail the request.
const lastWrittenTs = GetMinuteStartTimestampUTC(body.end_ts);
try {
await alertingQueue.push(monitorTag, lastWrittenTs, body.status);
} catch (err) {
console.error(`Failed to enqueue alert evaluation for ${monitorTag} after MANUAL data write:`, err);
}
// Calculate the number of data points that will be returned by GET
// GET uses: timestamp >= start_ts AND timestamp < end_ts
// Data is stored at minute-aligned timestamps
@@ -10,6 +10,7 @@ import type {
import GC from "$lib/global-constants";
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
import { SetLastMonitoringValue } from "$lib/server/cache/setGet";
import alertingQueue from "$lib/server/queues/alertingQueue";
export const GET: RequestHandler = async ({ params, locals }) => {
// Monitor is validated by middleware and available in locals
@@ -160,6 +161,15 @@ export const PATCH: RequestHandler = async ({ params, locals, request }) => {
await SetLastMonitoringValue(monitorTag, latestData);
}
// MANUAL samples are alert-visible (docs/adr/0005), so re-evaluate alerts for this
// sample — for NONE monitors nothing else would ever trigger evaluation.
// Best-effort: the row is already committed; a queue outage must not fail the request.
try {
await alertingQueue.push(monitorTag, timestamp, status);
} catch (err) {
console.error(`Failed to enqueue alert evaluation for ${monitorTag} after MANUAL data write:`, err);
}
// Fetch the updated data
const updatedData = await db.getMonitoringDataAt(monitorTag, timestamp);
+17 -79
View File
@@ -3,12 +3,13 @@ import db from "$lib/server/db/db";
import type {
GetPagesListResponse,
PageResponse,
PageSettings,
CreatePageRequest,
CreatePageResponse,
BadRequestResponse,
} from "$lib/types/api";
import type { PageRecord } from "$lib/server/types/db";
import GC from "$lib/global-constants";
import { toApiPageSettings, applyPageSettingsPatch, validatePageSettings } from "$lib/server/pageSettings";
function formatDateToISO(date: Date | string): string {
if (date instanceof Date) {
@@ -19,87 +20,15 @@ function formatDateToISO(date: Date | string): string {
return parsed.toISOString();
}
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 },
},
},
};
}
function mergePageSettings(defaults: PageSettings, partial?: Partial<PageSettings>): PageSettings {
if (!partial) {
return defaults;
}
return {
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,
},
},
},
};
}
async function formatPageResponse(page: PageRecord): Promise<PageResponse> {
let pageSettings: PageSettings = getDefaultPageSettings();
if (page.page_settings_json) {
try {
const parsed = JSON.parse(page.page_settings_json);
pageSettings = mergePageSettings(getDefaultPageSettings(), parsed);
} catch {
// Use defaults on parse error
}
}
const pageSettings = toApiPageSettings(page.page_settings_json);
const pageMonitors = await db.getPageMonitors(page.id);
return {
id: page.id,
page_path: page.page_path,
// The home page's empty page_path renders as the addressable ~home token
page_path: page.page_path === "" ? GC.HOME_PAGE_TOKEN : page.page_path,
page_title: page.page_title,
page_header: page.page_header,
page_subheader: page.page_subheader,
@@ -204,8 +133,17 @@ export const POST: RequestHandler = async ({ request }) => {
}
}
// Prepare page settings
const pageSettings = mergePageSettings(getDefaultPageSettings(), body.page_settings);
// Validate page settings if provided
const settingsError = validatePageSettings(body.page_settings);
if (settingsError) {
const errorResponse: BadRequestResponse = {
error: {
code: "BAD_REQUEST",
message: settingsError,
},
};
return json(errorResponse, { status: 400 });
}
// Create the page
const pageData = {
@@ -214,7 +152,7 @@ export const POST: RequestHandler = async ({ request }) => {
page_header: body.page_header.trim(),
page_subheader: body.page_subheader ?? null,
page_logo: body.page_logo ?? null,
page_settings_json: JSON.stringify(pageSettings),
page_settings_json: applyPageSettingsPatch(null, body.page_settings),
};
const createdPage = await db.createPage(pageData);
@@ -3,7 +3,6 @@ import db from "$lib/server/db/db";
import type {
GetPageResponse,
PageResponse,
PageSettings,
UpdatePageRequest,
UpdatePageResponse,
DeletePageResponse,
@@ -11,6 +10,8 @@ import type {
NotFoundResponse,
} from "$lib/types/api";
import type { PageRecord } from "$lib/server/types/db";
import GC from "$lib/global-constants";
import { toApiPageSettings, applyPageSettingsPatch, validatePageSettings } from "$lib/server/pageSettings";
function formatDateToISO(date: Date | string): string {
if (date instanceof Date) {
@@ -21,87 +22,15 @@ function formatDateToISO(date: Date | string): string {
return parsed.toISOString();
}
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 },
},
},
};
}
function mergePageSettings(defaults: PageSettings, partial?: Partial<PageSettings>): PageSettings {
if (!partial) {
return defaults;
}
return {
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,
},
},
},
};
}
async function formatPageResponse(page: PageRecord): Promise<PageResponse> {
let pageSettings: PageSettings = getDefaultPageSettings();
if (page.page_settings_json) {
try {
const parsed = JSON.parse(page.page_settings_json);
pageSettings = mergePageSettings(getDefaultPageSettings(), parsed);
} catch {
// Use defaults on parse error
}
}
const pageSettings = toApiPageSettings(page.page_settings_json);
const pageMonitors = await db.getPageMonitors(page.id);
return {
id: page.id,
page_path: page.page_path,
// The home page's empty page_path renders as the addressable ~home token
page_path: page.page_path === "" ? GC.HOME_PAGE_TOKEN : page.page_path,
page_title: page.page_title,
page_header: page.page_header,
page_subheader: page.page_subheader,
@@ -160,6 +89,12 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
return json(errorResponse, { status: 400 });
}
// API responses render the home page's path as ~home, so a read-modify-write
// client sends it back unchanged; treat that as "no path change"
if (page.page_path === "" && body.page_path === GC.HOME_PAGE_TOKEN) {
body.page_path = undefined;
}
// Validate page_path if provided
if (body.page_path !== undefined) {
if (typeof body.page_path !== "string") {
@@ -179,6 +114,18 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
.replace(/\s+/g, "-")
.replace(/[^a-z0-9_-]/g, "");
// The home page's path is fixed; matches the manage UI which disables
// the field with "Home page path cannot be changed"
if (page.page_path === "" && sanitizedPagePath !== "") {
const errorResponse: BadRequestResponse = {
error: {
code: "BAD_REQUEST",
message: "Home page path cannot be changed",
},
};
return json(errorResponse, { status: 400 });
}
// Check if page_path is being changed and conflicts with existing page
if (sanitizedPagePath !== page.page_path) {
const existingPage = await db.getPageByPath(sanitizedPagePath);
@@ -239,6 +186,18 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
}
}
// Validate page_settings if provided
const settingsError = validatePageSettings(body.page_settings);
if (settingsError) {
const errorResponse: BadRequestResponse = {
error: {
code: "BAD_REQUEST",
message: settingsError,
},
};
return json(errorResponse, { status: 400 });
}
// Build update data
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updateData: Record<string, any> = {};
@@ -263,21 +222,9 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
updateData.page_logo = body.page_logo;
}
// Handle page_settings merge
// Handle page_settings merge; unknown stored keys are preserved
if (body.page_settings !== undefined) {
let currentSettings: PageSettings = getDefaultPageSettings();
if (page.page_settings_json) {
try {
const parsed = JSON.parse(page.page_settings_json);
currentSettings = mergePageSettings(getDefaultPageSettings(), parsed);
} catch {
// Use defaults on parse error
}
}
const mergedSettings = mergePageSettings(currentSettings, body.page_settings);
updateData.page_settings_json = JSON.stringify(mergedSettings);
updateData.page_settings_json = applyPageSettingsPatch(page.page_settings_json, body.page_settings);
}
// Update page if there are changes
@@ -334,6 +281,18 @@ export const DELETE: RequestHandler = async ({ locals }) => {
return json(errorResponse, { status: 404 });
}
// The home page must always exist; DeletePage in pagesController enforces
// the same invariant for the manage UI
if (page.page_path === "") {
const errorResponse: BadRequestResponse = {
error: {
code: "BAD_REQUEST",
message: "Cannot delete the home page",
},
};
return json(errorResponse, { status: 400 });
}
// Delete all page monitors first
await db.deletePageMonitorsByPageId(page.id);
@@ -341,7 +300,7 @@ export const DELETE: RequestHandler = async ({ locals }) => {
await db.deletePage(page.id);
const response: DeletePageResponse = {
message: `Page '${page.page_path}' deleted successfully`,
message: `Page '${page.page_path || GC.HOME_PAGE_TOKEN}' deleted successfully`,
};
return json(response);
-7
View File
@@ -19,10 +19,3 @@
<main>
{@render children()}
</main>
<style>
/* Apply the global font family using the CSS variable */
* {
font-family: var(--font-family);
}
</style>
+12
View File
@@ -81,6 +81,10 @@
"title": "Overview",
"content": "v4/monitors/overview"
},
{
"title": "Grace Period",
"content": "v4/monitors/grace-period"
},
{
"title": "API Monitors",
"content": "v4/monitors/api"
@@ -303,6 +307,14 @@
"group": "v4.x",
"collapsible": false,
"pages": [
{
"title": "v4.1.0",
"content": "v4/changelogs/v4.1.0"
},
{
"title": "v4.0.23",
"content": "v4/changelogs/v4.0.23"
},
{
"title": "v4.0.22",
"content": "v4/changelogs/v4.0.22"
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -223,9 +223,7 @@
<Button
variant="ghost"
size="sm"
class="rounded-none border-0 {tab.key === getActiveTabKey()
? 'border-b-accent-foreground! border-b!'
: ''}"
class="rounded-none border-0 {tab.key === getActiveTabKey() ? 'border-b-primary! border-b!' : ''}"
onclick={() => selectTab(tab)}
>
{tab.name}
+1 -1
View File
@@ -192,7 +192,7 @@
<style>
.active {
color: var(--accent-foreground);
color: var(--primary);
font-weight: 500;
}
</style>
@@ -0,0 +1,252 @@
<script lang="ts">
import { onMount } from "svelte";
type TickStatus = "up" | "degraded";
interface Tick {
id: number;
status: TickStatus;
}
const WINDOW = 64;
/** A degraded check still serves most requests; mirrors how Kener scores a degraded bucket. */
const DEGRADED_VALUE = 97;
/** Every CYCLE seconds the demo dips for two ticks, then recovers. */
const CYCLE = 22;
const DIP_AT = 14;
let nextId = 0;
function seedTicks(): Tick[] {
// Seed with one healed dip mid-history so the bar tells its story at first paint.
return Array.from({ length: WINDOW }, (_, i) => ({
id: nextId++,
status: i === 22 || i === 23 ? "degraded" : "up"
}));
}
let ticks = $state<Tick[]>(seedTicks());
let clock = $state(0);
const isDegraded = $derived(ticks[ticks.length - 1]?.status === "degraded");
const uptime = $derived(
(ticks.reduce((sum, t) => sum + (t.status === "up" ? 100 : DEGRADED_VALUE), 0) / ticks.length).toFixed(3)
);
onMount(() => {
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
if (reduceMotion.matches) return; // static seeded bar, no live march
let timer: ReturnType<typeof setInterval> | undefined;
const tick = () => {
clock += 1;
const phase = clock % CYCLE;
const status: TickStatus = phase === DIP_AT || phase === DIP_AT + 1 ? "degraded" : "up";
ticks = [...ticks.slice(1), { id: nextId++, status }];
};
const start = () => {
if (timer === undefined) timer = setInterval(tick, 1000);
};
const stop = () => {
if (timer !== undefined) {
clearInterval(timer);
timer = undefined;
}
};
// Run only while visible: on-screen and tab focused.
const observer = new IntersectionObserver(
([entry]) => (entry.isIntersecting && !document.hidden ? start() : stop()),
{ threshold: 0.1 }
);
observer.observe(strip);
const onVisibility = () => (document.hidden ? stop() : start());
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
observer.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
};
});
let strip: HTMLElement;
</script>
<div class="demo-strip" bind:this={strip}>
<div class="demo-head">
<div class="demo-state" class:degraded={isDegraded}>
<span class="demo-dot" aria-hidden="true"></span>
<span class="demo-label">
{isDegraded ? "Degraded performance" : "All systems operational"}
</span>
</div>
<div class="demo-uptime">
<span class="demo-uptime-value">{uptime}%</span>
<span class="demo-uptime-meta">uptime</span>
</div>
</div>
<div class="demo-bar" aria-hidden="true">
{#each ticks as tick (tick.id)}
<span class="demo-tick" class:degraded={tick.status === "degraded"}></span>
{/each}
</div>
<div class="demo-meta" aria-hidden="true">
<span>demo monitor &middot; HTTP</span>
<span>last {WINDOW} checks</span>
</div>
<p class="sr-only">Demo of a Kener monitor: a rolling uptime bar that records a check every second.</p>
</div>
<style>
.demo-strip {
border: 1px solid color-mix(in oklch, var(--foreground) 10%, transparent);
border-radius: calc(var(--radius) + 4px);
background: var(--card);
padding: 1.125rem 1.25rem 1rem;
}
.demo-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.875rem;
}
.demo-state {
display: inline-flex;
align-items: center;
gap: 0.625rem;
min-width: 0;
}
.demo-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
background: var(--demo-up);
flex: none;
transition: background 0.4s cubic-bezier(0.32, 0.72, 0, 1);
}
@media (prefers-reduced-motion: no-preference) {
.demo-dot {
animation: demoPing 2.4s cubic-bezier(0.32, 0.72, 0, 1) infinite;
}
}
@keyframes demoPing {
0% {
box-shadow: 0 0 0 0 color-mix(in oklch, var(--demo-up) 45%, transparent);
}
70%,
100% {
box-shadow: 0 0 0 7px transparent;
}
}
.demo-state.degraded .demo-dot {
background: var(--primary);
animation: none;
}
.demo-label {
font-family: "Geist Mono", ui-monospace, monospace;
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--foreground);
white-space: nowrap;
}
@media (max-width: 520px) {
.demo-head {
flex-direction: column;
align-items: flex-start;
gap: 0.375rem;
}
}
.demo-state.degraded .demo-label {
color: var(--primary);
}
.demo-uptime {
display: flex;
align-items: baseline;
gap: 0.375rem;
flex: none;
}
.demo-uptime-value {
font-family: "Geist Mono", ui-monospace, monospace;
font-variant-numeric: tabular-nums;
font-size: 0.9375rem;
font-weight: 600;
color: var(--foreground);
}
.demo-uptime-meta {
font-size: 0.75rem;
color: var(--muted-foreground);
}
.demo-bar {
display: flex;
gap: 3px;
height: 2rem;
}
.demo-tick {
flex: 1 1 0;
min-width: 0;
border-radius: 2px;
background: var(--demo-up);
}
.demo-tick.degraded {
background: var(--primary);
}
/* Newest tick announces itself, then settles. */
@media (prefers-reduced-motion: no-preference) {
.demo-tick:last-child {
animation: tickIn 0.5s cubic-bezier(0.32, 0.72, 0, 1);
}
}
@keyframes tickIn {
from {
transform: scaleY(0.4);
opacity: 0.4;
}
to {
transform: scaleY(1);
opacity: 1;
}
}
.demo-meta {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-top: 0.75rem;
font-family: "Geist Mono", ui-monospace, monospace;
font-size: 0.6875rem;
color: var(--muted-foreground);
}
/* Hide a third of the ticks on narrow screens so each tick keeps presence. */
@media (max-width: 480px) {
.demo-tick:nth-child(3n) {
display: none;
}
}
</style>
@@ -1,281 +0,0 @@
---
title: Monitors API
description: Create, read, update, and delete monitors via the REST API
---
Create, read, update, and delete monitors via the API.
## List Monitors
Get all monitors with their current status.
```http
GET /api/monitors
```
### Parameters
| Parameter | Type | Description |
| --------- | ------- | ------------------------------------ |
| `page` | integer | Page number (default: 1) |
| `limit` | integer | Items per page (default: 20) |
| `status` | string | Filter by status: up, down, degraded |
| `type` | string | Filter by type: API, PING, TCP, etc. |
### Example Request
```bash
curl -X GET "https://your-kener.com/api/monitors?status=up&limit=10" \
-H "Authorization: Bearer YOUR_API_KEY"
```
### Example Response
```json
{
"success": true,
"data": {
"monitors": [
{
"id": "mon_abc123",
"name": "API Server",
"type": "API",
"url": "https://api.example.com/health",
"status": "up",
"uptime": 99.95,
"lastChecked": "2024-01-15T10:30:00Z"
}
],
"pagination": {
"page": 1,
"limit": 10,
"total": 25
}
}
}
```
## Get Monitor
Get details for a specific monitor.
```http
GET /api/monitors/:id
```
### Example Request
```bash
curl -X GET https://your-kener.com/api/monitors/mon_abc123 \
-H "Authorization: Bearer YOUR_API_KEY"
```
### Example Response
```json
{
"success": true,
"data": {
"id": "mon_abc123",
"name": "API Server",
"type": "API",
"url": "https://api.example.com/health",
"method": "GET",
"expectedStatusCode": 200,
"interval": 60,
"timeout": 10000,
"status": "up",
"uptime": {
"day": 100,
"week": 99.95,
"month": 99.87
},
"responseTime": {
"current": 145,
"average": 152
},
"lastChecked": "2024-01-15T10:30:00Z",
"createdAt": "2024-01-01T00:00:00Z"
}
}
```
## Create Monitor
Create a new monitor.
```http
POST /api/monitors
```
### Request Body
```json
{
"name": "Production API",
"type": "API",
"url": "https://api.example.com/health",
"method": "GET",
"expectedStatusCode": 200,
"interval": 60,
"timeout": 10000,
"headers": {
"Authorization": "Bearer token"
}
}
```
### Monitor Types
#### API Monitor
```json
{
"name": "API Health",
"type": "API",
"url": "https://api.example.com",
"method": "GET",
"expectedStatusCode": 200,
"headers": {},
"body": null
}
```
#### Ping Monitor
```json
{
"name": "Server Ping",
"type": "PING",
"host": "server.example.com"
}
```
#### TCP Monitor
```json
{
"name": "Database Port",
"type": "TCP",
"host": "db.example.com",
"port": 5432
}
```
#### DNS Monitor
```json
{
"name": "DNS Check",
"type": "DNS",
"host": "example.com",
"recordType": "A"
}
```
### Example Request
```bash
curl -X POST https://your-kener.com/api/monitors \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Production API",
"type": "API",
"url": "https://api.example.com/health",
"interval": 60
}'
```
### Example Response
```json
{
"success": true,
"data": {
"id": "mon_xyz789",
"name": "Production API",
"type": "API",
"status": "pending",
"createdAt": "2024-01-15T10:30:00Z"
},
"message": "Monitor created successfully"
}
```
## Update Monitor
Update an existing monitor.
```http
PUT /api/monitors/:id
```
### Example Request
```bash
curl -X PUT https://your-kener.com/api/monitors/mon_abc123 \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Updated API Name",
"interval": 120
}'
```
## Delete Monitor
Delete a monitor.
```http
DELETE /api/monitors/:id
```
### Example Request
```bash
curl -X DELETE https://your-kener.com/api/monitors/mon_abc123 \
-H "Authorization: Bearer YOUR_API_KEY"
```
## Monitor History
Get historical data for a monitor.
```http
GET /api/monitors/:id/history
```
### Parameters
| Parameter | Type | Description |
| ------------ | ------ | ---------------------------------- |
| `start` | string | Start date (ISO 8601) |
| `end` | string | End date (ISO 8601) |
| `resolution` | string | Data resolution: minute, hour, day |
### Example Request
```bash
curl -X GET "https://your-kener.com/api/monitors/mon_abc123/history?resolution=hour" \
-H "Authorization: Bearer YOUR_API_KEY"
```
## Pause/Resume Monitor
```http
POST /api/monitors/:id/pause
POST /api/monitors/:id/resume
```
### Example
```bash
# Pause
curl -X POST https://your-kener.com/api/monitors/mon_abc123/pause \
-H "Authorization: Bearer YOUR_API_KEY"
# Resume
curl -X POST https://your-kener.com/api/monitors/mon_abc123/resume \
-H "Authorization: Bearer YOUR_API_KEY"
```
@@ -0,0 +1,54 @@
---
title: v4.0.23 Changelog
description: See what's new in Kener v4.0.23, including new features, improvements, and bug fixes
---
## New features {#new-features}
### Role-based access control (RBAC) {#rbac}
Kener now uses a full RBAC system with roles, permissions, and user-role assignments. This replaces the previous single-role-per-user model with a flexible, permission-driven approach.
- **Permissions** follow a `domain.action` format (e.g. `monitors.read`, `incidents.write`). There are 30+ permissions covering all domains: monitors, incidents, maintenances, pages, triggers, alerts, API keys, users, settings, subscribers, email templates, images, and roles.
- **Built-in roles** — `admin`, `editor`, and `member` — are seeded automatically and cannot be edited or deleted. Admin gets all permissions, editor gets all except `api_keys.delete`, and member gets read-only access.
- **Custom roles** can be created, edited, deactivated, and deleted from the new **Manage → Roles** page. Permissions can be cloned from an existing role during creation.
- **Multi-role assignment** — users can now be assigned multiple roles simultaneously. A user's effective permissions are the union of all their roles' permissions.
- Permissions are enforced at both the **route level** (page access) and the **action level** (API operations).
New database tables: `roles`, `permissions`, `roles_permissions`, `users_roles`. Existing users are automatically migrated from the old `users.role` column to the new `users_roles` table.
See [User Management](/docs/v4/user-management) for full details.
### Roles management UI {#roles-management-ui}
A new **Manage → Roles** page provides full role administration:
- View all roles with their status and type (readonly or custom).
- **Permissions panel** — toggle individual permissions grouped by domain. Readonly roles show permissions in read-only mode.
- **Users panel** — view, add, and remove users assigned to each role.
- **Duplicate role** — create a new role by cloning permissions from an existing one.
- **Delete role** — choose to remove user assignments or migrate users to another role before deletion.
### Login role validation {#login-role-validation}
Users must have at least one active role to sign in. If a user's account exists but has no active roles assigned, login is blocked with a descriptive error message directing them to contact an administrator.
## Improvements {#improvements}
### Multi-role user invitations {#multi-role-invitations}
The **Add User** dialog now shows checkboxes for all active roles instead of a single role dropdown. At least one role must be selected when inviting a new user. All selected roles are validated to be active before the invitation is sent.
### Permission-based UI visibility {#permission-based-ui}
Sidebar navigation and action buttons throughout the manage dashboard are now driven by the current user's permissions. Pages and actions that the user lacks permission for are hidden rather than showing access-denied errors.
## Breaking changes {#breaking-changes}
### Vault page removed {#vault-removed}
The **Manage → Vault** page has been removed from the admin dashboard. The vault route and its associated permission (`vault`) have been dropped from the route permission map.
### User role column migration {#role-column-migration}
The `role` column on the `users` table is migrated to the `users_roles` junction table. A down migration re-creates the `role` column by backfilling from `users_roles` if you need to roll back. Existing user roles are preserved during the migration.
@@ -0,0 +1,96 @@
---
title: v4.1.0 Changelog
description: See what's new in Kener v4.1.0, including new features, improvements, and bug fixes
---
## New features {#new-features}
### Confirmation Threshold (grace period) {#confirmation-threshold}
Monitors can now require **N consecutive checks** before a status change is committed, so transient blips no longer dent uptime % or the day bars.
- Set a per-monitor **Grace period** (160, default **1 = off**) in **Monitor → General Settings**, or via `confirmation_threshold` on the v4 monitor create/update API.
- Damping happens once in the scheduled-check write path. A new `monitoring_data.raw_status` column records what each check actually observed, while `status` holds the confirmed side.
- On the Nth consecutive opposite observation the status flips and the pending window is **retroactively backfilled** — a confirmed outage reads as down from its first failing check, and recovery is symmetric.
- Everything downstream — uptime %, day bars, badges, alert evaluation, group scoring — inherits the damped `status` automatically. Alerts compose by `max(grace period, failure threshold)`.
See [Grace Period](/docs/v4/monitors/grace-period) for full details.
### Manual maintenance event transitions {#maintenance-manual-transitions}
Individual maintenance events can now be **manually completed or cancelled** from the maintenance management UI, letting the recorded timeline reflect what actually happened.
- New **Complete** and **Cancel** actions move an event to a terminal `COMPLETED` or `CANCELLED` status and adjust `end_date_time` accordingly.
- Terminal statuses are final — a completed or cancelled event can't be edited further.
- The v4 API supports status transitions alongside window edits, with the two enforced as mutually exclusive.
- Subscribers are notified of the transition.
See [Maintenance Events](/docs/v4/maintenances/events).
### Last known status for "None" monitors {#none-last-known-status}
The **None** monitor type can now optionally reuse the **last known status** (status, latency, and source) instead of reporting no data when no value is pushed. Enable it with the new checkbox in the monitor configuration UI.
### Page settings {#page-settings}
Status pages gained per-page presentation controls, configurable in the manage UI and over the v4 pages API:
- **Per-page status history** — separate day counts for **mobile** (default 30) and **desktop** (default 90), each bounded to 1365.
- **Monitor layout styles** — choose between `default-list`, `default-grid`, `compact-list`, and `compact-grid`.
- **Meta / social overrides** — per-page title, description, and preview image.
Page settings accept deep-merge `PATCH` payloads over the API, and invalid payloads are now rejected with validation errors.
### Inline monitor toggles {#inline-monitor-toggles}
The **Manage → Monitors** list now lets you flip a monitor's **active/inactive** state and **visibility** inline, without opening the full edit page for a one-bit change.
### API: address the home page with `~home` {#api-home-token}
The home page is stored with an empty path and previously couldn't be addressed over the REST API. `GET`/`PATCH`/`DELETE /api/v4/pages/~home` now targets it, and list and single-page responses render its `page_path` as `~home` so what you read is exactly what you can address. `PATCH` accepts every field except the (fixed) home page path, and `DELETE` is rejected with a 400. Unmatched `/api/` routes now return JSON 404s instead of falling through to an HTML page.
### API: absolute `url` on incident and maintenance responses {#api-absolute-url}
v4 API responses now carry an absolute `url` field so consumers link to the right page instead of concatenating ids onto paths:
| Response | `url` |
| :---------------------------------- | :------------------------------------------- |
| Incident | `<site>/incidents/<id>` |
| Maintenance (list/get/create/update) | `<site>/maintenances/<id>?type=maintenance` |
| Maintenance event | `<site>/maintenances/<event_id>` |
The URL is built from the configured Site URL plus base path.
### API: delete a monitor by tag {#api-delete-monitor}
A new **`DELETE /api/v4/monitors/{monitor_tag}`** endpoint removes a monitor through the same path the manage UI uses. Monitor deletion now also explicitly cleans up alert-config rows (v2 alerts and the trigger/monitor junctions), fixing orphaned rows left behind on SQLite — which never enforced the `ON DELETE CASCADE` foreign keys. Shared alert configs survive until their last monitor is detached.
## Improvements {#improvements}
### Rich maintenance descriptions on list views {#maintenance-html-descriptions}
Maintenance descriptions now render as **sanitized HTML** on list views — the home page, custom pages, monitor pages, events pages, and embeds — matching the detail page. HTML formatting and line breaks display correctly instead of showing literal tags, with sanitization kept since these render to anonymous visitors.
### Searchable monitor picker for groups {#group-monitor-search}
The group monitor picker now has a **search bar**, making it practical to find and add monitors on instances with hundreds of them instead of scrolling a flat list.
### Database connection pool tuning and health checks {#db-pool-tuning}
The database layer now tunes connection-pool sizing, clamps the pool bounds to safe values, guards the Redis probe, and hardens the error page. The configured database type is logged at startup for clearer diagnostics.
### Broader alert evaluation {#alert-evaluation}
Consecutive status and latency checks now count **all alert-visible sample types**, improving alert reliability, and manually submitted data triggers a best-effort alert re-evaluation after updates.
### Localization {#localization}
- Added a **Ukrainian** translation.
- Fixed syntax errors in the **French** locale strings.
- Updated the default v4 **alert body** description, which still referenced pre-v4 template variables.
## Bug fixes {#bug-fixes}
- **Overall status now follows worst-wins ordering** (`DOWN > DEGRADED > MAINTENANCE > UP`) consistently across components, so an active maintenance no longer masks a real outage in the aggregated status.
- **Social preview images use absolute URLs** — `og:image` and `twitter:image` meta tags now prepend the Site URL, fixing previews that failed because of relative paths.
- **Fixed overlapping header sections** on the public page by adding a single frosted backdrop behind the fixed nav and the sticky theme bar, so page content no longer shows through while scrolling.
@@ -196,7 +196,7 @@ status.yourdomain.com {
## Next Steps
- Set up [Monitors](/docs/monitors)
- Configure [Incidents](/docs/incidents)
- Set up [Monitors](/docs/v4/monitors)
- Configure [Incidents](/docs/v4/incidents)
- Configure [Sharing Monitors](/docs/v4/sharing)
- Explore the [API Reference](/docs/api-reference)
- Explore the [API Reference](/docs/spec/v4/)
@@ -148,45 +148,62 @@ This provides complete context about:
When the alert resolves, a detailed closure update is added:
```markdown
The alert has been resolved, Total duration: 47 minutes
#### Alert Details
title: Auto-Generated Incidents
description: Quick reference for incident creation via alerting
| Setting | Value |
| :-------------------- | :---------- |
| **Monitor Name** | Payment API |
Kener can auto-create incidents from alert configurations.
| **Monitor Tag** | payment-api |
## How it works {#how-it-works}
| **Alert Value** | DOWN |
1. Alert triggers.
2. Alert has **Create Incident = YES**.
3. Incident is created and monitor is attached.
4. When alert resolves, incident is resolved automatically.
| **Failure Threshold** | 3 |
## Where to configure {#where-to-configure}
| **Monitor Name** | Payment API |
| **Incident Status** | RESOLVED |
| **Monitor Tag** | payment-api |
| **Alert Type** | STATUS |
| **Alert Value** | DOWN |
| **Severity** | CRITICAL |
| **Failure Threshold** | 3 |
| **Success Threshold** | 5 |
```
This includes:
Use **Manage → Alerts → Alert Configurations**.
- Total incident duration
## Notes {#notes}
- Alert configuration
- Resolution confirmation
- Auto-generated incidents are ideal for critical, user-facing alerts.
- Tune thresholds to avoid noisy incident creation.
- Success threshold that was met
## See also {#see-also}
## Configuration Requirements {#configuration-requirements}
- [Alert Configurations](/docs/v4/alerting/alert-configurations)
- [Triggers](/docs/v4/alerting/triggers)
- [Creating and Managing Incidents](/docs/v4/incidents/creating-managing)
To enable auto-generated incidents:
### 1. Create Alert Configuration {#create-alert-config}
Navigate to **Manage > Alerts > Create Alert**
**Configure:**
- Monitor to watch
- Alert type (STATUS, LATENCY, UPTIME)
- Alert value (threshold)
- Failure threshold
- Success threshold
- Severity (CRITICAL or WARNING)
**Enable Incident Creation:**
- Set **Create Incident** to **YES**
See [Alert Configurations](/docs/v4/alerting/alert-configurations) for complete details.
### 2. Configure Triggers (Optional) {#configure-triggers}
While triggers are optional for incident creation, they enable notifications:
**Create Triggers:**
- Discord
- Slack
- Email
- Webhook
**Attach to Alert:**
@@ -194,7 +211,7 @@ Use **Manage → Alerts → Alert Configurations**.
- Select triggers when creating/editing alert
- Triggers fire on both TRIGGERED and RESOLVED events
See [Triggers](/docs/alerting/triggers) for setup.
See [Triggers](/docs/v4/alerting/triggers) for setup.
## Manual vs Auto-Generated {#manual-vs-auto}
@@ -289,11 +306,13 @@ A monitor can have multiple alert configurations:
**Example:**
```
Monitor: api-gateway
Alert 1: STATUS - DOWN (failure: 1)
Alert 2: LATENCY - 1000ms (failure: 5)
Alert 3: UPTIME - 99.9% (failure: 10)
```
**Each Alert:**
@@ -330,7 +349,7 @@ When incident is auto-resolved:
- Uses configured templates
- Variable: `is_resolved = true`
See [Triggers](/docs/alerting/triggers) and [Templates](/docs/alerting/templates) for customization.
See [Triggers](/docs/v4/alerting/triggers) and [Templates](/docs/v4/alerting/templates) for customization.
### Subscriber Notifications {#subscriber-notifications}
@@ -427,7 +446,7 @@ Every alert trigger creates an alert event log:
3. Trigger credentials valid
4. Check trigger logs for errors
See [Troubleshooting Triggers](/docs/alerting/triggers#troubleshooting-triggers) for detailed diagnosing.
See [Troubleshooting Triggers](/docs/v4/alerting/triggers#troubleshooting-triggers) for detailed diagnosing.
## Best Practices {#best-practices}
@@ -465,11 +484,13 @@ See [Troubleshooting Triggers](/docs/alerting/triggers#troubleshooting-triggers)
**Workflow:**
```
1. Alert triggers → Auto-creates incident
2. You investigate → Add IDENTIFIED update
3. You deploy fix → Add MONITORING update
4. Alert resolves → Auto-adds RESOLVED update
5. You add post-mortem → Add final manual update
```
### Threshold Tuning {#threshold-tuning}
@@ -493,7 +514,7 @@ See [Troubleshooting Triggers](/docs/alerting/triggers#troubleshooting-triggers)
## Next Steps {#next-steps}
- [Alert Configurations](/docs/alerting/alert-configurations) - Set up alerts that create incidents
- [Triggers](/docs/alerting/triggers) - Configure notification channels
- [Incident Updates](/docs/incidents/updates) - Add manual updates to auto-generated incidents
- [Incident Overview](/docs/incidents/overview) - Understand incident basics
- [Alert Configurations](/docs/v4/alerting/alert-configurations) - Set up alerts that create incidents
- [Triggers](/docs/v4/alerting/triggers) - Configure notification channels
- [Incident Updates](/docs/v4/incidents/updates) - Add manual updates to auto-generated incidents
- [Incident Overview](/docs/v4/incidents/overview) - Understand incident basics
@@ -60,32 +60,69 @@ The incident state after this update. This is **crucial** because:
- State progression drives the incident lifecycle
**Available States:**
title: Incident Updates
description: Quick reference for posting timeline updates on incidents
- **INVESTIGATING** - Initial investigation
- **IDENTIFIED** - Root cause found
- **MONITORING** - Fix applied, watching for stability
Incident updates are timeline entries used to communicate progress and move incident state.
- **RESOLVED** - Issue fully resolved
## Quick reference {#quick-reference}
**Important:** When you set state to **RESOLVED**, Kener automatically sets the incident's `end_date_time` to the update's timestamp.
When posting an update, choose one state:
### Timestamp (Required) {#timestamp}
- `INVESTIGATING`
- `IDENTIFIED`
- `MONITORING`
- `RESOLVED`
When this update was made.
Setting `RESOLVED` closes the incident and sets end time.
**Default:** Current date and time
**Can Be Modified:**
- Useful for backdating updates
- Aligning timeline with actual events
Use concise, user-facing text and include only meaningful changes.
- Recording updates made after the fact
## See also {#see-also}
**Display:** Shown in your local timezone but stored as UTC
- [Creating and Managing Incidents](/docs/v4/incidents/creating-managing)
- [Impact on Monitoring](/docs/v4/incidents/impact-on-monitoring)
**Important State Changes:**
## Creating Updates {#creating-updates}
### During Incident Creation {#during-creation}
When creating a new incident, you can optionally provide an **Initial Update**:
1. Fill out the incident details
2. Add text in the "Initial Update" field
3. Uses Markdown formatting
4. Automatically created with state "INVESTIGATING"
5. Timestamp matches incident start time
### Adding Updates to Existing Incidents {#adding-updates}
1. Navigate to the incident detail page
2. Click **Add Update** button
3. Enter your message (Markdown supported)
4. Select the new state
5. Optionally adjust the timestamp
6. Click **Add Update**
**What Happens:**
- Update is added to the incident timeline
- Incident state changes to the update's state
- If state is RESOLVED, end_date_time is set
- Update appears immediately on status page
- Subscribers receive notifications (if configured)
## Editing Updates {#editing-updates}
You can edit any existing update:
1. Find the update in the incident timeline
2. Click the **Edit** (pencil icon) button
3. Modify the message
4. Change the state if needed
5. Adjust the timestamp if needed
6. Click **Save**
**Important State Changes:**
**Moving to RESOLVED:**
@@ -401,6 +438,6 @@ See [Subscription documentation](/docs/v4/subscriptions) for setup.
## Next Steps {#next-steps}
- [Incident Impact on Monitoring](/docs/incidents/impact-on-monitoring) - How incident state affects monitor status
- [Creating and Managing Incidents](/docs/incidents/creating-managing) - Back to incident management basics
- [Auto-Generated Incidents](/docs/incidents/auto-generated) - How alerts create and update incidents automatically
- [Incident Impact on Monitoring](/docs/v4/incidents/impact-on-monitoring) - How incident state affects monitor status
- [Creating and Managing Incidents](/docs/v4/incidents/creating-managing) - Back to incident management basics
- [Auto-Generated Incidents](/docs/v4/incidents/auto-generated) - How alerts create and update incidents automatically
@@ -491,7 +491,7 @@ Add buffer time to your estimates:
- Ensure BYDAY is set for weekly frequency
- Check interval is positive number
- Verify RRULE syntax (see [RRULE Patterns](/docs/maintenances/rrule-patterns))
- Verify RRULE syntax (see [RRULE Patterns](/docs/v4/maintenances/rrule-patterns))
**Problem:** Events not generated for recurring maintenance
@@ -511,6 +511,6 @@ Add buffer time to your estimates:
## Next Steps {#next-steps}
- [Maintenance Events](/docs/maintenances/events) - Learn about event lifecycle and automatic transitions
- [Maintenance Impact on Monitoring](/docs/maintenances/impact-on-monitoring) - How maintenances affect monitor status display
- [RRULE Patterns](/docs/maintenances/rrule-patterns) - Advanced scheduling patterns and examples
- [Maintenance Events](/docs/v4/maintenances/events) - Learn about event lifecycle and automatic transitions
- [Maintenance Impact on Monitoring](/docs/v4/maintenances/impact-on-monitoring) - How maintenances affect monitor status display
- [RRULE Patterns](/docs/v4/maintenances/rrule-patterns) - Advanced scheduling patterns and examples
@@ -179,63 +179,66 @@ Action: Override monitor statuses
```
READY → ONGOING
title: Maintenance Events
description: How maintenance events are generated and shown to users
Condition: current_time >= start_time AND current_time < end_time
Executed by: Status update scheduler (runs every minute)
```
A maintenance event is one occurrence of a maintenance window.
#### COMPLETED {#completed-state}
## Event generation {#event-generation}
**When:** Current time is past end time, or an admin completes an ONGOING event early
### One-time maintenance {#one-time-maintenance}
**Meaning:**
- Creates one event.
- Triggered when maintenance is created.
- Maintenance has finished
- Monitor statuses restored to realtime values
- Historical record
### Recurring maintenance {#recurring-maintenance}
**Displayed As:** "Completed"
- Creates upcoming events from RRULE.
- Scheduler refreshes upcoming occurrences.
- Duplicate event start times are skipped.
**Notification:** "Maintenance Completed" notification sent
## Event statuses {#event-statuses}
**Example:**
- `SCHEDULED`
- `READY`
- `ONGOING`
- `COMPLETED`
- `CANCELLED`
Status transitions are time-based and automatic.
```
Current Time: May 15, 4:05 PM
## User-visible behavior {#user-visible-behavior}
Event End: May 15, 4:00 PM
Status: COMPLETED (finished 5 minutes ago)
- Ongoing events affect monitor display according to impact settings.
- Upcoming and past visibility depends on site/page event display settings.
Action: Restore monitor statuses
```
## Manual actions {#manual-actions}
**Automatic Transition:**
- You can cancel/delete events from maintenance management screens.
- Edit the parent maintenance to regenerate future schedule behavior.
```
ONGOING → COMPLETED
Condition: current_time >= end_time
Executed by: Status update scheduler (runs every minute)
```
## Related guides {#related-guides}
**Manual Completion:**
Condition: current_time >= end_time
An ONGOING event can be completed early from the maintenance edit page or via the API (see [Completing an Event Early](#completing-events)). Its end time is moved to the moment it was completed, so the recorded window reflects what actually happened.
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing)
- [Impact on Monitoring](/docs/v4/maintenances/impact-on-monitoring)
- [RRULE Patterns](/docs/v4/maintenances/rrule-patterns)
**Displayed As:** "Cancelled"
#### CANCELLED {#cancelled-state}
**Notification:** No automatic notification
**When:** Manually cancelled by an admin (allowed for SCHEDULED, READY, and ONGOING events)
**Manual Action:** User clicks delete/cancel on event
**Meaning:**
- The occurrence was called off — before or during its window
- Monitor statuses are not (or no longer) overridden
- Kept in history, explicitly marked as cancelled vs completed
**Time Handling:**
- Cancelled before start: the planned window is kept unchanged
- Cancelled while ONGOING: the end time is moved to the moment of cancellation
**Displayed As:** "Cancelled"
**Notification:** "Maintenance Cancelled" notification sent (controlled by the same "ended" notification setting as completion)
> [!NOTE]
> COMPLETED and CANCELLED are terminal — an event cannot be moved out of them. For recurring maintenances, a cancelled occurrence is never regenerated, so cancelling is how you skip one occurrence while keeping the schedule.
## Automatic Status Transitions {#automatic-transitions}
@@ -323,9 +326,20 @@ When: At start time
Subject: Maintenance Completed
Body: "{title} has been completed"
Monitors: List of affected monitors with impacts
When: At end time
When: At end time, or immediately when completed early
```
**Cancelled (manual cancellation):**
```
Subject: Maintenance Cancelled
Body: "{title} has been cancelled"
Monitors: List of affected monitors with impacts
When: Immediately when an admin cancels the event
```
Completed and Cancelled notifications share the same "ended" notification setting.
### Notification Channels {#notification-channels}
Notifications are sent based on your subscription configuration:
@@ -390,22 +404,35 @@ Users see events on the public status page:
## Managing Events Manually {#managing-events-manually}
### Completing an Event Early {#completing-events}
If the maintenance work finishes before the scheduled end:
1. Find the ONGOING event on the maintenance edit page
2. Click **Complete**
3. Confirm
The event moves to COMPLETED, its end time is set to the current time, monitor statuses return to realtime values, and the completion notification is sent.
Via the API: `PATCH /api/v4/maintenances/{maintenance_id}/events/{event_id}` with body `{"status": "COMPLETED"}`.
### Cancelling Events {#cancelling-events}
From the maintenance edit page:
1. Find the event in the list
2. Click the trash icon
3. Confirm cancellation
4. Event status changes to CANCELLED
1. Find the event in the list (SCHEDULED, READY, or ONGOING)
2. Click **Cancel**
3. Confirm
Via the API: `PATCH /api/v4/maintenances/{maintenance_id}/events/{event_id}` with body `{"status": "CANCELLED"}`.
**When to Cancel:**
- Maintenance no longer needed
- Rescheduling to different time
- Discovered conflict
- Maintenance was aborted partway through
- Skipping one occurrence of a recurring maintenance
**Note:** Cancelled events remain in history but are not executed.
**Note:** Cancelled events remain in history but are not executed. A cancelled occurrence of a recurring maintenance is never regenerated.
### Deleting Events {#deleting-events}
@@ -425,9 +452,9 @@ Events can be permanently deleted:
**Best Practice:** Cancel rather than delete to preserve history.
### Cannot Edit Events {#cannot-edit-events}
### Editing Event Times {#cannot-edit-events}
Individual events cannot be edited directly. To change event timing or duration:
Individual event times cannot be edited from the dashboard. To change event timing or duration:
1. Edit the parent maintenance
2. Update start time, RRULE, or duration
@@ -437,7 +464,9 @@ Individual events cannot be edited directly. To change event timing or duration:
- Future SCHEDULED events deleted
- New events generated with updated settings
- ONGOING/COMPLETED events preserved
- ONGOING/COMPLETED/CANCELLED events preserved
API consumers can edit a single event's window directly: `PATCH /api/v4/maintenances/{maintenance_id}/events/{event_id}` with both `start_date_time` and `end_date_time`. A request cannot combine time fields with a `status` transition.
## Event Retention {#event-retention}
@@ -472,7 +501,7 @@ During ONGOING events:
- Realtime monitoring continues in background
- Status page shows maintenance impact
See [Maintenance Impact on Monitoring](/docs/maintenances/impact-on-monitoring) for details.
See [Maintenance Impact on Monitoring](/docs/v4/maintenances/impact-on-monitoring) for details.
## Troubleshooting {#troubleshooting}
@@ -511,6 +540,6 @@ See [Maintenance Impact on Monitoring](/docs/maintenances/impact-on-monitoring)
## Next Steps {#next-steps}
- [Maintenance Impact on Monitoring](/docs/maintenances/impact-on-monitoring) - How events affect monitor status display
- [RRULE Patterns](/docs/maintenances/rrule-patterns) - Advanced scheduling patterns for recurring maintenances
- [Creating and Managing Maintenances](/docs/maintenances/creating-managing) - Learn how to create and edit maintenances
- [Maintenance Impact on Monitoring](/docs/v4/maintenances/impact-on-monitoring) - How events affect monitor status display
- [RRULE Patterns](/docs/v4/maintenances/rrule-patterns) - Advanced scheduling patterns for recurring maintenances
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing) - Learn how to create and edit maintenances
@@ -66,63 +66,79 @@ Displayed Status: MAINTENANCE
**Example:**
```
Monitor: Database
Realtime Status: UP (checks passing now)
Incident: OPEN
Monitor Impact: DOWN
Displayed Status: DOWN
title: Impact on Monitoring
description: How maintenance events affect displayed monitor status
```
**Rationale:** Incident status takes precedence over current checks during active issues.
During an **ONGOING** maintenance event, maintenance impact can override monitor status shown to users.
**Note:** Maintenance status takes precedence over incident status. If both exist, maintenance wins.
## Status precedence {#status-precedence}
#### 3. Realtime Monitoring Data {#realtime-priority}
Kener resolves status in this order (later overrides earlier):
**When:** No maintenance or incident affecting this monitor
`default status → realtime monitor result → incident impact → maintenance impact`
**Display:** Show latest monitoring check result (UP, DOWN, DEGRADED)
So maintenance has the highest effective priority when active.
## Impact values {#impact-values}
**Example:**
```
Monitor: Web Server
Set per monitor in a maintenance:
Latest Check: DOWN (connection timeout)
No Maintenance: ✓
No Incident: ✓
- `MAINTENANCE` (recommended)
- `DOWN`
- `DEGRADED`
- `UP`
Displayed Status: DOWN
```
Choose the value that matches expected user impact during the window.
#### 4. Default Monitor Status (Lowest Priority) {#default-priority}
## Event lifecycle behavior {#event-lifecycle-behavior}
**When:** No monitoring data exists yet
- `SCHEDULED` / `READY`: no override yet
- `ONGOING`: override active
- `COMPLETED` / `CANCELLED`: override removed
**Display:** Monitor's configured default status
## Realtime monitoring still runs {#realtime-monitoring-still-runs}
**Example:**
```
Monitor: New Service
Even during maintenance, checks continue and data is recorded.
No Checks Run: (just created)
Default Status: UP
Maintenance changes **displayed/effective** status, not monitor execution.
Displayed Status: UP
```
## Practical guidance {#practical-guidance}
## Monitor Impact Levels {#impact-levels}
- Prefer `MAINTENANCE` for planned work communication.
- Use `DOWN` only when service is expected to be unavailable.
- Avoid overlapping maintenances on the same monitor.
When configuring a maintenance, you specify the impact for each affected monitor:
## Related guides {#related-guides}
### MAINTENANCE {#maintenance-impact}
- [Maintenances Overview](/docs/v4/maintenances/overview)
- [Maintenance Events](/docs/v4/maintenances/events)
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing)
**Visual:** Orange/yellow, wrench icon
**Meaning:** Service is under planned maintenance
**When to Use:**
- General maintenance work
- Service available but under maintenance
- Default/recommended choice
**User Interpretation:** "Service may be affected due to planned work"
**Example:**
```yaml
Monitor: API Server
Impact: MAINTENANCE
During Event: Shows orange "Under Maintenance"
```
### DOWN {#down-impact}
**Visual:** Red, X icon
@@ -514,6 +530,6 @@ Description: "Database will be completely offline during this window"
## Next Steps {#next-steps}
- [Maintenance Events](/docs/maintenances/events) - Learn about event lifecycle and status transitions
- [Creating and Managing Maintenances](/docs/maintenances/creating-managing) - How to configure monitor impacts
- [RRULE Patterns](/docs/maintenances/rrule-patterns) - Advanced scheduling patterns
- [Maintenance Events](/docs/v4/maintenances/events) - Learn about event lifecycle and status transitions
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing) - How to configure monitor impacts
- [RRULE Patterns](/docs/v4/maintenances/rrule-patterns) - Advanced scheduling patterns
@@ -90,75 +90,92 @@ Kener uses the industry-standard [iCalendar RRULE](http://www.kanzaki.com/docs/i
FREQ=frequency;[INTERVAL=n;][BYDAY=days;][COUNT=n;]
```
title: Maintenances Overview
description: Plan and communicate scheduled service work with one-time or recurring maintenance windows
| Pattern | RRULE | Meaning |
**Example Patterns:**
| Pattern | RRULE | Meaning |
| :---------------------- | :--------------------------------- | :-------------------- |
Maintenances are planned service windows. Use them to communicate expected downtime or degradation before work starts.
| Every 2 weeks on Monday | `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO` | Bi-weekly on Monday |
| Every Sunday | `FREQ=WEEKLY;BYDAY=SU` | Weekly on Sunday |
| Every 2 weeks on Monday | `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO` | Bi-weekly on Monday |
| Every day | `FREQ=DAILY` | Daily |
| First of each month | `FREQ=MONTHLY;BYMONTHDAY=1` | Monthly on day 1 |
| Weekdays only | `FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR` | Monday through Friday |
## What a maintenance includes {#what-a-maintenance-includes}
Learn more in [RRULE Patterns](/docs/v4/maintenances/rrule-patterns).
| First of each month | `FREQ=MONTHLY;BYMONTHDAY=1` | Monthly on day 1 |
Each maintenance has:
## Maintenance Events {#maintenance-events}
When you create a maintenance, Kener automatically generates **maintenance events** based on the RRULE:
- Title and optional description
- Start time
- Duration
- Schedule (`one-time` or recurring `RRULE`)
- Affected monitors with impact
- Status (`ACTIVE` / `INACTIVE`)
- **One-Time Maintenances:** Generate 1 event at creation time
- **Recurring Maintenances:** Generate events for the next 7 days, refreshed hourly
## Maintenance vs incident {#maintenance-vs-incident}
Each event represents a single occurrence of the maintenance window and tracks:
| Aspect | Maintenance | Incident |
| ---------- | --------------------------- | ----------------------------- |
| Nature | Planned | Unplanned |
| Timing | Scheduled in advance | Created when issue occurs |
| Recurrence | Can recur with RRULE | Typically one-off |
| Purpose | Communicate expected impact | Communicate active disruption |
- Start date/time
- End date/time (calculated from duration)
- Status (SCHEDULED → READY → ONGOING → COMPLETED, or CANCELLED)
## One-time vs recurring {#one-time-vs-recurring}
Events are covered in detail in [Maintenance Events](/docs/v4/maintenances/events).
## Maintenance Status {#maintenance-status}
Maintenances have two levels of status:
- **One-time**: single event (`FREQ=MINUTELY;COUNT=1`)
- **Recurring**: repeated events from RRULE (for example weekly/monthly patterns)
### Maintenance-Level Status {#maintenance-level-status}
## Monitor impact during maintenance {#monitor-impact-during-maintenance}
Controls whether the maintenance is active in the system:
Set per-monitor impact for the maintenance window:
- **ACTIVE** - Maintenance is enabled and will generate events
- `MAINTENANCE` (recommended for planned work)
- `DOWN`
- `DEGRADED`
- `UP` (rare)
- **INACTIVE** - Maintenance is disabled and will not affect monitors
When an event is ongoing, this impact can override realtime status shown to users.
**Note:** Changing a maintenance to INACTIVE does not cancel already scheduled events. You must manually cancel or delete those events.
## Event lifecycle {#event-lifecycle}
### Event-Level Status {#event-level-status}
Each occurrence is a maintenance event that moves through statuses:
Tracks the lifecycle of each individual maintenance occurrence:
- `SCHEDULED`
- `READY` (starting soon)
- `ONGOING`
- `COMPLETED`
- `CANCELLED`
- **SCHEDULED** - Event created, more than 60 minutes away
- **READY** - Event starts within 60 minutes (notification sent)
## Related guides {#related-guides}
- **ONGOING** - Event is currently in progress
- **COMPLETED** - Event has finished
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing)
- [Maintenance Events](/docs/v4/maintenances/events)
- [Impact on Monitoring](/docs/v4/maintenances/impact-on-monitoring)
- [RRULE Patterns](/docs/v4/maintenances/rrule-patterns)
- **CANCELLED** - Event was manually cancelled
Events automatically transition through states based on the current time. An ONGOING event can also be completed early, and SCHEDULED/READY/ONGOING events can be cancelled — see [Managing Events Manually](/docs/v4/maintenances/events#managing-events-manually).
## Affected Monitors {#affected-monitors}
Each maintenance specifies which monitors are affected and their expected status during the maintenance window:
**Monitor Impact Options:**
- **MAINTENANCE** - Show as under maintenance (recommended)
- **DOWN** - Show as completely unavailable
- **DEGRADED** - Show as partially available
- **UP** - Show as operational (rare, for non-disruptive maintenance)
When a maintenance event is ONGOING, the specified impact **overrides** the monitor's realtime status on the status page.
Learn more in [Maintenance Impact on Monitoring](/docs/v4/maintenances/impact-on-monitoring).
## Public Visibility {#public-visibility}
Maintenances are visible to users on your public status page:
### Upcoming Maintenances {#upcoming-maintenances}
- Shown on the home page
- Displays upcoming events (configurable days ahead)
- Shows affected monitors and maintenance window
### Ongoing Maintenances {#ongoing-maintenances}
- Prominently displayed during the maintenance window
- Affected monitors show maintenance status
- Duration and progress indicators
### Past Maintenances {#past-maintenances}
- Listed on the events/history page
- Shows completed maintenance events
- Configurable retention period
@@ -214,11 +231,16 @@ When maintenance events change status, notifications can be sent:
- "Maintenance in progress" notification
- Confirms maintenance has begun
**COMPLETED (when finished):**
**COMPLETED (when finished, or completed early):**
- "Maintenance completed" notification
- Confirms services are back to normal
**CANCELLED (when manually cancelled):**
- "Maintenance cancelled" notification
- Shares the same "ended" notification setting as completion
Notifications respect your subscription configuration and trigger settings.
## Use Cases {#use-cases}
@@ -255,7 +277,7 @@ Impact: DOWN
## Next Steps {#next-steps}
- [Creating and Managing Maintenances](/docs/maintenances/creating-managing) - Learn how to create and configure maintenances
- [Maintenance Events](/docs/maintenances/events) - Understand event lifecycle and management
- [Maintenance Impact on Monitoring](/docs/maintenances/impact-on-monitoring) - How maintenances affect status display
- [RRULE Patterns](/docs/maintenances/rrule-patterns) - Advanced scheduling patterns and examples
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing) - Learn how to create and configure maintenances
- [Maintenance Events](/docs/v4/maintenances/events) - Understand event lifecycle and management
- [Maintenance Impact on Monitoring](/docs/v4/maintenances/impact-on-monitoring) - How maintenances affect status display
- [RRULE Patterns](/docs/v4/maintenances/rrule-patterns) - Advanced scheduling patterns and examples
@@ -18,71 +18,63 @@ FREQ=frequency[;INTERVAL=n][;BYDAY=days][;BYMONTHDAY=day][;COUNT=n][;UNTIL=date]
### Supported Components {#supported-components}
**FREQ** (Required)
title: RRULE Patterns
description: Common RRULE patterns for recurring maintenance schedules
- `DAILY` - Daily recurrence
- `WEEKLY` - Weekly recurrence
- `MONTHLY` - Monthly recurrence
Kener uses iCalendar RRULE strings to schedule recurring maintenances.
**INTERVAL** (Optional)
## RRULE basics {#rrule-basics}
**INTERVAL** (Optional)
- Default: 1
Format:
- Integer value: 2 = every other, 3 = every third, etc.
```text
FREQ=...;[INTERVAL=n];[BYDAY=...];[BYMONTHDAY=...]
```
**BYDAY** (Optional, for WEEKLY)
- Day codes: `MO`, `TU`, `WE`, `TH`, `FR`, `SA`, `SU`
- Multiple days: `MO,WE,FR`
For one-time maintenances, Kener uses:
**BYMONTHDAY** (Optional, for MONTHLY)
```text
FREQ=MINUTELY;COUNT=1
```
- Day of month: 1-31
- Example: `BYMONTHDAY=1` (first of month)
## Common patterns {#common-patterns}
| Use case | RRULE |
| ----------------------- | ---------------------------------- |
| Every day | `FREQ=DAILY` |
| Every Sunday | `FREQ=WEEKLY;BYDAY=SU` |
| Weekdays | `FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR` |
| Every 2 weeks on Monday | `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO` |
| First day of each month | `FREQ=MONTHLY;BYMONTHDAY=1` |
**COUNT** (For one-time only)
- `COUNT=1` - Single occurrence
- Used for one-time maintenances
## Day codes {#day-codes}
## Common Patterns {#common-patterns}
- `MO` Monday
- `TU` Tuesday
- `WE` Wednesday
- `TH` Thursday
- `FR` Friday
- `SA` Saturday
- `SU` Sunday
### Daily Patterns {#daily-patterns}
## Tips {#tips}
#### Every Day {#every-day}
- Use simple, readable patterns.
- Verify next occurrences in the maintenance UI preview.
- Avoid overlapping schedules for the same monitor.
**Description:** Maintenance occurs every single day at the configured time
**RRULE:** `FREQ=DAILY`
## Related guides {#related-guides}
**Description:** Maintenance occurs every single day at the configured time
**Use Case:** Daily backup windows, nightly cleanup tasks
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing)
- [Maintenance Events](/docs/v4/maintenances/events)
- [Maintenances Overview](/docs/v4/maintenances/overview)
**Description:** Maintenance occurs every 2 days
**Example:**
```
Title: Nightly Database Backup
Start: 2:00 AM (any day)
RRULE: FREQ=DAILY
Duration: 30 minutes
Occurrences:
- Today at 2:00 AM
- Tomorrow at 2:00 AM
- Day after at 2:00 AM
- Continues daily...
```
#### Every Other Day {#every-other-day}
**RRULE:** `FREQ=DAILY;INTERVAL=2`
**Description:** Maintenance occurs every 2 days
**Use Case:** Bi-daily tasks
@@ -625,6 +617,6 @@ Description: |
## Next Steps {#next-steps}
- [Creating and Managing Maintenances](/docs/maintenances/creating-managing) - Apply these patterns in the dashboard
- [Maintenance Events](/docs/maintenances/events) - Understand how RRULE generates events
- [Maintenance Overview](/docs/maintenances/overview) - Learn maintenance fundamentals
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing) - Apply these patterns in the dashboard
- [Maintenance Events](/docs/v4/maintenances/events) - Understand how RRULE generates events
- [Maintenance Overview](/docs/v4/maintenances/overview) - Learn maintenance fundamentals
@@ -0,0 +1,86 @@
---
title: Grace Period (Confirmation Threshold)
description: Require N consecutive checks before a status change is recorded, so transient blips don't count as downtime
---
A grace period makes a monitor ignore transient flapping: its recorded status changes only after several consecutive checks agree. A single failed ping no longer registers as downtime or dents your uptime %.
## How it works {#how-it-works}
The grace period is a count of consecutive checks (internally the **Confirmation Threshold**). With a grace period of `N`, a monitor's recorded status flips between healthy (`UP`) and unhealthy (`DOWN`/`DEGRADED`) only after `N` consecutive checks land on the new side. Shorter runs are treated as transient and never appear as a status change.
It is **count-based, not time-based**: `5` means 5 consecutive checks. On an every-minute cron that is about 5 minutes.
Example — grace period `5`, every-minute checks:
```
11:05 ping fails → still UP (1/5)
11:06 ping fails → still UP (2/5)
11:07 ping fails → still UP (3/5)
11:08 ping fails → still UP (4/5)
11:09 ping fails → DOWN, recorded from 11:05 (5/5)
```
If the check recovers before the 5th failure, the whole stretch stays `UP`.
> [!NOTE]
> The change is **retroactive**. When an outage is confirmed it is recorded from its first failing check, not the moment of confirmation, so start times stay accurate. Recovery is symmetric — the monitor returns to `UP` from the first recovering check, again after `N` consecutive successes.
## Enable it {#enable}
Set it per monitor in **Manage → Monitors → _(monitor)_ → General Settings → Grace period**.
| Setting | Values | Default | Meaning |
|---|---|---|---|
| Grace period (`confirmation_threshold`) | integer `1``60` | `1` | Consecutive checks required to confirm a status change. `1` disables damping (every check is recorded immediately). |
> [!NOTE]
> A grace period of `1` is the default and reproduces Kener's previous behavior exactly — no damping, no delay.
## API {#api}
Set `confirmation_threshold` when creating or updating a monitor:
```json
PATCH /api/v4/monitors/{monitor_tag}
{ "confirmation_threshold": 5 }
```
It is validated as an integer between `1` and `60`. See the [API Reference](/docs/v4/api-reference).
## What gets damped {#scope}
The grace period damps the **recorded timeline** — status bars, current status, and uptime % — not just alerts. Everything that reads a monitor's status sees the confirmed value.
- Applies to **all monitor types** uniformly (a no-op for monitors that never flap).
- Counts only scheduled checks.
- `DEGRADED` counts as "not `UP`": a service flapping between `DOWN` and `DEGRADED` still confirms unhealthy. Changing severity *within* an already-unhealthy stretch (`DOWN``DEGRADED`) is recorded immediately.
## During the grace window {#during-window}
While a change is pending confirmation, the monitor keeps showing its current confirmed status (for example, still `UP`). No diagnostic data is lost: the held sample keeps its **real latency** and its **error text**, tagged `| Status held during grace period`. Once confirmed, the backfilled samples append `| Down confirmed after N consecutive checks`.
## Interactions {#interactions}
- **Alerts** — [alert failure/success thresholds](/docs/v4/alerting/alert-configurations) evaluate the already-damped timeline, so the effective delay is the **larger** of the grace period and the alert threshold (a max, not a sum).
- **Maintenance & incidents** — while a [maintenance](/docs/v4/maintenances/impact-on-monitoring) or incident overlay is active, it wins the display and the grace count **freezes**; a fresh count begins when normal monitoring resumes.
- **NO_DATA** — a no-data check stays grey, is **neutral** to the count (neither advances nor resets it), and is never rewritten.
- **Manual / API pushes & default status** — data-API pushes and default-status fill pass through undamped and do not move the count.
- **Group monitors** — a [group](/docs/v4/monitors/group) scores on its members' confirmed status, so member grace periods propagate automatically. Set a grace period on the group itself only if you also want to damp the aggregate.
- **Heartbeat** — stacks on top of the heartbeat's own down/degraded timing.
## Verify {#verify}
1. Set a grace period of `3` on a test monitor and save.
2. Make the target fail for 2 checks → status stays `UP`.
3. Let it fail a 3rd consecutive check → status flips to `DOWN` and the prior 2 samples backfill to `DOWN`.
4. Restore the target → after 3 consecutive successes it returns to `UP`.
> [!TIP]
> Pick a grace period slightly longer than your typical transient blip but short enough to still catch real outages quickly. For an every-minute monitor, `3``5` is a good starting point.
## Related docs {#related}
- [Monitors Overview](/docs/v4/monitors/overview)
- [Alert Configurations](/docs/v4/alerting/alert-configurations)
- [Maintenance impact on monitoring](/docs/v4/maintenances/impact-on-monitoring)
@@ -54,6 +54,7 @@ UP + MAINTENANCE + DEGRADED + DOWN
## Related docs {#related-docs}
- [Grace Period](/docs/v4/monitors/grace-period) — require N consecutive checks before a status change is recorded
- [Monitors (general)](/docs/v4/monitors)
- [Sharing Monitors](/docs/v4/sharing)
- [Alert Configurations](/docs/v4/alerting/alert-configurations)
@@ -97,6 +97,10 @@ Example:
delete services
```
## Manage pages via API {#manage-pages-via-api}
Pages support full CRUD through the v4 REST API (`/api/v4/pages`), including assigning monitors. The home page has an empty stored path, so the API addresses it with the special segment `~home` (for example `PATCH /api/v4/pages/~home`); its path cannot be changed and it cannot be deleted. See the [API Reference](/docs/spec/v4/) for endpoints and schemas.
## Tips {#tips}
- Keep page paths short and stable (changing links later is disruptive).
@@ -89,6 +89,26 @@ Use when MySQL/MariaDB is your standard stack.
DATABASE_URL=mysql://kener:password@localhost:3306/kener
```
## Connection pool tuning {#connection-pool-tuning}
For PostgreSQL and MySQL, Kener ships fail-fast, self-healing pool defaults: no permanently-idle connections, TCP keepalive on, and 15-second connection timeouts. This protects deployments on cloud networks (Railway, Docker Swarm overlays, Kubernetes) that silently drop idle TCP connections, which otherwise causes 500s after idle periods and can require a restart after a database outage.
Override only if your setup needs it:
| Variable | Description | Default |
| ----------------------------- | --------------------------------------------------------------- | ------- |
| `DATABASE_POOL_MIN` | Minimum pool connections (0 lets idle connections be reclaimed) | `0` |
| `DATABASE_POOL_MAX` | Maximum pool connections | `10` |
| `DATABASE_ACQUIRE_TIMEOUT_MS` | How long a query waits for a free connection before failing | `15000` |
| `DATABASE_CREATE_TIMEOUT_MS` | How long a new connection attempt waits before failing | `15000` |
| `DATABASE_IDLE_TIMEOUT_MS` | How long a connection may sit idle before being closed | `30000` |
| `DATABASE_KEEPALIVE` | TCP keepalive on connections (`true`/`false`) | `true` |
> [!TIP]
> If your database is slow to accept connections (cold starts, cross-region), raise `DATABASE_ACQUIRE_TIMEOUT_MS` and `DATABASE_CREATE_TIMEOUT_MS` instead of disabling keepalive or raising `DATABASE_POOL_MIN`.
These variables have no effect on SQLite.
## Switching databases {#switching-databases}
1. Backup/export data.
@@ -103,9 +123,13 @@ DATABASE_URL=mysql://kener:password@localhost:3306/kener
- Connection failed: verify host, port, credentials, firewall.
- Migration failed: ensure DB exists and user can `CREATE`/`ALTER`.
- SQLite write error: ensure directory exists and is writable.
- `KnexTimeoutError: Timeout acquiring a connection`: the database is unreachable or too slow to accept connections — check database health first, then see [Connection pool tuning](#connection-pool-tuning).
- `Connection terminated unexpectedly` after idle periods: the network dropped an idle connection; keepalive (on by default) prevents this — verify `DATABASE_KEEPALIVE` is not set to `false`.
## Environment variables {#environment-variables}
| Variable | Description | Default | Required |
| -------------- | -------------------------- | ------------------------------------- | -------- |
| `DATABASE_URL` | Database connection string | `sqlite://./database/kener.sqlite.db` | No |
Pool tuning variables are listed in [Connection pool tuning](#connection-pool-tuning).
@@ -285,10 +285,21 @@ curl -fsS https://your-domain/healthcheck
Expected response body:
```text
ok
```json
{ "status": "ok", "db": true, "redis": true }
```
`status` is `degraded` when the database or Redis is unreachable. The endpoint always returns HTTP 200 so healthcheck-driven restarters do not bounce the app while a dependency is down.
For orchestrators that should act on dependency health (load balancer readiness, alerting), pass `?strict=1` to get HTTP 503 when any component is down:
```bash
curl -fsS https://your-domain/healthcheck?strict=1
```
> [!WARNING]
> Do not point a restart-on-failure healthcheck (Docker `HEALTHCHECK`, Railway) at `?strict=1` — restarting Kener can not fix a dead database and will loop for the whole outage.
## Next steps {#next-steps}
- For reverse proxy and TLS setup, continue with [Reverse Proxy Setup](/docs/v4/guides/reverse-proxy).
@@ -238,9 +238,15 @@ SMTP_SECURE=1
### Database Configuration {#database-configuration}
| Variable | Description | Default |
| :------------- | :------------------------------ | :----------------------------- |
| `DATABASE_URL` | Full database connection string | `sqlite://./database/kener.db` |
| Variable | Description | Default |
| :---------------------------- | :----------------------------------------------------------- | :------------------------------------ |
| `DATABASE_URL` | Full database connection string | `sqlite://./database/kener.sqlite.db` |
| `DATABASE_POOL_MIN` | Minimum pool connections (PostgreSQL/MySQL) | `0` |
| `DATABASE_POOL_MAX` | Maximum pool connections (PostgreSQL/MySQL) | `10` |
| `DATABASE_ACQUIRE_TIMEOUT_MS` | Wait for a free connection before failing (PostgreSQL/MySQL) | `15000` |
| `DATABASE_CREATE_TIMEOUT_MS` | Wait for a new connection before failing (PostgreSQL/MySQL) | `15000` |
| `DATABASE_IDLE_TIMEOUT_MS` | Idle time before a connection is closed (PostgreSQL/MySQL) | `30000` |
| `DATABASE_KEEPALIVE` | TCP keepalive on connections (PostgreSQL/MySQL) | `true` |
**Supported Databases**:
@@ -252,7 +258,7 @@ SMTP_SECURE=1
```bash
# SQLite (default)
DATABASE_URL=sqlite://./database/kener.db
DATABASE_URL=sqlite://./database/kener.sqlite.db
# PostgreSQL
DATABASE_URL=postgresql://user:password@localhost:5432/kener
@@ -261,7 +267,7 @@ DATABASE_URL=postgresql://user:password@localhost:5432/kener
DATABASE_URL=mysql://user:password@localhost:3306/kener
```
📖 **See**: [Database Setup Guide](/docs/v4/setup/database-setup) for migration guides and best practices.
📖 **See**: [Database Setup Guide](/docs/v4/setup/database-setup) for migration guides and [connection pool tuning](/docs/v4/setup/database-setup#connection-pool-tuning) for when to change the pool variables.
### Redis Configuration {#redis-configuration}
@@ -478,7 +484,7 @@ Create a `.env` file in the project root:
```bash
# .env
KENER_SECRET_KEY=dev-secret-key
DATABASE_URL=sqlite://./database/kener.db
DATABASE_URL=sqlite://./database/kener.sqlite.db
# Custom variables
API_KEY=test-key-123
@@ -18,6 +18,26 @@ From **Manage → Badges**, you can generate:
You can customize style, label, colors, and time range.
### All-monitors badge {#all-monitors-badge}
Status and dot badges accept `_` instead of a monitor tag:
```
/badge/_/status
/badge/_/dot
```
The `_` badge shows the overall status of **every active, non-hidden monitor site-wide**. It is not scoped to a page, so on a multi-page setup it may not match an individual page's banner.
### Overall status priority {#overall-status-priority}
When multiple monitor statuses collapse into one overall status (the page banner and the `_` badge), the worst state wins:
`DOWN` > `DEGRADED` > `MAINTENANCE` > `UP`
> [!NOTE]
> Maintenance never masks an active problem: a page with one monitor under maintenance and another down or degraded reports the outage, not the maintenance. `NO_DATA` is shown only when no monitor has reported any data.
### Embeds {#embeds}
From **Manage → Embed**, you can generate:
@@ -1,75 +1,81 @@
---
title: User Management
description: Manage users, roles, invitations, and role permissions in Kener
description: Manage users, roles, permissions, and invitations in Kener
---
Use **Manage → Users** to invite teammates, control access, and manage account status.
Use **Manage → Users** to invite teammates and manage account status. Use **Manage → Roles** to control access with fine-grained permissions.
## Roles overview {#roles-overview}
## Roles and permissions {#roles-and-permissions}
Kener uses three roles:
Kener uses a role-based access control (RBAC) system. Each user can be assigned one or more **roles**, and each role has a set of **permissions** that determine what actions the user can perform.
| Role | What it means |
| -------- | ------------------------------------------------------------------------------------------------------------ |
| `admin` | Full access, including user administration and vault/API-key level operations |
| `editor` | Can run day-to-day operations (monitors, incidents, maintenances, site settings) but cannot administer users |
| `member` | Limited access; cannot administer users or change system settings |
### Built-in roles {#built-in-roles}
## What each role can do {#what-each-role-can-do}
Three readonly roles are seeded automatically:
### Admin {#admin}
| Role | Permissions | Notes |
| -------- | ----------- | ----- |
| `admin` | All permissions | Full access including `api_keys.delete` |
| `editor` | All except `api_keys.delete` | Day-to-day operations |
| `member` | All `.read` permissions only | View-only access |
Admin can:
Built-in roles cannot be edited or deleted.
- invite users
- resend invitations
- change user role
- activate/deactivate users
- send verification email to any user
- perform all editor-level operational actions
- manage admin-only areas like vault and certain privileged API actions
### Custom roles {#custom-roles}
Admin invite permissions:
From **Manage → Roles**, users with the `roles.write` permission can create custom roles:
- admin can invite `admin`, `editor`, and `member`
1. Click **Create Role**.
2. Enter a role ID (lowercase, numbers, underscores, hyphens) and display name.
3. Optionally clone permissions from an existing role.
4. After creation, assign permissions in the **Permissions** panel.
Admin user-management restrictions:
Custom roles can be edited, deactivated, or deleted. When deleting a custom role, you can either remove user assignments or migrate them to another role.
- non-owner admin cannot modify other admins
- owner admin can modify other admins (role update and activate/deactivate)
### Permission domains {#permission-domains}
### Editor {#editor}
Permissions follow a `domain.action` format:
Editor can:
| Domain | Actions |
| ------ | ------- |
| `monitors` | `read`, `write` |
| `incidents` | `read`, `write` |
| `maintenances` | `read`, `write` |
| `pages` | `read`, `write` |
| `triggers` | `read`, `write` |
| `alerts` | `read`, `write` |
| `api_keys` | `read`, `write`, `delete` |
| `users` | `read`, `write` |
| `settings` | `read`, `write` |
| `subscribers` | `read`, `write` |
| `email_templates` | `read`, `write` |
| `images` | `write` |
| `roles` | `read`, `write`, `assign_permissions`, `assign_users` |
- invite users
Permissions are enforced at both the **route level** (page access) and the **action level** (API operations).
### Managing role permissions {#managing-role-permissions}
From the roles table, click **Permissions** on any role to view or edit its permissions. Permissions are grouped by domain and can be toggled individually. Readonly (built-in) roles show permissions in read-only mode.
### Managing role users {#managing-role-users}
Click **Users** on any role to see assigned users. Users with `roles.assign_users` permission can add or remove users from roles.
## User management {#user-management}
Users with the `users.write` permission can:
- invite new users
- resend invitation emails
- manage monitors, incidents, maintenances, alerts, triggers, pages, subscriptions, and site data
Editor invite permissions:
- editor can invite `editor` and `member`
Editor cannot:
- change user roles
- update user roles
- activate/deactivate users
- perform admin-only user administration actions
- send verification emails
### Member {#member}
Owner-specific restrictions:
Member can:
- sign in and use allowed views
- send verification email for their own account (if unverified)
Member cannot:
- invite users
- resend invitations
- change roles
- activate/deactivate other users
- perform admin/editor configuration actions
- the owner must always retain the `admin` role
- the owner account cannot be deactivated
## Invite flow {#invite-flow}
@@ -79,19 +85,14 @@ Member cannot:
From **Manage → Users**:
1. Click **Add User**.
2. Enter name, email, and role.
2. Enter name, email, and select one or more roles.
3. Invitation email is sent with a secure token link.
Role options in **Add User** are filtered by your role:
- admin: `admin`, `editor`, `member`
- editor: `editor`, `member`
- member: no access to Add User
Current behavior:
- invited user is created with inactive account and empty password
- invitation token expires after 7 days
- all selected roles must be active
## How users accept invitation {#how-users-accept-invitation}
@@ -106,19 +107,20 @@ If link is invalid, expired, or already used, invitation page shows an error and
## Verification emails {#verification-emails}
- Admin/editor can send verification email to users.
- Member can only trigger verification for their own account.
- Users with `users.write` permission can send verification emails to other users.
- Any user can trigger verification for their own account (if unverified).
## Common user management tasks {#common-user-management-tasks}
## Common tasks {#common-tasks}
- **Promote/demote user**: admin updates role in user settings sheet. Non-owner admins cannot change other admins.
- **Deactivate user**: admin toggles account inactive (session access removed). Non-owner admins cannot deactivate other admins.
- **Change user roles**: open user settings sheet, toggle roles, and click **Update Roles**. Users can be assigned multiple roles simultaneously.
- **Deactivate user**: toggle account inactive in user settings sheet. Existing sessions are invalidated.
- **Re-invite user**: resend invitation if user has not set password yet.
## UI behavior notes {#ui-behavior-notes}
- The current signed-in user is highlighted in the users table.
- For non-owner admins, admin targets do not show admin-management actions.
- Users table can be filtered by active/inactive status.
- Role badges show the user's assigned role IDs.
## Requirements and dependencies {#requirements-and-dependencies}
+8 -8
View File
@@ -7,29 +7,29 @@
import IncidentItem from "$lib/components/IncidentItem.svelte";
import MaintenanceItem from "$lib/components/MaintenanceItem.svelte";
import mdToHTML from "$lib/marked.js";
import clientResolver from "$lib/client/resolver.js";
import clientResolver, { absoluteResolve } from "$lib/client/resolver.js";
import { resolve } from "$app/paths";
import { selectedTimezone } from "$lib/stores/timezone";
import { getEndOfDayAtTz } from "$lib/client/datetime";
import { requestMonitorBar } from "$lib/client/monitor-bar-client";
import type { MonitorBarResponse } from "$lib/server/api-server/monitor-bar/get";
import { SveltePurify } from "@humanspeak/svelte-purify";
import type { PageMonitorLayoutStyle } from "$lib/types/api";
import GC from "$lib/global-constants.js";
let { data } = $props();
let pageSettings = $derived(data.pageDetails.page_settings);
let barCount = $derived.by(() =>
data.isMobile
? pageSettings?.monitor_status_history_days.mobile || 30
: pageSettings?.monitor_status_history_days.desktop || 90
? pageSettings?.monitor_status_history_days.mobile || GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE
: pageSettings?.monitor_status_history_days.desktop || GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP
);
let endOfDayTodayAtTz = $derived(getEndOfDayAtTz($selectedTimezone));
let monitorBarDataByTag = $state<Record<string, MonitorBarResponse>>({});
let monitorBarErrorByTag = $state<Record<string, string>>({});
let requestVersion = 0;
let viewType = $derived<"compact-list" | "default-list" | "default-grid" | "compact-grid" | undefined>(
pageSettings?.monitor_layout_style
);
let viewType = $derived<PageMonitorLayoutStyle | undefined>(pageSettings?.monitor_layout_style);
let isCompact = $derived(viewType === "compact-list" || viewType === "compact-grid");
function getGridItemSpanClass(index: number, total: number, type: typeof viewType): string {
@@ -136,8 +136,8 @@
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
{#if data.socialPagePreviewImage}
<meta property="og:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
<meta name="twitter:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
<meta property="og:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPagePreviewImage)} />
<meta name="twitter:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPagePreviewImage)} />
{/if}
</svelte:head>
+8 -8
View File
@@ -7,29 +7,29 @@
import IncidentItem from "$lib/components/IncidentItem.svelte";
import MaintenanceItem from "$lib/components/MaintenanceItem.svelte";
import mdToHTML from "$lib/marked.js";
import clientResolver from "$lib/client/resolver.js";
import clientResolver, { absoluteResolve } from "$lib/client/resolver.js";
import { resolve } from "$app/paths";
import { selectedTimezone } from "$lib/stores/timezone";
import { getEndOfDayAtTz } from "$lib/client/datetime";
import { requestMonitorBar } from "$lib/client/monitor-bar-client";
import type { MonitorBarResponse } from "$lib/server/api-server/monitor-bar/get";
import { SveltePurify } from "@humanspeak/svelte-purify";
import type { PageMonitorLayoutStyle } from "$lib/types/api";
import GC from "$lib/global-constants.js";
let { data } = $props();
let pageSettings = $derived(data.pageDetails.page_settings);
let barCount = $derived.by(() =>
data.isMobile
? pageSettings?.monitor_status_history_days.mobile || 30
: pageSettings?.monitor_status_history_days.desktop || 90
? pageSettings?.monitor_status_history_days.mobile || GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE
: pageSettings?.monitor_status_history_days.desktop || GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP
);
let endOfDayTodayAtTz = $derived(getEndOfDayAtTz($selectedTimezone));
let monitorBarDataByTag = $state<Record<string, MonitorBarResponse>>({});
let monitorBarErrorByTag = $state<Record<string, string>>({});
let requestVersion = 0;
let viewType = $derived<"compact-list" | "default-list" | "default-grid" | "compact-grid" | undefined>(
pageSettings?.monitor_layout_style
);
let viewType = $derived<PageMonitorLayoutStyle | undefined>(pageSettings?.monitor_layout_style);
let isCompact = $derived(viewType === "compact-list" || viewType === "compact-grid");
function getGridItemSpanClass(index: number, total: number, type: typeof viewType): string {
@@ -136,8 +136,8 @@
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
{#if data.socialPagePreviewImage}
<meta property="og:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
<meta name="twitter:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
<meta property="og:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPagePreviewImage)} />
<meta name="twitter:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPagePreviewImage)} />
{/if}
</svelte:head>
@@ -13,7 +13,7 @@
import { t } from "$lib/stores/i18n";
import { formatDate } from "$lib/stores/datetime";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
import clientResolver, { absoluteResolve } from "$lib/client/resolver.js";
import { format, parse, addMonths, subMonths, getUnixTime, startOfDay, formatDistanceStrict } from "date-fns";
import { page } from "$app/state";
import type { IncidentForMonitorListWithComments, MaintenanceEventsMonitorList } from "$lib/server/types/db";
@@ -165,8 +165,8 @@
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
{#if data.socialPreviewImage}
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
<meta property="og:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
<meta name="twitter:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
{/if}
</svelte:head>
@@ -13,7 +13,7 @@
import { t } from "$lib/stores/i18n";
import { formatDate } from "$lib/stores/datetime";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
import clientResolver, { absoluteResolve } from "$lib/client/resolver.js";
import { format, parse, addMonths, subMonths, getUnixTime, startOfDay, formatDistanceStrict } from "date-fns";
import { page } from "$app/state";
import type { IncidentForMonitorListWithComments, MaintenanceEventsMonitorList } from "$lib/server/types/db";
@@ -165,8 +165,8 @@
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
{#if data.socialPreviewImage}
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
<meta property="og:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
<meta name="twitter:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
{/if}
</svelte:head>
@@ -12,7 +12,7 @@
import { SveltePurify } from "@humanspeak/svelte-purify";
import { t } from "$lib/stores/i18n";
import { formatDate, formatDuration } from "$lib/stores/datetime";
import clientResolver from "$lib/client/resolver.js";
import clientResolver, { absoluteResolve } from "$lib/client/resolver.js";
import { page } from "$app/state";
let { data } = $props();
@@ -28,8 +28,8 @@
<meta property="og:description" content={data.comments[0].comment} />
{/if}
{#if data.socialPreviewImage}
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
<meta property="og:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
<meta name="twitter:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
{/if}
</svelte:head>
@@ -15,7 +15,7 @@
import STATUS_ICON from "$lib/icons";
import { t } from "$lib/stores/i18n";
import { formatDate, formatDuration } from "$lib/stores/datetime";
import clientResolver from "$lib/client/resolver.js";
import clientResolver, { absoluteResolve } from "$lib/client/resolver.js";
import { SveltePurify } from "@humanspeak/svelte-purify";
import { page } from "$app/state";
@@ -73,8 +73,8 @@
<meta property="og:description" content={data.maintenance.description} />
{/if}
{#if data.socialPreviewImage}
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
<meta property="og:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
<meta name="twitter:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
{/if}
</svelte:head>
@@ -83,11 +83,13 @@ export const load: PageServerLoad = async ({ params, parent }) => {
}
}
let maxDays = parentData.isMobile ? 30 : 90;
let maxDays: number = parentData.isMobile
? GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE
: GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP;
if (monitor.monitor_settings_json?.monitor_status_history_days) {
maxDays = parentData.isMobile
? monitor.monitor_settings_json.monitor_status_history_days.mobile || 30
: monitor.monitor_settings_json.monitor_status_history_days.desktop || 90;
? monitor.monitor_settings_json.monitor_status_history_days.mobile || GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE
: monitor.monitor_settings_json.monitor_status_history_days.desktop || GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP;
}
return {
...{
@@ -6,7 +6,7 @@
import ThemePlus from "$lib/components/ThemePlus.svelte";
import MonitorOverview from "$lib/components/MonitorOverview.svelte";
import ArrowUpRight from "@lucide/svelte/icons/arrow-up-right";
import clientResolver from "$lib/client/resolver.js";
import clientResolver, { absoluteResolve } from "$lib/client/resolver.js";
import { resolve } from "$app/paths";
import trackEvent from "$lib/beacon";
import IncidentItem from "$lib/components/IncidentItem.svelte";
@@ -37,8 +37,8 @@
<meta property="og:description" content={data.monitorDescription} />
{/if}
{#if data.socialPreviewImage}
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
<meta property="og:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
<meta name="twitter:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
{/if}
</svelte:head>
<div class="flex flex-col gap-3">
+20 -2
View File
@@ -2,8 +2,11 @@ import { redirect } from "@sveltejs/kit";
import MobileDetect from "mobile-detect";
import type { LayoutServerLoad } from "./$types";
import { IsEmailSetup } from "$lib/server/controllers/controller.js";
import { RequirePermission } from "$lib/server/controllers/userController.js";
import seedSiteData from "$lib/server/db/seedSiteData.js";
import serverResolve from "$lib/server/resolver.js";
import { ROUTE_PERMISSION_MAP } from "$lib/allPerms.js";
import { error } from "@sveltejs/kit";
import { resolve } from "$app/paths";
import {
@@ -12,8 +15,9 @@ import {
GetLoggedInSession,
GetLocaleFromCookie,
} from "$lib/server/controllers/controller.js";
import { GetUserPermissions } from "$lib/server/controllers/userController.js";
export const load: LayoutServerLoad = async ({ cookies }) => {
export const load: LayoutServerLoad = async ({ cookies, route }) => {
let isSetupComplete = await IsSetupComplete();
if (!isSetupComplete) {
throw redirect(302, serverResolve(`/account/signin`));
@@ -27,13 +31,27 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
}
const siteData = await GetAllSiteData();
const userPermissions = await GetUserPermissions(loggedInUser.id);
const routeId = route.id || "";
const requiredPermission = ROUTE_PERMISSION_MAP[routeId];
if (requiredPermission === undefined) {
throw error(403, "Forbidden");
}
if (requiredPermission !== null) {
try {
RequirePermission(userPermissions, requiredPermission);
} catch {
throw error(403, "Forbidden");
}
}
const siteStatusColors = siteData.colors;
const siteStatusColorsDark = siteData.colorsDark || siteStatusColors;
const font = siteData.font || { cssSrc: "", family: "" };
// const emailSubscriptionTrigger = await GetSubscriptionTriggerByEmail();
return {
userDb: loggedInUser,
userPermissions: [...userPermissions],
siteStatusColors,
siteStatusColorsDark,
font,
+17 -4
View File
@@ -22,6 +22,7 @@
import BookOpenIcon from "@lucide/svelte/icons/book-open";
import KeyIcon from "@lucide/svelte/icons/key";
import UsersIcon from "@lucide/svelte/icons/users";
import ShieldIcon from "@lucide/svelte/icons/shield";
import Columns3CogIcon from "@lucide/svelte/icons/columns-3-cog";
import SiteHeader from "./manage/site-header.svelte";
import TemplateIcon from "@lucide/svelte/icons/layout-template";
@@ -30,9 +31,12 @@
import { Toaster } from "$lib/components/ui/sonner/index.js";
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import { ROUTE_PERMISSION_MAP } from "$lib/allPerms.js";
let { children, data } = $props();
// Navigation items - single source of truth
const navItems = [
const allNavItems = [
{ title: "Site Configurations", url: "/manage/app/site-configurations", icon: Settings2Icon },
{ title: "Internationalization", url: "/manage/app/internationalization", icon: GlobeIcon },
{ title: "Customizations", url: "/manage/app/customizations", icon: Columns3CogIcon },
@@ -45,17 +49,26 @@
{ title: "Alerts", url: "/manage/app/alerts", icon: SirenIcon },
{ title: "Subscriptions", url: "/manage/app/subscriptions", icon: BellIcon },
{ title: "Users", url: "/manage/app/users", icon: UsersIcon },
{ title: "Roles", url: "/manage/app/roles", icon: ShieldIcon },
{ title: "Triggers", url: "/manage/app/triggers", icon: MailboxIcon },
{ title: "Templates", url: "/manage/app/templates", icon: TemplateIcon },
{ title: "Badges", url: "/manage/app/badges", icon: BadgeIcon },
{ title: "Embed", url: "/manage/app/embed", icon: CodeIcon },
{ title: "API Keys", url: "/manage/app/api-keys", icon: KeyIcon }
].map((item) => ({ ...item, url: clientResolver(resolve, item.url) }));
];
const navItems = allNavItems
.filter((item) => {
const routeId = `/(manage)${item.url}`;
const requiredPermission = ROUTE_PERMISSION_MAP[routeId];
if (requiredPermission === undefined) return false;
if (requiredPermission === null) return true;
return (data.userPermissions ?? []).includes(requiredPermission);
})
.map((item) => ({ ...item, url: clientResolver(resolve, item.url) }));
// Derive page title from current URL
let pageTitle = $derived(navItems.find((item) => page.url.pathname.startsWith(item.url))?.title || "Dashboard");
let { children, data } = $props();
</script>
<ModeWatcher />
+71 -83
View File
@@ -74,6 +74,7 @@ import {
GetMaintenanceEventById,
GetMaintenanceEventsByMaintenanceId,
UpdateMaintenanceEvent,
UpdateMaintenanceEventStatus,
DeleteMaintenanceEvent,
AddMonitorToMaintenance,
RemoveMonitorFromMaintenance,
@@ -111,6 +112,20 @@ import {
GetGeneralEmailTemplateById,
UpdateGeneralEmailTemplate,
} from "$lib/server/controllers/generalTemplateController.js";
import {
GetAllRoles,
GetAllPermissions,
GetRolePermissions,
UpdateRolePermissions,
GetRoleUsers,
AddUserToRole,
RemoveUserFromRole,
CreateRole,
UpdateRole,
DeleteRole,
GetUserPermissions,
RequirePermission,
} from "$lib/server/controllers/userController.js";
import type { SiteDataForNotification } from "$lib/server/notification/types";
import { alertToVariables, siteDataToVariables } from "$lib/server/notification/notification_utils";
import type { TriggerMeta } from "$lib/server/types/db.js";
@@ -120,29 +135,7 @@ import sendDiscord from "$lib/server/notification/discord_notification.js";
import sendSlack from "$lib/server/notification/slack_notification.js";
import heicConvert from "heic-convert";
import serverResolver from "$lib/server/resolver.js";
function AdminCan(role: string) {
if (role !== "admin") {
throw new Error("Only Admins can perform this action");
}
}
function EditorCan(role: string) {
if (role !== "editor") {
throw new Error("Only Editors can perform this action");
}
}
function MemberCan(role: string) {
if (role !== "member") {
throw new Error("Only Member can perform this action");
}
}
function AdminEditorCan(role: string) {
if (role !== "admin" && role !== "editor") {
throw new Error("Only Admins and Editors can perform this action");
}
}
import { ACTION_PERMISSION_MAP } from "$lib/allPerms.js";
export async function POST({ request, cookies }) {
const payload = await request.json();
@@ -155,6 +148,22 @@ export async function POST({ request, cookies }) {
return json({ error: "User not logged in" }, { status: 401 });
}
// Fetch user permissions once for the entire request
const userPermissions = await GetUserPermissions(userDB.id);
// Check permission for the action
const requiredPermission = ACTION_PERMISSION_MAP[action];
if (requiredPermission === undefined) {
return json({ error: "Unknown action" }, { status: 400 });
}
if (requiredPermission !== null) {
try {
RequirePermission(userPermissions, requiredPermission);
} catch {
return json({ error: "You do not have permission to perform this action" }, { status: 403 });
}
}
try {
if (action == "updateUser") {
data.userID = userDB.id;
@@ -162,68 +171,69 @@ export async function POST({ request, cookies }) {
} else if (action == "getAllSiteData") {
resp = await GetAllSiteData();
} else if (action == "manualUpdate") {
await ManualUpdateUserData(userDB, data.id, data);
await ManualUpdateUserData(data.id, data);
resp = await GetUserByIDDashboard(data.id);
} else if (action == "updatePassword") {
data.userID = userDB.id;
resp = await UpdatePassword(data);
} else if (action == "createNewUser") {
await SendInvitationEmail(data.email, data.role, data.name, userDB.role);
await SendInvitationEmail(data.email, data.role_ids, data.name);
resp = await GetUserByEmail(data.email);
} else if (action == "resendInvitation") {
AdminEditorCan(userDB.role);
await ResendInvitationEmail(data.email, userDB.role);
await ResendInvitationEmail(data.email);
resp = { success: true };
} else if (action == "sendVerificationEmail") {
const toId = parseInt(String(data.toId));
if (!toId) {
throw new Error("User ID is required");
}
await SendVerificationEmail(toId, { id: userDB.id, role: userDB.role });
// Non-self verification requires users.write permission
if (toId !== userDB.id) {
if (!userPermissions.has("users.write")) {
return json({ error: "You do not have permission to perform this action" }, { status: 403 });
}
}
await SendVerificationEmail(toId, userDB.id);
resp = { success: true };
} else if (action == "getUsers") {
const page = parseInt(String(data.page)) || 1;
const limit = parseInt(String(data.limit)) || 10;
const users = await GetAllUsersPaginatedDashboard({ page, limit });
const totalResult = await GetUsersCount();
const filter: { is_active?: number } = {};
if (data.is_active !== undefined && data.is_active !== null) {
filter.is_active = parseInt(String(data.is_active));
}
const hasFilter = Object.keys(filter).length > 0 ? filter : undefined;
const users = await GetAllUsersPaginatedDashboard({ page, limit }, hasFilter);
const totalResult = await GetUsersCount(hasFilter);
const total = totalResult ? Number(totalResult.count) : 0;
resp = { users, total };
} else if (action === "storeSiteData") {
AdminEditorCan(userDB.role);
resp = await storeSiteData(data);
} else if (action == "storeMonitorData") {
AdminEditorCan(userDB.role);
resp = await CreateUpdateMonitor(data);
} else if (action == "updateMonitoringData") {
AdminEditorCan(userDB.role);
data.type = GC.MANUAL;
resp = await UpdateMonitoringData(data);
} else if (action == "getMonitors") {
resp = await GetMonitors(data);
} else if (action == "deleteMonitor") {
AdminEditorCan(userDB.role);
resp = await DeleteMonitorCompletelyUsingTag(data.tag);
} else if (action == "deleteMonitorData") {
AdminEditorCan(userDB.role);
await db.deleteMonitorDataByTag(data.tag || undefined, data.start, data.end);
resp = { success: true };
} else if (action == "cloneMonitor") {
AdminEditorCan(userDB.role);
resp = await CloneMonitor({
sourceTag: String(data.sourceTag || ""),
newTag: String(data.newTag || ""),
newName: String(data.newName || ""),
});
} else if (action == "createUpdateTrigger") {
AdminEditorCan(userDB.role);
resp = await CreateUpdateTrigger(data);
} else if (action == "getTriggers") {
resp = await GetAllTriggers(data);
} else if (action == "updateMonitorTriggers") {
AdminEditorCan(userDB.role);
resp = await UpdateTriggerData(data);
} else if (action == "deleteTrigger") {
AdminEditorCan(userDB.role);
resp = await DeleteTrigger(data.trigger_id);
} else if (action == "getAllAlertsPaginated") {
const page = parseInt(String(data.page)) || 1;
@@ -249,13 +259,10 @@ export async function POST({ request, cookies }) {
} else if (action == "getAPIKeys") {
resp = await GetAllAPIKeys();
} else if (action == "createNewApiKey") {
AdminEditorCan(userDB.role);
resp = await CreateNewAPIKey(data);
} else if (action == "updateApiKeyStatus") {
AdminEditorCan(userDB.role);
resp = await UpdateApiKeyStatus(data);
} else if (action == "deleteApiKey") {
AdminCan(userDB.role);
const deleted = await DeleteApiKey(data);
if (!deleted) {
throw new Error("API key not found");
@@ -269,33 +276,24 @@ export async function POST({ request, cookies }) {
throw new Error("Incident not found");
}
} else if (action == "createIncident") {
AdminEditorCan(userDB.role);
resp = await CreateIncident(data);
} else if (action == "updateIncident") {
AdminEditorCan(userDB.role);
resp = await UpdateIncident(data.id, data);
} else if (action == "deleteIncident") {
AdminEditorCan(userDB.role);
resp = await DeleteIncident(data.incident_id);
} else if (action == "addMonitor") {
AdminEditorCan(userDB.role);
resp = await AddIncidentMonitor(data.incident_id, data.monitor_tag, data.monitor_impact);
} else if (action == "removeMonitor") {
AdminEditorCan(userDB.role);
resp = await RemoveIncidentMonitor(data.incident_id, data.monitor_tag);
} else if (action == "getComments") {
resp = await GetIncidentActiveComments(data.incident_id);
} else if (action == "addComment") {
AdminEditorCan(userDB.role);
resp = await AddIncidentComment(data.incident_id, data.comment, data.state, data.commented_at);
} else if (action == "deleteComment") {
AdminEditorCan(userDB.role);
resp = await UpdateCommentStatusByID(data.incident_id, data.comment_id, "INACTIVE");
} else if (action == "updateComment") {
AdminEditorCan(userDB.role);
resp = await UpdateCommentByID(data.incident_id, data.comment_id, data.comment, data.state, data.commented_at);
} else if (action == "testTrigger") {
AdminEditorCan(userDB.role);
const trigger = await GetTriggerByID(data.trigger_id);
const siteData = await GetAllSiteData();
if (!trigger || !siteData) {
@@ -370,7 +368,6 @@ export async function POST({ request, cookies }) {
throw new Error("Unsupported trigger type for testing");
}
} else if (action == "testMonitor") {
AdminEditorCan(userDB.role);
let monitorID = data.monitor_id;
let monitors = await GetMonitorsParsed({ id: monitorID });
let monitor = monitors[0];
@@ -386,10 +383,8 @@ export async function POST({ request, cookies }) {
const serviceClient = new Service(monitorReducedType);
resp = await serviceClient.execute();
} else if (action == "uploadImage") {
AdminEditorCan(userDB.role);
resp = await uploadImage(data);
} else if (action == "deleteImage") {
AdminEditorCan(userDB.role);
resp = await db.deleteImage(data.id);
} else if (action == "getPages") {
const pages = await GetAllPages();
@@ -402,26 +397,20 @@ export async function POST({ request, cookies }) {
);
resp = pagesWithMonitors;
} else if (action == "createPage") {
AdminEditorCan(userDB.role);
resp = await CreatePage(data);
} else if (action == "updatePage") {
AdminEditorCan(userDB.role);
const { id, ...updateData } = data;
resp = await UpdatePage(id, updateData);
} else if (action == "deletePage") {
AdminEditorCan(userDB.role);
await DeletePage(data.id);
resp = { success: true };
} else if (action == "addMonitorToPage") {
AdminEditorCan(userDB.role);
await AddMonitorToPage(data.page_id, data.monitor_tag);
resp = { success: true };
} else if (action == "removeMonitorFromPage") {
AdminEditorCan(userDB.role);
await RemoveMonitorFromPage(data.page_id, data.monitor_tag);
resp = { success: true };
} else if (action == "reorderPageMonitors") {
AdminEditorCan(userDB.role);
await ReorderPageMonitors(data.page_id, data.monitor_tags);
resp = { success: true };
}
@@ -434,15 +423,12 @@ export async function POST({ request, cookies }) {
throw new Error("Maintenance not found");
}
} else if (action == "createMaintenance") {
AdminEditorCan(userDB.role);
resp = await CreateMaintenance(data);
} else if (action == "updateMaintenance") {
AdminEditorCan(userDB.role);
const { id, ...updateData } = data;
await UpdateMaintenance(id, updateData);
resp = { success: true };
} else if (action == "deleteMaintenance") {
AdminEditorCan(userDB.role);
await DeleteMaintenance(data.id);
resp = { success: true };
} else if (action == "getMaintenanceEvents") {
@@ -453,38 +439,32 @@ export async function POST({ request, cookies }) {
throw new Error("Maintenance event not found");
}
} else if (action == "createMaintenanceEvent") {
AdminEditorCan(userDB.role);
resp = await CreateMaintenanceEvent(data);
} else if (action == "updateMaintenanceEvent") {
AdminEditorCan(userDB.role);
const { id, ...updateData } = data;
await UpdateMaintenanceEvent(id, updateData);
resp = { success: true };
} else if (action == "updateMaintenanceEventStatus") {
resp = await UpdateMaintenanceEventStatus(data.id, data.status);
} else if (action == "deleteMaintenanceEvent") {
AdminEditorCan(userDB.role);
await DeleteMaintenanceEvent(data.id);
resp = { success: true };
} else if (action == "addMonitorToMaintenance") {
AdminEditorCan(userDB.role);
await AddMonitorToMaintenance(data.maintenance_id, data.monitor_tag);
resp = { success: true };
} else if (action == "removeMonitorFromMaintenance") {
AdminEditorCan(userDB.role);
await RemoveMonitorFromMaintenance(data.maintenance_id, data.monitor_tag);
resp = { success: true };
} else if (action == "getMaintenanceMonitors") {
resp = await GetMaintenanceMonitors(data.maintenance_id);
} else if (action == "updateMaintenanceMonitorImpact") {
AdminEditorCan(userDB.role);
await UpdateMaintenanceMonitorImpact(data.maintenance_id, data.monitor_tag, data.monitor_impact);
resp = { success: true };
}
// ============ Monitor Alert Config Actions ============
else if (action == "createMonitorAlertConfig") {
AdminEditorCan(userDB.role);
resp = await CreateMonitorAlertConfig(data);
} else if (action == "updateMonitorAlertConfig") {
AdminEditorCan(userDB.role);
resp = await UpdateMonitorAlertConfig(data);
} else if (action == "getMonitorAlertConfig" || action == "getMonitorAlertConfigById") {
resp = await GetMonitorAlertConfigById(data.id);
@@ -494,11 +474,9 @@ export async function POST({ request, cookies }) {
} else if (action == "getMonitorAlertConfigsByMonitorTag") {
resp = await GetMonitorAlertConfigsByMonitorTag(data.monitor_tag);
} else if (action == "deleteMonitorAlertConfig") {
AdminEditorCan(userDB.role);
await DeleteMonitorAlertConfig(data.id);
resp = { success: true };
} else if (action == "toggleMonitorAlertConfigStatus") {
AdminEditorCan(userDB.role);
resp = await ToggleMonitorAlertConfigStatus(data.id);
} else if (action == "getAlertConfigsPaginated") {
const page = parseInt(String(data.page)) || 1;
@@ -510,7 +488,6 @@ export async function POST({ request, cookies }) {
if (data.alert_for) filter.alert_for = data.alert_for as "STATUS" | "LATENCY" | "UPTIME";
resp = await GetMonitorAlertConfigsPaginated(page, limit, Object.keys(filter).length > 0 ? filter : undefined);
} else if (action == "deleteMonitorAlertV2") {
AdminEditorCan(userDB.role);
const deleteIncident = data.deleteIncident === true;
// If deleteIncident is true, delete the incident first
if (deleteIncident && data.incident_id) {
@@ -518,7 +495,6 @@ export async function POST({ request, cookies }) {
}
resp = await DeleteMonitorAlertV2(data.id);
} else if (action == "updateMonitorAlertV2Status") {
AdminEditorCan(userDB.role);
resp = await UpdateMonitorAlertV2Status(data.id, data.status);
}
@@ -565,14 +541,12 @@ export async function POST({ request, cookies }) {
} else if (action == "getSubscriberCountsByMethod") {
resp = await GetSubscriberCountsByMethod();
} else if (action == "deleteUserSubscription") {
AdminCan(userDB.role);
const { subscriptionId } = data;
if (!subscriptionId) {
throw new Error("subscriptionId is required");
}
resp = await DeleteUserSubscription(subscriptionId);
} else if (action == "updateUserSubscriptionStatus") {
AdminCan(userDB.role);
const { subscriptionId, status } = data;
if (!subscriptionId || !status) {
throw new Error("subscriptionId and status are required");
@@ -594,7 +568,6 @@ export async function POST({ request, cookies }) {
throw new Error("Template not found");
}
} else if (action == "updateGeneralEmailTemplate") {
AdminEditorCan(userDB.role);
const { templateId, template_subject, template_html_body, template_text_body } = data;
if (!templateId) {
throw new Error("Template ID is required");
@@ -614,7 +587,6 @@ export async function POST({ request, cookies }) {
const limit = parseInt(String(data.limit)) || 10;
resp = await GetAdminSubscribersPaginated(page, limit);
} else if (action == "adminUpdateSubscriptionStatus") {
AdminEditorCan(userDB.role);
const { methodId, eventType, enabled } = data;
if (!methodId || !eventType) {
throw new Error("Method ID and event type are required");
@@ -624,7 +596,6 @@ export async function POST({ request, cookies }) {
throw new Error(resp.error);
}
} else if (action == "adminDeleteSubscriber") {
AdminEditorCan(userDB.role);
const { methodId } = data;
if (!methodId) {
throw new Error("Method ID is required");
@@ -634,7 +605,6 @@ export async function POST({ request, cookies }) {
throw new Error(resp.error);
}
} else if (action == "adminAddSubscriber") {
AdminEditorCan(userDB.role);
const { email, incidents, maintenances } = data;
if (!email) {
throw new Error("Email is required");
@@ -644,7 +614,6 @@ export async function POST({ request, cookies }) {
throw new Error(resp.error);
}
} else if (action == "getSubscriptionsConfig") {
AdminCan(userDB.role);
let subscriptionsSettings = await GetSiteDataByKey("subscriptionsSettings");
if (!!!subscriptionsSettings) {
subscriptionsSettings = {
@@ -669,8 +638,27 @@ export async function POST({ request, cookies }) {
}
resp = siteData;
} else if (action == "updateSubscriptionsConfig") {
AdminCan(userDB.role);
resp = await InsertKeyValue("subscriptionsSettings", JSON.stringify(data));
} else if (action == "getRoles") {
resp = await GetAllRoles();
} else if (action == "getAllPermissions") {
resp = await GetAllPermissions();
} else if (action == "getRolePermissions") {
resp = await GetRolePermissions(data.roleId);
} else if (action == "updateRolePermissions") {
resp = await UpdateRolePermissions(data.roleId, data.permissionIds);
} else if (action == "getRoleUsers") {
resp = await GetRoleUsers(data.roleId);
} else if (action == "addUserToRole") {
resp = await AddUserToRole(data.roleId, data.userId);
} else if (action == "removeUserFromRole") {
resp = await RemoveUserFromRole(data.roleId, data.userId);
} else if (action == "createRole") {
resp = await CreateRole({ role_id: data.role_id, name: data.name });
} else if (action == "updateRole") {
resp = await UpdateRole(data.roleId, { name: data.name, status: data.status });
} else if (action == "deleteRole") {
resp = await DeleteRole(data.roleId, data.options);
}
} catch (error: unknown) {
console.log(error);
@@ -273,7 +273,7 @@
<Table.Cell class="pr-4 text-right">
<Button
variant="destructive"
disabled={page.data.userDb.role !== "admin"}
disabled={!page.data.userPermissions?.includes("api_keys.delete")}
size="sm"
onclick={() => openDeleteDialog(apiKey)}
>

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