Compare commits

..

77 Commits

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

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

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

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

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

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

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

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

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

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

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

Fixes #737

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

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

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

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

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

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

Fixes #723

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:55:35 +05:30
Raj Nandan Sharma a8fbac1b69 Merge pull request #741 from rajnandan1/fix/694
chore: update documentation and enhance monitor group functionality, …
2026-06-06 13:05:15 +05:30
Raj Nandan Sharma cfc99e2f14 chore: update documentation and enhance monitor group functionality, fixes #694 2026-06-06 13:04:13 +05:30
Raj Nandan Sharma 31ba10f434 Merge pull request #740 from rajnandan1/fix/707
refactor: implement absolute URL resolution for social media meta tags
2026-06-06 12:26:31 +05:30
Raj Nandan Sharma 1750e2a341 refactor: implement absolute URL resolution for social media meta tags 2026-06-06 12:25:42 +05:30
Raj Nandan Sharma a12df92b94 Merge pull request #734 from Kukks/fix/sticky-header-backdrop
Fix overlapping header sections on the public page
2026-06-06 11:32:38 +05:30
Andrew Camilleri 5d86084138 fix(public): add frosted backdrop behind fixed nav and sticky theme-plus bar
The sticky theme-plus bar has no background, and the gap between it and the
fixed nav stays transparent, so page content shows through both sections as
it scrolls. Paint one blurred layer behind both (above content, below nav
z-10 and bar z-20).
2026-05-29 10:30:27 +02:00
Raj Nandan Sharma 7050f780a3 Merge pull request #702 from smeeckaert/feature/change-alert-body-description
Update alert body description
2026-04-09 10:21:50 +05:30
Raj Nandan Sharma cb93089dcc Merge pull request #703 from LoveGmod/patch-1
Update french locale syntax
2026-04-09 10:21:12 +05:30
LoveGmod fcd05e1d68 Update fr.json
french syntax changes
2026-04-05 23:54:33 +02:00
Martin SMEECKAERT db9d7807e0 update alert body description 2026-04-03 08:05:54 +02:00
Raj Nandan Sharma b7e0756c54 Merge pull request #699 from toporivskiy/main 2026-04-02 14:20:05 +05:30
Andrew Toporivskiy b920d2f9bc Ukrainian translate 2026-04-02 10:35:54 +03:00
github-actions[bot] 560c87219b chore(release): bump version to 4.0.23 2026-04-01 03:18:39 +00:00
Raj Nandan Sharma 94e24eec04 Include allPerms.ts in the Docker build process 2026-03-31 23:02:22 +05:30
Raj Nandan Sharma 15680a58aa Merge pull request #695 from rajnandan1/rbac-1-2
Implement role and permission management system with seeding scripts …
2026-03-31 22:08:34 +05:30
Raj Nandan Sharma 59f0eaef27 Refactor layout component to streamline dynamic styles and improve color handling 2026-03-31 22:08:08 +05:30
Raj Nandan Sharma 8362a73058 Implement indexing for users_roles table and enhance roles seeding logic to prevent FK constraint errors 2026-03-31 21:58:43 +05:30
Raj Nandan Sharma 52f8c50f50 Implement v4.0.23 changelog with new features, improvements, and breaking changes 2026-03-31 21:37:54 +05:30
Raj Nandan Sharma 60868d55ca Implement role validation in login action to ensure users have active roles assigned 2026-03-31 21:04:21 +05:30
Raj Nandan Sharma f7e657ee95 Implement role backfilling logic in down migration and remove unused vault permission from ROUTE_PERMISSION_MAP 2026-03-31 20:48:41 +05:30
Raj Nandan Sharma 63e5ec2886 Refactor role validation to ensure all role_ids are active and update role badge logic to prioritize active roles 2026-03-31 20:11:11 +05:30
Raj Nandan Sharma 2aef97c1ed Implement role migration and enforce role permissions in user management 2026-03-31 19:10:37 +05:30
Raj Nandan Sharma 51b2da97e0 Implement role and permission management system with seeding scripts for permissions and roles, including user role migration and UI components for role management. 2026-03-31 16:55:40 +05:30
Raj Nandan Sharma 50bddcd9a3 Merge pull request #691 from p-klassen/p-klassen-patch-1 2026-03-28 18:10:31 +05:30
p-klassen bd36533b05 Update de.json
Update wording and sentences (depending in their context) to be more natural and closer to  how they are actually commonly used in standard German.
2026-03-28 12:58:03 +01:00
github-actions[bot] 17500a0b43 chore(release): bump version to 4.0.22 2026-03-28 06:02:11 +00:00
Raj Nandan Sharma 0050cd810b Merge pull request #690 from rajnandan1/fix/sqlite-migration
Fix/sqlite migration
2026-03-28 11:23:34 +05:30
Raj Nandan Sharma db6cb6cf7d refactor: improve SQLite migration for monitor_alerts_config to ensure monitor_tag is nullable 2026-03-28 11:07:13 +05:30
Raj Nandan Sharma babeeb75b2 chore: update v4.0.22 changelog to note migration fix for sqlite3 from v4.0.21 2026-03-28 10:36:30 +05:30
Raj Nandan Sharma e0187605e7 chore: update changelog for v4.0.22 and remove v4.0.21 entry 2026-03-28 10:34:43 +05:30
Raj Nandan Sharma e2861f1e59 refactor: rebuild SQLite monitor_alerts_config table to make monitor_tag nullable 2026-03-28 10:32:50 +05:30
Raj Nandan Sharma 01aa4d9984 refactor: modify SQLite monitor_alerts_config to allow nullable monitor_tag 2026-03-28 09:50:08 +05:30
github-actions[bot] 555372f175 chore(release): bump version to 4.0.21 2026-03-27 17:58:55 +00:00
Raj Nandan Sharma bd5fd409d1 chore: update documentation for v4.0.21 changelog and navigation 2026-03-27 22:27:24 +05:30
Raj Nandan Sharma f61d82e13f Merge pull request #688 from rajnandan1/implement/672
refactor: implement global maintenance notification settings and upda…
2026-03-27 21:28:02 +05:30
Raj Nandan Sharma f39588fff7 refactor: enhance maintenance notification settings validation and update event status logic 2026-03-27 21:02:51 +05:30
Raj Nandan Sharma 0716f271df refactor: implement global maintenance notification settings and update related event handling 2026-03-27 20:46:00 +05:30
Raj Nandan Sharma 1085c3e561 Merge pull request #679 from rajnandan1/release-4.0.21
refactor: support multi-monitor alerts by updating alert configuratio…
2026-03-25 16:44:43 +05:30
Raj Nandan Sharma 93907e6d96 refactor: improve error handling in tcpEval and evalResp validation 2026-03-25 16:40:44 +05:30
Raj Nandan Sharma d03fd63c64 refactor: support multi-monitor alerts by updating alert configuration and database schema
- Modify alertToVariables function to accept an optional monitorTag parameter for better flexibility in alert naming.
- Update sendAlertNotifications function to pass monitorTag to alertToVariables.
- Enhance addWorker function to handle multiple monitors by querying and creating alerts with the appropriate monitorTag.
- Introduce a new junction table for many-to-many relationships between monitor alerts and monitors.
- Update database migration to accommodate the new schema and backfill existing data.
- Revise frontend components to support multiple monitor selections in alert configurations.
- Enhance documentation to include analytics provider setup instructions.

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

All npm audit checks pass with 0 vulnerabilities.
2026-03-23 11:55:45 +01:00
Raj Nandan Sharma b1f8a505a5 Merge pull request #677 from rajnandan1/maintenance-prominient
refactor: implement upcoming maintenances feature in dashboard and mo…
2026-03-23 09:19:16 +05:30
Raj Nandan Sharma 104da58646 refactor: implement upcoming maintenances feature in dashboard and monitor views. implements #665 and #674 2026-03-23 09:18:45 +05:30
Raj Nandan Sharma ed6e97e8c9 refactor: update Google Tag Manager script source and enhance configuration with transport URL 2026-03-22 23:11:43 +05:30
Raj Nandan Sharma 0b25274874 refactor: enhance Google Tag Manager integration with transport URL and script host options 2026-03-22 20:31:34 +05:30
Raj Nandan Sharma bb8ab41bfe Merge pull request #673 from danynocz/update-czech-slovakia-march
Update Czech and Slovak translation
2026-03-20 22:16:01 +05:30
_DANYNO_ b43a5fb343 Update Slovak translations 2026-03-20 15:23:22 +01:00
_DANYNO_ 554caa5018 Update Czech translation 2026-03-20 15:19:55 +01:00
Raj Nandan Sharma ffe7403043 refactor: remove unused <svelte:head> elements and associated metadata from documentation layouts 2026-03-20 16:41:58 +05:30
Raj Nandan Sharma a26d0ece59 Merge pull request #671 from rajnandan1/fixcustom-js-css-guide-link
Fixcustom js css guide link
2026-03-20 15:37:50 +05:30
122 changed files with 6756 additions and 2537 deletions
+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`.
+99
View File
@@ -0,0 +1,99 @@
# Kener
An open-source status page application providing real-time monitoring, uptime tracking, incident management, and customizable dashboards.
## Language
### Monitoring
**Monitor**:
A single check against a service, with a unique tag, a type (API, Ping, TCP, DNS, SSL, SQL, Heartbeat, GameDig, Group, gRPC, None), and a status (ACTIVE or otherwise).
_Avoid_: Check, probe, service
**Inactive Monitor**:
A monitor that is not checked at all: the scheduler drops its job and no monitoring data is collected until it is made ACTIVE again. Independent of visibility (see Hidden Monitor).
_Avoid_: Disabled monitor, paused monitor
**Hidden Monitor**:
A monitor excluded from all status pages while remaining fully checked and alerted. Independent of ACTIVE/INACTIVE.
_Avoid_: Invisible monitor, private monitor
**Group Monitor**:
A monitor whose status is derived from other monitors via a weighted score (UP=1, DEGRADED=0.5, DOWN=0; maintenance counts as UP). A group cannot contain another group.
_Avoid_: Monitor group, composite monitor
**Member**:
A monitor belonging to a Group Monitor, carrying a weight and a position in the execution order. Membership is an explicit stored list, never a dynamic rule (e.g. tag wildcards).
_Avoid_: Child monitor, sub-monitor
**Weight**:
A member's share of the group score, between 0 and 1. Weights across a group's members must sum to 1. Any membership change (add or remove) redistributes all weights equally; manual tuning happens after membership is settled.
**Execution Order**:
The stored order in which a Group Monitor's members are checked before aggregation. Manually arranged, not derived.
**Eligible Monitor**:
A monitor that may become a Member: ACTIVE, not a Group Monitor, and not the group being edited itself.
**Monitoring Sample**:
One recorded data point for a monitor at a timestamp: a status, a latency, and a sample type describing how it was produced. Every sample is either Observed or Synthetic.
_Avoid_: Data point, record, check result
**Observed Sample**:
A Monitoring Sample produced by a check that actually ran against the target, whatever the outcome: a clean evaluation (`REALTIME`), a timed-out check (`TIMEOUT`), or a check that errored (`ERROR`). A check failing to reach the target is itself an observation — for most monitor types that is exactly what "down" looks like.
**Synthetic Sample**:
A Monitoring Sample written by the system or an admin rather than by a check: a raw heartbeat receipt (`SIGNAL`), a status pushed through the data API (`MANUAL`), a default-status fill (`DEFAULT_STATUS`), or an incident/maintenance overlay (`INCIDENT`, `MAINTENANCE`).
**Stale Member**:
A Member whose monitor is no longer an Eligible Monitor (paused or deleted after being added). It remains a Member until explicitly removed, but is excluded from the group score.
### Alerting
**Alert Configuration**:
A per-monitor alerting rule: a condition (status, latency, or uptime against a value), a Failure Threshold, a Success Threshold, whether triggering creates an incident, and the Triggers to notify.
_Avoid_: Alert rule, alarm
**Alert**:
A fired instance of an Alert Configuration for a monitor. TRIGGERED when the condition holds, RESOLVED when the resolve condition later holds. May own the incident it created.
_Avoid_: Alarm, notification (that's what Triggers send)
**Trigger**:
A notification channel (email, webhook, Discord, Slack) that Alert Configurations notify on trigger and on resolve.
_Avoid_: Notifier, channel
**Alert-Visible Sample**:
A Monitoring Sample that alert evaluation can see: every Observed Sample, plus data-API pushes (`MANUAL`) and default-status fill (`DEFAULT_STATUS`). Raw heartbeat receipts (`SIGNAL`) and incident/maintenance overlays are never alert-visible — while an overlay is active the alert window freezes (alerts neither trigger nor resolve). All alert conditions (status and latency alike) evaluate the same alert-visible timeline.
**Failure Threshold**:
The number of consecutive Alert-Visible Samples matching the condition required to trigger an Alert.
**Success Threshold**:
The number of consecutive Alert-Visible Samples meeting the resolve condition required to resolve an Alert.
### Pages
**Page**:
A public status page with its own path, title, monitors, and display settings. Served at `/<page_path>`.
**Home Page**:
The Page served at the site root. Its stored path is empty, it always exists (it can not be deleted), and its path can not be changed. Addressed in the API by the `~home` token.
_Avoid_: Default page, base page, root page
**Status History Window**:
The number of days of per-day status shown for a monitor, per device class (desktop/mobile). Configurable at two levels with the same defaults and bounds: per Page (applies to all its monitors) and per Monitor (overrides the page level).
_Avoid_: History days, bar count
**Page Settings**:
A Page's display configuration: status-history window per device class, monitor layout style, per-page meta/social overrides, and event display preferences. The admin UI and the API expose the same settings, though each surface may name fields differently; a writer must never drop fields it does not understand.
_Avoid_: Display settings (ambiguous with site-wide event display settings)
### Maintenance
**Maintenance**:
A recurring maintenance definition: title, description, an RRULE schedule, a duration, and affected monitors. Identified by its own id.
_Avoid_: Maintenance window, maintenance event (that's an occurrence, see below)
**Maintenance Event**:
A single occurrence of a Maintenance, generated from its RRULE: a concrete start/end time with a lifecycle status (SCHEDULED → READY → ONGOING → COMPLETED). Has its own id, independent of the Maintenance id. The public maintenance page is keyed by Maintenance Event id.
_Avoid_: Occurrence, maintenance instance
+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)
@@ -0,0 +1,5 @@
# Group membership is an explicit stored list, not a rule
When making group-monitor member selection searchable (#694), the requester also proposed dynamic membership by tag pattern (e.g. `site1-*` auto-adds matching monitors). We decided group membership stays an explicit, stored list of members. Dynamic membership contradicts the group model: each member carries an explicit weight (weights must sum to 1) and a manual execution order — a rule that adds/removes members over time would need an auto-weighting policy, silent weight redistribution when monitors are created or deleted, and an undefined execution order for matched members. Bulk needs are served in the editor instead: search plus "Add all N matching" makes large explicit groups cheap to build.
If wildcard groups are requested again, the answer is here: it's a different feature (a rule-based aggregate without weights or order), not an extension of Group Monitors.
@@ -0,0 +1,5 @@
# Public maintenance page is keyed by Maintenance Event id
The public route `/maintenances/<id>` interprets `<id>` as a Maintenance Event id by default (with `?type=maintenance` to address a Maintenance definition instead), even though the route param is named `maintenance_id`. Issue #723 showed this misleads API consumers: `/api/v4/maintenances` returns Maintenance ids, and linking those to `/maintenances/<id>` lands on whatever Event happens to carry that id — an apparent "off-by-one title mismatch" with no actual data corruption.
We considered flipping the route default to Maintenance ids but rejected it: every internal status-page link, subscriber email, and externally bookmarked URL is keyed by Event id, and all of those would break. Instead, v4 API responses carry an absolute `url` field (built from the configured Site URL) that resolves correctly — `?type=maintenance` for Maintenance objects, the plain Event-id path for Maintenance Events. Consumers should link via `url`, never by concatenating ids onto paths.
@@ -0,0 +1,9 @@
# Fail-fast, self-healing database pool defaults
`knexfile.ts` overrides knex's pool defaults for network databases (Postgres, MySQL): `pool.min` is 0 instead of 2, acquire/create timeouts are 15s instead of 60s/30s, and TCP keepalive is enabled on connections. All knobs are overridable via `DATABASE_*` env vars.
Two production incidents drove this. On Railway, a Postgres outage caused every request to hang for knex's default 60s `acquireConnectionTimeout` before failing with `KnexTimeoutError`, and after the database recovered the app stayed broken until a manual restart. In Docker Swarm (#692), the overlay network's conntrack silently dropped idle TCP connections after ~20 minutes, so the first request after an idle period drew a dead socket from the pool and returned a 500; the reporter worked around it with server-side Postgres `tcp_keepalives_*` settings and asked for an application-level fix.
Both share one root cause: knex keeps `pool.min` connections forever and never validates them. Those permanently-idle sockets are exactly the ones cloud networks (Railway proxies, Swarm overlays, k8s) silently kill, and after any database blip they wedge the pool with corpses. `min: 0` lets the reaper retire every idle connection (`idleTimeoutMillis` 30s, well under typical conntrack windows), keepalive lets the OS detect silently-dropped sockets, and the 15s timeouts turn a minute-long hang into a fast failure during an outage.
The trade-off: a quiet instance pays connection setup on the first query after idle (tens of milliseconds), and a database that takes longer than 15s to accept connections will see failures where the old defaults would have waited a minute. Deployments with such databases can raise `DATABASE_ACQUIRE_TIMEOUT_MS` / `DATABASE_CREATE_TIMEOUT_MS` rather than the project reverting to defaults that wedge everyone else.
+9
View File
@@ -0,0 +1,9 @@
# Home page is addressed as `~home` in the v4 API
The Home Page is stored with an empty `page_path`, which can not appear as a URL segment, so `/api/v4/pages/{page_path}` could not address it at all (#737). The v4 API now accepts the special segment `~home` for it: the request middleware maps `~home` to a lookup of the empty path before handlers run.
We considered the reporter's suggestion of a `default` keyword, and `home`, but both are valid page paths under the sanitizer (`[a-z0-9_-]`), so a real page could shadow the keyword or force reservation rules and migration edge cases. We also considered addressing pages by id, which either breaks the existing path-based contract or is ambiguous with numeric page paths. `~home` can never collide because the sanitizer strips `~`, and tilde is an RFC 3986 unreserved character, so clients never need to encode it (percent-encoded `%7Ehome` works too, since the middleware decodes segments).
Semantics follow the server-side invariants the manage UI relies on: `PATCH` via `~home` accepts every field except `page_path`, which is fixed for the home page (the UI disables the field), and `DELETE` is rejected — `DeletePage` in `pagesController` throws "Cannot delete the home page" and the rest of the app assumes the home page exists.
API responses also render the home page's `page_path` as `~home` (list, single, and write responses), so what a consumer reads is exactly what it can address — list → pick → `PATCH` round-trips cleanly, including read-modify-write bodies that send `~home` back (treated as "no path change"). The stored path remains empty and the public URL remains the site root; consumers must not build public URLs by concatenating `page_path`.
@@ -0,0 +1,9 @@
# Alerts evaluate alert-visible samples, not just REALTIME ones
The consecutive-sample checks behind alert evaluation (`consecutivelyStatusFor`, `consecutivelyLatencyGreaterThan`, `consecutivelyLatencyLessThan` in `src/lib/server/db/repositories/monitoring.ts`) consider samples whose type is `REALTIME`, `ERROR`, `TIMEOUT`, `MANUAL`, or `DEFAULT_STATUS` — the "alert-visible" set — instead of `REALTIME` only. Both data-API PATCH endpoints (single timestamp and range) enqueue one alert evaluation after writing `MANUAL` rows. `SIGNAL` rows and `INCIDENT`/`MAINTENANCE` overlay rows remain invisible to alerting.
Two issues drove this. In #633, a GameDig monitor showed DOWN on the status page but never alerted: a down game server makes `GameDig.query` throw, so every down-sample is recorded as `ERROR`, which the old `type = REALTIME` filter excluded — the "N consecutive DOWN" condition could never become true. The same failure mode silently broke gRPC, SQL, and SSL monitors (hard-down records `ERROR`) and API monitors whose outage manifests as timeouts (`TIMEOUT`). In #720, a NONE monitor driven by the data API never alerted for two stacked reasons: PATCH writes `MANUAL` rows the filter excluded, and the endpoint never enqueued evaluation at all. The status page and UPTIME alerts have no type filter, which is why users saw DOWN while alerts stayed silent.
The whitelist is exactly the set of types written by flows that trigger alert evaluation — scheduler checks (`REALTIME`/`ERROR`/`TIMEOUT`), default-status fill (`DEFAULT_STATUS`), and data-API pushes (`MANUAL`). That invariant ("every enqueuer of evaluation contributes a row the evaluator can see") is what keeps the un-time-bounded last-N query self-healing: without `DEFAULT_STATUS` in the set, a NONE monitor with a default status evaluates every minute against rows it cannot see, so stale backfilled `MANUAL` rows would rank as "the last N" and fire alerts weeks after the fact. The rejected alternatives: fixing only `gamedigCall` to emit `REALTIME` on query failure (fixes one monitor type out of five, loses the stored down-vs-errored diagnostic, and does nothing for existing data), and time-bounding the query (the cutoff must scale with each monitor's cron, which means parsing cron expressions in the alert path for marginal benefit).
The trade-offs, accepted deliberately for one uniform rule across status and latency alerts: a service that degrades from slow to hard-down resolves an active latency alert (error samples carry latency 0, satisfying "consecutively below threshold") — the status alert is the one that covers outages; a NONE monitor with a default status auto-resolves MANUAL-pushed alerts once default fill resumes, because a default status is an explicit statement that absence of pushes means that status; and the fix is retroactive, so monitors that were already down at upgrade time alert shortly after — which is the bug report, inverted.
+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);
-27
View File
@@ -1,27 +0,0 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": ["src/app.html", "build/main.js", "scripts/**/*.{js,ts}", "migrations/**/*.{js,ts}", "seeds/**/*.{js,ts}"],
"project": ["src/**/*.{js,ts,svelte}", "scripts/**/*.{js,ts}", "migrations/**/*.{js,ts}", "seeds/**/*.{js,ts}"],
"ignore": ["src/lib/components/ui/**"],
"ignoreDependencies": [
"@babel/runtime",
"js-yaml",
"mysql2",
"node-cache",
"pg",
"pg-pool",
"randomstring",
"style-to-object",
"lucide-svelte",
"marked-gfm-heading-id"
],
"ignoreExportsUsedInFile": {
"interface": true,
"type": true
},
"ignoreBinaries": [],
"compilers": {
"css": ["postcss"],
"svelte": ["svelte"]
}
}
@@ -0,0 +1,215 @@
/**
* Migration: Multi-Monitor Alerts
*
* This migration refactors the monitor alerting system to support many-to-many
* relationships between alert configurations and monitors. Previously, each
* `monitor_alerts_config` row was tied to exactly one monitor via a `monitor_tag`
* foreign key column. This migration:
*
* 1. Creates a new `monitor_alerts_config_monitors` junction table that links
* `monitor_alerts_config` rows to one or more `monitors` rows, enabling a
* single alert configuration to fire across multiple monitors.
*
* 2. Adds a `monitor_tag` column to `monitor_alerts_v2` so that each firing
* alert record knows which specific monitor triggered it (important when one
* config covers many monitors).
*
* 3. Migrates existing data: copies every `monitor_alerts_config.monitor_tag`
* value into the new junction table and backfills `monitor_alerts_v2.monitor_tag`
* from the same source, preserving all historical alert records.
*
* 4. Removes the one-to-one constraint on `monitor_alerts_config.monitor_tag` by
* dropping its foreign key and setting the column nullable (SQLite workaround:
* nulls the column directly since SQLite cannot drop foreign key constraints
* inline).
*
* 5. Adds a composite index on `monitor_alerts_v2 (config_id, monitor_tag,
* alert_status)` for fast per-monitor alert status lookups.
*
* The `down` migration reverses these steps: restores the first junction-table
* entry back onto `monitor_alerts_config.monitor_tag`, re-adds the foreign key
* (non-SQLite), removes the `monitor_tag` column from `monitor_alerts_v2`
* (non-SQLite), and drops the junction table.
*/
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Step 1: Create monitor_alerts_config_monitors junction table
if (!(await knex.schema.hasTable("monitor_alerts_config_monitors"))) {
await knex.schema.createTable("monitor_alerts_config_monitors", (table) => {
table.integer("monitor_alerts_id").unsigned().notNullable();
table.string("monitor_tag", 255).notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Composite primary key
table.primary(["monitor_alerts_id", "monitor_tag"]);
// Foreign keys
table.foreign("monitor_alerts_id").references("id").inTable("monitor_alerts_config").onDelete("CASCADE");
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
});
}
// Step 2: Add monitor_tag column to monitor_alerts_v2 for per-monitor alert tracking
const hasV2Column = await knex.schema.hasColumn("monitor_alerts_v2", "monitor_tag");
if (!hasV2Column) {
await knex.schema.alterTable("monitor_alerts_v2", (table) => {
table.string("monitor_tag", 255).nullable();
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
});
}
// Step 3: Migrate existing data from monitor_alerts_config.monitor_tag to junction table
// and backfill monitor_alerts_v2.monitor_tag from the same source
const existingConfigs = await knex("monitor_alerts_config").whereNotNull("monitor_tag").select("id", "monitor_tag");
if (existingConfigs.length > 0) {
// Build a config_id -> monitor_tag map for backfilling alerts
const configTagMap = new Map<number, string>();
const inserts = existingConfigs.map((config: { id: number; monitor_tag: string }) => {
configTagMap.set(config.id, config.monitor_tag);
return {
monitor_alerts_id: config.id,
monitor_tag: config.monitor_tag,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
};
});
// Batch insert into junction table
const chunkSize = 100;
for (let i = 0; i < inserts.length; i += chunkSize) {
await knex("monitor_alerts_config_monitors").insert(inserts.slice(i, i + chunkSize));
}
// Backfill monitor_tag on existing monitor_alerts_v2 rows
const existingAlerts = await knex("monitor_alerts_v2").whereNull("monitor_tag").select("id", "config_id");
for (const alert of existingAlerts) {
const tag = configTagMap.get(alert.config_id);
if (tag) {
await knex("monitor_alerts_v2").where({ id: alert.id }).update({ monitor_tag: tag });
}
}
}
// Step 4: Drop foreign key and make monitor_tag nullable on monitor_alerts_config
const dbClient = knex.client.config.client;
if (dbClient === "sqlite3" || dbClient === "better-sqlite3") {
// SQLite cannot ALTER COLUMN, so we rebuild the table with monitor_tag nullable
await knex.transaction(async (trx) => {
await trx.raw("PRAGMA foreign_keys = OFF");
await trx.raw("DROP TABLE IF EXISTS monitor_alerts_config_new");
await trx.raw(`
CREATE TABLE monitor_alerts_config_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_tag VARCHAR(255),
alert_for VARCHAR(50) NOT NULL,
alert_value VARCHAR(255) NOT NULL,
failure_threshold INTEGER NOT NULL DEFAULT 1,
success_threshold INTEGER NOT NULL DEFAULT 1,
alert_description TEXT,
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await trx.raw(`
INSERT INTO monitor_alerts_config_new
(id, monitor_tag, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at)
SELECT
id, NULL, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at
FROM monitor_alerts_config
`);
await trx.raw("DROP TABLE monitor_alerts_config");
await trx.raw("ALTER TABLE monitor_alerts_config_new RENAME TO monitor_alerts_config");
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
} catch (_e) {
/* index may already exist */
}
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
} catch (_e) {
/* index may already exist */
}
await trx.raw("PRAGMA foreign_keys = ON");
});
} else {
try {
await knex.schema.alterTable("monitor_alerts_config", (table) => {
table.dropForeign(["monitor_tag"]);
});
} catch (_e) {
// Foreign key may not exist or already dropped
}
await knex.schema.alterTable("monitor_alerts_config", (table) => {
table.string("monitor_tag", 255).nullable().alter();
});
await knex("monitor_alerts_config").update({ monitor_tag: null });
}
// Step 5: Add composite index on monitor_alerts_v2 for fast lookups
try {
await knex.raw(
"CREATE INDEX idx_monitor_alerts_v2_config_tag_status ON monitor_alerts_v2 (config_id, monitor_tag, alert_status)",
);
} catch (_e) {
/* index already exists */
}
}
export async function down(knex: Knex): Promise<void> {
// Step 1: Drop the composite index on monitor_alerts_v2
try {
await knex.raw("DROP INDEX IF EXISTS idx_monitor_alerts_v2_config_tag_status");
} catch (_e) {
/* index may not exist */
}
// Step 2: Copy first monitor_tag from junction table back to monitor_alerts_config
const configs = await knex("monitor_alerts_config").select("id");
for (const config of configs) {
const firstMonitor = await knex("monitor_alerts_config_monitors").where({ monitor_alerts_id: config.id }).first();
if (firstMonitor) {
await knex("monitor_alerts_config").where({ id: config.id }).update({ monitor_tag: firstMonitor.monitor_tag });
}
}
// Step 3: Delete configs that have no monitors (can't satisfy NOT NULL)
await knex("monitor_alerts_config").whereNull("monitor_tag").del();
// Step 4: Re-add foreign key constraint on monitor_alerts_config (non-SQLite only)
const dbClient = knex.client.config.client;
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
await knex.schema.alterTable("monitor_alerts_config", (table) => {
table.string("monitor_tag", 255).notNullable().alter();
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
});
}
// Step 5: Drop monitor_tag column from monitor_alerts_v2 (non-SQLite only)
const hasV2Column = await knex.schema.hasColumn("monitor_alerts_v2", "monitor_tag");
if (hasV2Column) {
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
await knex.schema.alterTable("monitor_alerts_v2", (table) => {
table.dropForeign(["monitor_tag"]);
table.dropColumn("monitor_tag");
});
}
}
// Step 6: Drop junction table
await knex.schema.dropTableIfExists("monitor_alerts_config_monitors");
}
@@ -0,0 +1,144 @@
/**
* Migration: Fix SQLite monitor_alerts_config.monitor_tag NOT NULL constraint
*
* The earlier migration 20260325120000_multi_monitor_alerts nulled out data in
* monitor_alerts_config.monitor_tag for SQLite but could not alter the column
* constraint (SQLite doesn't support ALTER COLUMN). This migration recreates
* the table with monitor_tag as nullable, preserving all data and indexes.
*
* Only runs on SQLite/better-sqlite3; other databases already had the column
* altered in the previous migration.
*/
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
const dbClient = knex.client.config.client;
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
return; // Already handled by 20260325120000_multi_monitor_alerts
}
// Check if monitor_tag is already nullable (migration 1 already rebuilt the table)
const tableInfo: Array<{ name: string; notnull: number }> = await knex.raw(
"PRAGMA table_info(monitor_alerts_config)",
);
const monitorTagCol = tableInfo.find((col) => col.name === "monitor_tag");
if (monitorTagCol && monitorTagCol.notnull === 0) {
return; // Column is already nullable, nothing to do
}
// Column is still NOT NULL — rebuild the table to make it nullable
try {
await knex.transaction(async (trx) => {
await trx.raw("PRAGMA foreign_keys = OFF");
await trx.raw("DROP TABLE IF EXISTS monitor_alerts_config_new");
await trx.raw(`
CREATE TABLE monitor_alerts_config_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_tag VARCHAR(255),
alert_for VARCHAR(50) NOT NULL,
alert_value VARCHAR(255) NOT NULL,
failure_threshold INTEGER NOT NULL DEFAULT 1,
success_threshold INTEGER NOT NULL DEFAULT 1,
alert_description TEXT,
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await trx.raw(`
INSERT INTO monitor_alerts_config_new
(id, monitor_tag, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at)
SELECT
id, NULL, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at
FROM monitor_alerts_config
`);
await trx.raw("DROP TABLE monitor_alerts_config");
await trx.raw("ALTER TABLE monitor_alerts_config_new RENAME TO monitor_alerts_config");
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
} catch (_e) {
/* index may already exist */
}
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
} catch (_e) {
/* index may already exist */
}
await trx.raw("PRAGMA foreign_keys = ON");
});
} catch (e) {
await knex.raw("PRAGMA foreign_keys = ON");
throw e;
}
}
export async function down(knex: Knex): Promise<void> {
const dbClient = knex.client.config.client;
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
return;
}
// Revert: make monitor_tag NOT NULL again via table rebuild
try {
await knex.transaction(async (trx) => {
await trx.raw("PRAGMA foreign_keys = OFF");
await trx.raw(`
CREATE TABLE monitor_alerts_config_old (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_tag VARCHAR(255) NOT NULL,
alert_for VARCHAR(50) NOT NULL,
alert_value VARCHAR(255) NOT NULL,
failure_threshold INTEGER NOT NULL DEFAULT 1,
success_threshold INTEGER NOT NULL DEFAULT 1,
alert_description TEXT,
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Only copy rows that have a non-null monitor_tag
await trx.raw(`
INSERT INTO monitor_alerts_config_old
(id, monitor_tag, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at)
SELECT
id, monitor_tag, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at
FROM monitor_alerts_config
WHERE monitor_tag IS NOT NULL
`);
await trx.raw("DROP TABLE monitor_alerts_config");
await trx.raw("ALTER TABLE monitor_alerts_config_old RENAME TO monitor_alerts_config");
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
} catch (_e) {
/* index may already exist */
}
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
} catch (_e) {
/* index may already exist */
}
await trx.raw("PRAGMA foreign_keys = ON");
});
} catch (e) {
await knex.raw("PRAGMA foreign_keys = ON");
throw e;
}
}
@@ -0,0 +1,58 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// 1. Roles table
if (!(await knex.schema.hasTable("roles"))) {
await knex.schema.createTable("roles", (table) => {
table.string("id", 100).primary();
table.text("role_name").notNullable();
table.integer("readonly").notNullable().defaultTo(0);
table.string("status", 20).notNullable().defaultTo("ACTIVE");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
});
}
// 2. Permissions table
if (!(await knex.schema.hasTable("permissions"))) {
await knex.schema.createTable("permissions", (table) => {
table.string("id", 100).primary();
table.text("permission_name").notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
});
}
// 3. Roles ↔ Permissions junction table
if (!(await knex.schema.hasTable("roles_permissions"))) {
await knex.schema.createTable("roles_permissions", (table) => {
table.string("roles_id", 100).notNullable().references("id").inTable("roles").onDelete("CASCADE");
table.string("permissions_id", 100).notNullable().references("id").inTable("permissions").onDelete("CASCADE");
table.string("status", 20).notNullable().defaultTo("ACTIVE");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
table.primary(["roles_id", "permissions_id"]);
});
}
// 4. Users ↔ Roles junction table
if (!(await knex.schema.hasTable("users_roles"))) {
await knex.schema.createTable("users_roles", (table) => {
table.string("roles_id", 100).notNullable().references("id").inTable("roles").onDelete("CASCADE");
table.integer("users_id").unsigned().notNullable().references("id").inTable("users").onDelete("CASCADE");
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
table.primary(["roles_id", "users_id"]);
table.index("users_id", "idx_users_roles_users_id");
});
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("users_roles");
await knex.schema.dropTableIfExists("roles_permissions");
await knex.schema.dropTableIfExists("permissions");
await knex.schema.dropTableIfExists("roles");
}
@@ -0,0 +1,94 @@
import type { Knex } from "knex";
// Maps the legacy users.role string to the new roles.id value.
// The old default was "user"; everything unmapped falls back to "member".
const ROLE_MAP: Record<string, string> = {
admin: "admin",
editor: "editor",
member: "member",
user: "member",
};
export async function up(knex: Knex): Promise<void> {
const hasColumn = await knex.schema.hasColumn("users", "role");
if (!hasColumn) return;
// 1. Ensure the three target roles exist so FK inserts succeed.
// Seeds will reconcile permissions later; we only need the rows.
const rolesToEnsure = [
{ id: "admin", role_name: "Administrator" },
{ id: "editor", role_name: "Editor" },
{ id: "member", role_name: "Member" },
];
for (const role of rolesToEnsure) {
const exists = await knex("roles").where("id", role.id).first();
if (!exists) {
await knex("roles").insert({
id: role.id,
role_name: role.role_name,
readonly: 1,
status: "ACTIVE",
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
}
// 2. Read users.role into memory BEFORE dropping the column.
// On SQLite, dropColumn recreates the table (create → copy → drop → rename),
// which can discard DML inserts to tables with FKs pointing at users.
const users: Array<{ id: number; role: string }> = await knex("users").select("id", "role");
// 3. Drop the column first.
await knex.schema.alterTable("users", (table) => {
table.dropColumn("role");
});
// 4. Now populate users_roles from the in-memory snapshot.
for (const user of users) {
const newRoleId = ROLE_MAP[user.role] ?? "member";
const alreadyAssigned = await knex("users_roles").where({ roles_id: newRoleId, users_id: user.id }).first();
if (!alreadyAssigned) {
await knex("users_roles").insert({
roles_id: newRoleId,
users_id: user.id,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
}
}
// Reverse map: pick the highest-precedence role when backfilling.
const REVERSE_ROLE_PRECEDENCE: string[] = ["admin", "editor", "member"];
export async function down(knex: Knex): Promise<void> {
const hasColumn = await knex.schema.hasColumn("users", "role");
if (!hasColumn) {
await knex.schema.alterTable("users", (table) => {
table.string("role").defaultTo("member");
});
}
// Backfill users.role from users_roles using deterministic precedence
const assignments: Array<{ users_id: number; roles_id: string }> = await knex("users_roles").select(
"users_id",
"roles_id",
);
// Group roles by user
const userRolesMap = new Map<number, string[]>();
for (const row of assignments) {
const list = userRolesMap.get(row.users_id) || [];
list.push(row.roles_id);
userRolesMap.set(row.users_id, list);
}
// Pick highest-precedence role for each user
for (const [userId, roleIds] of userRolesMap) {
const bestRole = REVERSE_ROLE_PRECEDENCE.find((r) => roleIds.includes(r)) || roleIds[0] || "member";
await knex("users").where("id", userId).update({ role: bestRole });
}
}
+211 -221
View File
@@ -1,12 +1,12 @@
{
"name": "kener",
"version": "4.0.20",
"version": "4.0.23",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kener",
"version": "4.0.20",
"version": "4.0.23",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
@@ -89,7 +89,7 @@
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/kit": "^2.53.3",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
@@ -114,7 +114,7 @@
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.43.8",
"svelte": "^5.53.5",
"svelte-awesome-color-picker": "^4.1.0",
"svelte-check": "^4.3.4",
"svelte-sonner": "^1.0.7",
@@ -1792,9 +1792,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
"integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
"cpu": [
"arm"
],
@@ -1805,9 +1805,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
"integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
"cpu": [
"arm64"
],
@@ -1818,9 +1818,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
"integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
"cpu": [
"arm64"
],
@@ -1831,9 +1831,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
"integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
"cpu": [
"x64"
],
@@ -1844,9 +1844,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
"integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
"cpu": [
"arm64"
],
@@ -1857,9 +1857,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
"integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
"cpu": [
"x64"
],
@@ -1870,9 +1870,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
"integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
"cpu": [
"arm"
],
@@ -1883,9 +1883,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
"integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
"cpu": [
"arm"
],
@@ -1896,9 +1896,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
"integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
"cpu": [
"arm64"
],
@@ -1909,9 +1909,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
"integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
"cpu": [
"arm64"
],
@@ -1922,9 +1922,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
"integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
"cpu": [
"loong64"
],
@@ -1935,9 +1935,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz",
"integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==",
"cpu": [
"loong64"
],
@@ -1948,9 +1948,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
"integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
"cpu": [
"ppc64"
],
@@ -1961,9 +1961,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz",
"integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==",
"cpu": [
"ppc64"
],
@@ -1974,9 +1974,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
"integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
"cpu": [
"riscv64"
],
@@ -1987,9 +1987,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
"integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
"cpu": [
"riscv64"
],
@@ -2000,9 +2000,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
"integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
"cpu": [
"s390x"
],
@@ -2013,9 +2013,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
"integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
"cpu": [
"x64"
],
@@ -2026,9 +2026,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
"integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
"cpu": [
"x64"
],
@@ -2039,9 +2039,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz",
"integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==",
"cpu": [
"x64"
],
@@ -2052,9 +2052,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
"integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
"cpu": [
"arm64"
],
@@ -2065,9 +2065,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
"integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
"cpu": [
"arm64"
],
@@ -2078,9 +2078,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
"integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
"cpu": [
"ia32"
],
@@ -2091,9 +2091,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
"integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
"cpu": [
"x64"
],
@@ -2104,9 +2104,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
"integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
"cpu": [
"x64"
],
@@ -2262,9 +2262,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.52.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.52.0.tgz",
"integrity": "sha512-zG+HmJuSF7eC0e7xt2htlOcEMAdEtlVdb7+gAr+ef08EhtwUsjLxcAwBgUCJY3/5p08OVOxVZti91WfXeuLvsg==",
"version": "2.55.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz",
"integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -2272,12 +2272,11 @@
"@types/cookie": "^0.6.0",
"acorn": "^8.14.1",
"cookie": "^0.6.0",
"devalue": "^5.6.2",
"devalue": "^5.6.4",
"esm-env": "^1.2.2",
"kleur": "^4.1.5",
"magic-string": "^0.30.5",
"mrmime": "^2.0.0",
"sade": "^1.8.1",
"set-cookie-parser": "^3.0.0",
"sirv": "^3.0.0"
},
@@ -2289,10 +2288,10 @@
},
"peerDependencies": {
"@opentelemetry/api": "^1.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
"typescript": "^5.3.3",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
@@ -2868,8 +2867,7 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/@uiw/codemirror-theme-github": {
"version": "4.25.4",
@@ -2984,9 +2982,9 @@
"license": "Python-2.0"
},
"node_modules/aria-query": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -3142,10 +3140,13 @@
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/barse": {
"version": "0.4.3",
@@ -3334,13 +3335,15 @@
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/browserslist": {
@@ -3755,12 +3758,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
@@ -3809,9 +3806,9 @@
}
},
"node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -4538,9 +4535,9 @@
}
},
"node_modules/devalue": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
"version": "5.6.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
"license": "MIT"
},
"node_modules/dns2": {
@@ -4591,9 +4588,9 @@
}
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -5064,25 +5061,16 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fast-xml-parser": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
"node_modules/fast-xml-builder": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
"funding": [
{
"type": "github",
@@ -5091,7 +5079,24 @@
],
"license": "MIT",
"dependencies": {
"strnum": "^2.1.0"
"path-expression-matcher": "^1.1.3"
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.8",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz",
"integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.2.0",
"strnum": "^2.2.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -5577,42 +5582,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob/node_modules/balanced-match": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz",
"integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==",
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
"integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/globalthis": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
@@ -7151,27 +7120,27 @@
}
},
"node_modules/mailparser": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.1.tgz",
"integrity": "sha512-6vHZcco3fWsDMkf4Vz9iAfxvwrKNGbHx0dV1RKVphQ/zaNY34Buc7D37LSa09jeSeybWzYcTPjhiZFxzVRJedA==",
"version": "3.9.5",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.5.tgz",
"integrity": "sha512-i5mXdEqDrh7I095uiQiebOrc00AOIJRLlpze1nBb82oZpI4GaCJIn9ypXR2bqb/Ayr7q+nmT6k11yYU5Age/Gg==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0",
"he": "1.2.0",
"html-to-text": "9.0.5",
"iconv-lite": "0.7.0",
"iconv-lite": "0.7.2",
"libmime": "5.3.7",
"linkify-it": "5.0.0",
"nodemailer": "7.0.11",
"nodemailer": "8.0.3",
"punycode.js": "2.3.1",
"tlds": "1.261.0"
}
},
"node_modules/mailparser/node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -7185,9 +7154,9 @@
}
},
"node_modules/mailparser/node_modules/nodemailer": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz",
"integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==",
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.3.tgz",
"integrity": "sha512-JQNBqvK+bj3NMhUFR3wmCl3SYcOeMotDiwDBvIoCuQdF0PvlIY0BH+FJ2CG7u4cXKPChplE78oowlH/Otsc4ZQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -7358,15 +7327,18 @@
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"license": "ISC",
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^1.1.7"
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "*"
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
@@ -7466,6 +7438,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
@@ -8090,6 +8063,21 @@
"node": ">= 0.8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz",
"integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -8979,9 +8967,9 @@
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
"integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@@ -8994,31 +8982,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.57.1",
"@rollup/rollup-android-arm64": "4.57.1",
"@rollup/rollup-darwin-arm64": "4.57.1",
"@rollup/rollup-darwin-x64": "4.57.1",
"@rollup/rollup-freebsd-arm64": "4.57.1",
"@rollup/rollup-freebsd-x64": "4.57.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
"@rollup/rollup-linux-arm64-musl": "4.57.1",
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
"@rollup/rollup-linux-loong64-musl": "4.57.1",
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
"@rollup/rollup-linux-x64-gnu": "4.57.1",
"@rollup/rollup-linux-x64-musl": "4.57.1",
"@rollup/rollup-openbsd-x64": "4.57.1",
"@rollup/rollup-openharmony-arm64": "4.57.1",
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
"@rollup/rollup-win32-x64-gnu": "4.57.1",
"@rollup/rollup-win32-x64-msvc": "4.57.1",
"@rollup/rollup-android-arm-eabi": "4.60.0",
"@rollup/rollup-android-arm64": "4.60.0",
"@rollup/rollup-darwin-arm64": "4.60.0",
"@rollup/rollup-darwin-x64": "4.60.0",
"@rollup/rollup-freebsd-arm64": "4.60.0",
"@rollup/rollup-freebsd-x64": "4.60.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
"@rollup/rollup-linux-arm-musleabihf": "4.60.0",
"@rollup/rollup-linux-arm64-gnu": "4.60.0",
"@rollup/rollup-linux-arm64-musl": "4.60.0",
"@rollup/rollup-linux-loong64-gnu": "4.60.0",
"@rollup/rollup-linux-loong64-musl": "4.60.0",
"@rollup/rollup-linux-ppc64-gnu": "4.60.0",
"@rollup/rollup-linux-ppc64-musl": "4.60.0",
"@rollup/rollup-linux-riscv64-gnu": "4.60.0",
"@rollup/rollup-linux-riscv64-musl": "4.60.0",
"@rollup/rollup-linux-s390x-gnu": "4.60.0",
"@rollup/rollup-linux-x64-gnu": "4.60.0",
"@rollup/rollup-linux-x64-musl": "4.60.0",
"@rollup/rollup-openbsd-x64": "4.60.0",
"@rollup/rollup-openharmony-arm64": "4.60.0",
"@rollup/rollup-win32-arm64-msvc": "4.60.0",
"@rollup/rollup-win32-ia32-msvc": "4.60.0",
"@rollup/rollup-win32-x64-gnu": "4.60.0",
"@rollup/rollup-win32-x64-msvc": "4.60.0",
"fsevents": "~2.3.2"
}
},
@@ -9092,6 +9080,7 @@
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"mri": "^1.1.0"
@@ -9849,9 +9838,9 @@
"license": "MIT"
},
"node_modules/strnum": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
"funding": [
{
"type": "github",
@@ -9904,20 +9893,21 @@
}
},
"node_modules/svelte": {
"version": "5.50.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.50.1.tgz",
"integrity": "sha512-/Jlom4ddkISyVHXpM2O5dXP9pYnaiFrVQzPbIL1/pEoOa77ZunCb6nDgUCTNCQ/X3t64z9ukrK6R+BbB3kPR3A==",
"version": "5.54.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.54.1.tgz",
"integrity": "sha512-ow8tncN097Ty8U1H+C3bM1xNlsCbnO2UZeN0lWBnv8f3jKho7QTTQ2LWbMXrPQDodLjH91n4kpNnLolyRhVE6A==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@types/estree": "^1.0.5",
"@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1",
"aria-query": "^5.3.1",
"aria-query": "5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"devalue": "^5.6.2",
"devalue": "^5.6.4",
"esm-env": "^1.2.1",
"esrap": "^2.2.2",
"is-reference": "^3.0.3",
@@ -10448,9 +10438,9 @@
}
},
"node_modules/undici": {
"version": "7.21.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz",
"integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==",
"version": "7.24.5",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz",
"integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
+13 -3
View File
@@ -1,6 +1,6 @@
{
"name": "kener",
"version": "4.0.20",
"version": "4.0.23",
"type": "module",
"private": false,
"license": "MIT",
@@ -55,7 +55,7 @@
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/kit": "^2.53.3",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
@@ -80,7 +80,7 @@
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.43.8",
"svelte": "^5.53.5",
"svelte-awesome-color-picker": "^4.1.0",
"svelte-check": "^4.3.4",
"svelte-sonner": "^1.0.7",
@@ -172,5 +172,15 @@
"style-to-object": "^1.0.14",
"svelte-codemirror-editor": "^2.1.0",
"vite-plugin-package-version": "^1.1.0"
},
"overrides": {
"fast-xml-parser": "^5.5.6",
"rollup": "^4.59.0",
"undici": "^7.24.0",
"minimatch": "^10.2.3",
"devalue": "^5.6.4",
"dompurify": "^3.3.2",
"cookie": "^0.7.0",
"mailparser": "^3.9.3"
}
}
+110 -70
View File
@@ -6,6 +6,7 @@ import Startup from "../src/lib/server/startup.ts";
import shutdownSchedulers from "../src/lib/server/schedulers/shutdown.ts";
import shutdownQueues from "../src/lib/server/queues/shutdown.ts";
import dbInstance from "../src/lib/server/db/db.ts";
import { redisConnection } from "../src/lib/server/redisConnector.ts";
import knex from "knex";
import knexOb from "../knexfile.js";
@@ -13,89 +14,128 @@ const PORT = process.env.PORT || 3000;
const base = process.env.KENER_BASE_PATH || "";
async function start() {
// Dynamic import so BODY_SIZE_LIMIT from .env is available
// before the handler reads it at module top-level
const { handler } = await import("../build/handler.js");
// Dynamic import so BODY_SIZE_LIMIT from .env is available
// before the handler reads it at module top-level
const { handler } = await import("../build/handler.js");
const app: any = express();
const db = knex(knexOb);
const app: any = express();
const db = knex(knexOb);
app.get(base + "/healthcheck", (req: any, res: any) => {
res.end("ok");
});
// Caps a health probe at 2s so a wedged dependency can not hang the
// endpoint. A probe is healthy unless it throws, times out, or resolves false.
const probe = async (check: () => Promise<unknown>): Promise<boolean> => {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const result = await Promise.race([
check(),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("health probe timeout")), 2000);
}),
]);
return result !== false;
} catch {
return false;
} finally {
clearTimeout(timer);
}
};
app.use(handler);
// Reports component health. Always 200 so healthcheck-driven restarters do
// not bounce the app while a dependency is down (a restart can not fix a
// dead database); pass ?strict=1 to get 503 when any component is down.
app.get(base + "/healthcheck", async (req: any, res: any) => {
const [dbOk, redisOk] = await Promise.all([
probe(() => dbInstance.ping()),
// Guard on status before PING: the shared ioredis client has
// maxRetriesPerRequest null, so commands sent while disconnected would
// queue forever and accumulate across healthcheck polls
probe(async () => {
const redis = redisConnection();
if (redis.status !== "ready") return false;
return await redis.ping();
}),
]);
const healthy = dbOk && redisOk;
const strict = req.query.strict === "1";
res.status(strict && !healthy ? 503 : 200).json({
status: healthy ? "ok" : "degraded",
db: dbOk,
redis: redisOk,
});
});
//migrations
async function runMigrations() {
try {
// Rename old .js migration entries to .ts in the knex_migrations table
// so Knex can find the renamed files on disk
const hasTable = await db.schema.hasTable("knex_migrations");
if (hasTable) {
const oldJsMigrations = await db("knex_migrations").where("name", "like", "%.js");
for (const row of oldJsMigrations) {
const newName = row.name.replace(/\.js$/, ".ts");
await db("knex_migrations").where("id", row.id).update({ name: newName });
console.log(`Renamed migration record: ${row.name} -> ${newName}`);
}
}
app.use(handler);
console.log("Running migrations...");
await db.migrate.latest(); // Runs migrations to the latest state
console.log("Migrations completed successfully!");
} catch (err) {
console.error("Error running migrations:", err);
}
}
//migrations
async function runMigrations() {
try {
// Rename old .js migration entries to .ts in the knex_migrations table
// so Knex can find the renamed files on disk
const hasTable = await db.schema.hasTable("knex_migrations");
if (hasTable) {
const oldJsMigrations = await db("knex_migrations").where("name", "like", "%.js");
for (const row of oldJsMigrations) {
const newName = row.name.replace(/\.js$/, ".ts");
await db("knex_migrations").where("id", row.id).update({ name: newName });
console.log(`Renamed migration record: ${row.name} -> ${newName}`);
}
}
//seed
async function runSeed() {
try {
console.log("Running seed...");
await db.seed.run(); // Runs seed to the latest state
console.log("Seed completed successfully!");
} catch (err) {
console.error("Error running seed:", err);
}
}
console.log("Running migrations...");
await db.migrate.latest(); // Runs migrations to the latest state
console.log("Migrations completed successfully!");
} catch (err) {
console.error("Error running migrations:", err);
}
}
app.listen(PORT, async () => {
await runMigrations();
await runSeed();
await db.destroy();
Startup();
console.log("Kener is running on port " + PORT + "!");
});
//seed
async function runSeed() {
try {
console.log("Running seed...");
await db.seed.run(); // Runs seed to the latest state
console.log("Seed completed successfully!");
} catch (err) {
console.error("Error running seed:", err);
}
}
// Graceful shutdown handler
async function gracefulShutdown(signal: string) {
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
app.listen(PORT, async () => {
await runMigrations();
await runSeed();
await db.destroy();
Startup();
console.log("Kener is running on port " + PORT + "!");
});
try {
console.log("Shutting down schedulers...");
await shutdownSchedulers();
console.log("Schedulers shut down successfully.");
// Graceful shutdown handler
async function gracefulShutdown(signal: string) {
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
console.log("Shutting down queues...");
await shutdownQueues();
console.log("Queues shut down successfully.");
try {
console.log("Shutting down schedulers...");
await shutdownSchedulers();
console.log("Schedulers shut down successfully.");
console.log("Closing database connection...");
await dbInstance.close();
console.log("Database connection closed successfully.");
console.log("Shutting down queues...");
await shutdownQueues();
console.log("Queues shut down successfully.");
console.log("Graceful shutdown completed.");
process.exit(0);
} catch (err) {
console.error("Error during graceful shutdown:", err);
process.exit(1);
}
}
console.log("Closing database connection...");
await dbInstance.close();
console.log("Database connection closed successfully.");
// Handle termination signals
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
console.log("Graceful shutdown completed.");
process.exit(0);
} catch (err) {
console.error("Error during graceful shutdown:", err);
process.exit(1);
}
}
// Handle termination signals
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
}
start();
+28
View File
@@ -0,0 +1,28 @@
import type { Knex } from "knex";
import { permissions } from "../src/lib/allPerms.ts";
export async function seed(knex: Knex): Promise<void> {
const permissionIds = new Set(permissions.map((p) => p.id));
// Get all existing permissions
const existing: Array<{ id: string }> = await knex("permissions").select("id");
const existingIds = new Set(existing.map((e) => e.id));
// Insert missing permissions
for (const perm of permissions) {
if (!existingIds.has(perm.id)) {
await knex("permissions").insert({
id: perm.id,
permission_name: perm.permission_name,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
}
// Delete permissions that are no longer in the seed list
const toDelete = existing.filter((e) => !permissionIds.has(e.id)).map((e) => e.id);
if (toDelete.length > 0) {
await knex("permissions").whereIn("id", toDelete).del();
}
}
+106
View File
@@ -0,0 +1,106 @@
import type { Knex } from "knex";
import { permissions } from "../src/lib/allPerms.ts";
/**
* Seeds the three readonly roles (admin, editor, member),
* assigns permissions to each role in roles_permissions,
* and migrates existing users.role → users_roles.
*
* Permission mapping derived from src/routes/(manage)/manage/api/+server.ts:
*
* admin → all permissions
* editor → all except api_keys.delete (AdminCan-only)
* member → all .read permissions only
*/
const readonlyRoles = [
{ id: "admin", role_name: "Administrator" },
{ id: "editor", role_name: "Editor" },
{ id: "member", role_name: "Member" },
];
const allPermissionIds = permissions.map((p) => p.id);
const readPermissionIds = allPermissionIds.filter((id) => id.endsWith(".read"));
const rolePermissions: Record<string, string[]> = {
admin: allPermissionIds,
editor: allPermissionIds.filter((id) => id !== "api_keys.delete"),
member: readPermissionIds,
};
export async function seed(knex: Knex): Promise<void> {
// 1. Ensure readonly roles exist
for (const role of readonlyRoles) {
const existing = await knex("roles").where("id", role.id).first();
if (!existing) {
await knex("roles").insert({
id: role.id,
role_name: role.role_name,
readonly: 1,
status: "ACTIVE",
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
}
// 2. Seed roles_permissions for readonly roles
// Only insert permissions that actually exist in the permissions table
// to avoid FK constraint errors if permissions seed hasn't run yet.
const existingPermRows: Array<{ id: string }> = await knex("permissions").select("id");
const existingPermIds = new Set(existingPermRows.map((p) => p.id));
for (const [roleId, permissionIds] of Object.entries(rolePermissions)) {
const validPermissionIds = permissionIds.filter((id) => existingPermIds.has(id));
const existingPerms: Array<{ permissions_id: string }> = await knex("roles_permissions")
.where("roles_id", roleId)
.select("permissions_id");
const existingSet = new Set(existingPerms.map((e) => e.permissions_id));
// Insert missing permissions
for (const permId of validPermissionIds) {
if (!existingSet.has(permId)) {
await knex("roles_permissions").insert({
roles_id: roleId,
permissions_id: permId,
status: "ACTIVE",
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
}
// Remove permissions no longer assigned to this role
const desiredSet = new Set(validPermissionIds);
const toRemove = existingPerms.filter((e) => !desiredSet.has(e.permissions_id)).map((e) => e.permissions_id);
if (toRemove.length > 0) {
await knex("roles_permissions").where("roles_id", roleId).whereIn("permissions_id", toRemove).del();
}
}
// 3. Migrate existing users: read users.role → insert into users_roles
const hasRoleColumn = await knex.schema.hasColumn("users", "role");
if (hasRoleColumn) {
const users: Array<{ id: number; role: string }> = await knex("users").select("id", "role");
for (const user of users) {
if (!user.role) continue;
// Only migrate if a matching role exists
const roleExists = await knex("roles").where("id", user.role).first();
if (!roleExists) continue;
// Skip if already assigned
const existing = await knex("users_roles").where({ roles_id: user.role, users_id: user.id }).first();
if (!existing) {
await knex("users_roles").insert({
roles_id: user.role,
users_id: user.id,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
}
}
}
}
+73
View File
@@ -0,0 +1,73 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="refresh" content="30" />
<title>%sveltekit.status% — Status page temporarily unavailable</title>
<style>
:root {
color-scheme: light dark;
--bg: #ffffff;
--fg: #09090b;
--muted: #71717a;
--border: #e4e4e7;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #09090b;
--fg: #fafafa;
--muted: #a1a1aa;
--border: #27272a;
}
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
color: var(--fg);
font-family:
ui-sans-serif,
system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
}
.card {
max-width: 28rem;
margin: 1rem;
padding: 2rem;
border: 1px solid var(--border);
border-radius: 1rem;
text-align: center;
}
h1 {
font-size: 1.25rem;
margin: 0 0 0.5rem;
}
p {
color: var(--muted);
font-size: 0.875rem;
line-height: 1.5;
margin: 0.25rem 0;
}
.code {
color: var(--muted);
font-size: 0.75rem;
margin-top: 1.25rem;
}
</style>
</head>
<body>
<div class="card">
<h1>This status page is temporarily unavailable</h1>
<p>We are having trouble serving this page right now. It usually resolves on its own.</p>
<p>This page will retry automatically in 30 seconds.</p>
<div class="code">%sveltekit.status%</div>
</div>
</body>
</html>
+17 -1
View File
@@ -4,6 +4,7 @@ import { VerifyAPIKey } from "$lib/server/controllers/apiController";
import db from "$lib/server/db/db";
import type { UnauthorizedResponse, NotFoundResponse } from "$lib/types/api";
import { GetMonitorsParsed } from "$lib/server/controllers/monitorsController";
import GC from "$lib/global-constants";
const API_PATH_PREFIX = "/api/";
@@ -119,6 +120,18 @@ const apiAuthHandle: Handle = async ({ event, resolve }) => {
return json(errorResponse, { status: 401 });
}
// API consumers must always get JSON; without this, an /api/ path with no
// matching route falls through to SvelteKit's HTML error page
if (event.route.id === null) {
const errorResponse: NotFoundResponse = {
error: {
code: "NOT_FOUND",
message: `No API route matches '${pathname}'`,
},
};
return json(errorResponse, { status: 404 });
}
// Validate monitor tag exists for /api/(vX/)?monitors/:monitor_tag/* routes
const monitorTag = extractMonitorTag(pathname);
if (monitorTag) {
@@ -173,7 +186,10 @@ const apiAuthHandle: Handle = async ({ event, resolve }) => {
// Validate page_path exists for /api/(vX/)?pages/:page_path/* routes
const pagePath = extractPagePath(pathname);
if (pagePath) {
const page = await db.getPageByPath(pagePath);
// The home page has an empty page_path, unreachable as a URL segment;
// the ~home token addresses it instead
const lookupPath = pagePath === GC.HOME_PAGE_TOKEN ? "" : pagePath;
const page = await db.getPageByPath(lookupPath);
if (!page) {
const errorResponse: NotFoundResponse = {
error: {
+274
View File
@@ -0,0 +1,274 @@
/**
* Permissions derived from src/routes/(manage)/manage/api/+server.ts actions.
* Grouped by domain with read/write granularity.
*
* Mapping from actions → permissions:
*
* monitors.read → getMonitors, getMonitoringDataPaginated
* monitors.write → storeMonitorData, updateMonitoringData, deleteMonitor, deleteMonitorData, cloneMonitor, testMonitor
*
* incidents.read → getIncidents, getIncident, getComments
* incidents.write → createIncident, updateIncident, deleteIncident, addMonitor, removeMonitor, addComment, deleteComment, updateComment
*
* maintenances.read → getMaintenances, getMaintenance, getMaintenanceEvents, getMaintenanceEvent, getMaintenanceMonitors
* maintenances.write → createMaintenance, updateMaintenance, deleteMaintenance, createMaintenanceEvent, updateMaintenanceEvent, deleteMaintenanceEvent, addMonitorToMaintenance, removeMonitorFromMaintenance, updateMaintenanceMonitorImpact
*
* pages.read → getPages
* pages.write → createPage, updatePage, deletePage, addMonitorToPage, removeMonitorFromPage, reorderPageMonitors
*
* triggers.read → getTriggers
* triggers.write → createUpdateTrigger, updateMonitorTriggers, deleteTrigger, testTrigger
*
* alerts.read → getMonitorAlertConfig, getMonitorAlertConfigById, getMonitorAlertConfigsByMonitorTag, getAlertConfigsPaginated, getAllAlertsPaginated
* alerts.write → createMonitorAlertConfig, updateMonitorAlertConfig, deleteMonitorAlertConfig, toggleMonitorAlertConfigStatus, deleteMonitorAlertV2, updateMonitorAlertV2Status
*
* api_keys.read → getAPIKeys
* api_keys.write → createNewApiKey, updateApiKeyStatus
* api_keys.delete → deleteApiKey (admin-only today)
*
* users.read → getUsers
* users.write → manualUpdate, createNewUser, resendInvitation, sendVerificationEmail
*
* settings.read → getAllSiteData, getSiteDataByKey, getSubscriptionsConfig
* settings.write → storeSiteData, updateSubscriptionsConfig
*
* subscribers.read → getSubscribersByMethod, getSubscriberWithSubscriptions, getSubscriberCountsByMethod, getAdminSubscribers
* subscribers.write → deleteUserSubscription, updateUserSubscriptionStatus, adminUpdateSubscriptionStatus, adminDeleteSubscriber, adminAddSubscriber
*
* email_templates.read → getGeneralEmailTemplates, getGeneralEmailTemplateById
* email_templates.write → updateGeneralEmailTemplate
*
* images.write → uploadImage, deleteImage
*/
export const permissions: Array<{ id: string; permission_name: string }> = [
// Monitors
{ id: "monitors.read", permission_name: "View monitors and monitoring data" },
{ id: "monitors.write", permission_name: "Create, update, delete, and clone monitors" },
// Incidents
{ id: "incidents.read", permission_name: "View incidents and comments" },
{ id: "incidents.write", permission_name: "Create, update, and delete incidents and comments" },
// Maintenances
{ id: "maintenances.read", permission_name: "View maintenances and events" },
{ id: "maintenances.write", permission_name: "Create, update, and delete maintenances and events" },
// Pages
{ id: "pages.read", permission_name: "View pages" },
{ id: "pages.write", permission_name: "Create, update, and delete pages" },
// Triggers
{ id: "triggers.read", permission_name: "View triggers" },
{ id: "triggers.write", permission_name: "Create, update, delete, and test triggers" },
// Alerts
{ id: "alerts.read", permission_name: "View alert configurations and alert history" },
{ id: "alerts.write", permission_name: "Create, update, and delete alert configurations" },
// API Keys
{ id: "api_keys.read", permission_name: "View API keys" },
{ id: "api_keys.write", permission_name: "Create and update API keys" },
{ id: "api_keys.delete", permission_name: "Delete API keys" },
// Users
{ id: "users.read", permission_name: "View users" },
{ id: "users.write", permission_name: "Manage users, invitations, and verification" },
// Settings (site data + subscriptions config)
{ id: "settings.read", permission_name: "View site settings and subscriptions config" },
{ id: "settings.write", permission_name: "Update site settings and subscriptions config" },
// Subscribers
{ id: "subscribers.read", permission_name: "View subscribers" },
{ id: "subscribers.write", permission_name: "Manage subscribers and subscriptions" },
// Email Templates
{ id: "email_templates.read", permission_name: "View email templates" },
{ id: "email_templates.write", permission_name: "Update email templates" },
// Images
{ id: "images.write", permission_name: "Upload and delete images" },
// Roles
{ id: "roles.read", permission_name: "View roles, permissions, and user assignments" },
{ id: "roles.write", permission_name: "Create, update, and delete roles" },
{ id: "roles.assign_permissions", permission_name: "Add and remove permissions from roles" },
{ id: "roles.assign_users", permission_name: "Add and remove users to and from roles" },
];
export const ACTION_PERMISSION_MAP: Record<string, string | null> = {
// Self-actions — no permission needed beyond being logged in
updateUser: null,
updatePassword: null,
sendVerificationEmail: null, // controller has its own self-vs-other check
// Settings
getAllSiteData: "settings.read",
getSiteDataByKey: "settings.read",
getSubscriptionsConfig: "settings.read",
storeSiteData: "settings.write",
updateSubscriptionsConfig: "settings.write",
// Users
getUsers: "users.read",
manualUpdate: "users.write",
createNewUser: "users.write",
resendInvitation: "users.write",
// Monitors
getMonitors: "monitors.read",
getMonitoringDataPaginated: "monitors.read",
storeMonitorData: "monitors.write",
updateMonitoringData: "monitors.write",
deleteMonitor: "monitors.write",
deleteMonitorData: "monitors.write",
cloneMonitor: "monitors.write",
testMonitor: "monitors.write",
// Incidents
getIncidents: "incidents.read",
getIncident: "incidents.read",
getComments: "incidents.read",
createIncident: "incidents.write",
updateIncident: "incidents.write",
deleteIncident: "incidents.write",
addMonitor: "incidents.write",
removeMonitor: "incidents.write",
addComment: "incidents.write",
deleteComment: "incidents.write",
updateComment: "incidents.write",
// Maintenances
getMaintenances: "maintenances.read",
getMaintenance: "maintenances.read",
getMaintenanceEvents: "maintenances.read",
getMaintenanceEvent: "maintenances.read",
getMaintenanceMonitors: "maintenances.read",
createMaintenance: "maintenances.write",
updateMaintenance: "maintenances.write",
deleteMaintenance: "maintenances.write",
createMaintenanceEvent: "maintenances.write",
updateMaintenanceEvent: "maintenances.write",
deleteMaintenanceEvent: "maintenances.write",
addMonitorToMaintenance: "maintenances.write",
removeMonitorFromMaintenance: "maintenances.write",
updateMaintenanceMonitorImpact: "maintenances.write",
// Pages
getPages: "pages.read",
createPage: "pages.write",
updatePage: "pages.write",
deletePage: "pages.write",
addMonitorToPage: "pages.write",
removeMonitorFromPage: "pages.write",
reorderPageMonitors: "pages.write",
// Triggers
getTriggers: "triggers.read",
createUpdateTrigger: "triggers.write",
updateMonitorTriggers: "triggers.write",
deleteTrigger: "triggers.write",
testTrigger: "triggers.write",
// Alerts
getAllAlertsPaginated: "alerts.read",
getMonitorAlertConfig: "alerts.read",
getMonitorAlertConfigById: "alerts.read",
getMonitorAlertConfigsByMonitorTag: "alerts.read",
getAlertConfigsPaginated: "alerts.read",
createMonitorAlertConfig: "alerts.write",
updateMonitorAlertConfig: "alerts.write",
deleteMonitorAlertConfig: "alerts.write",
toggleMonitorAlertConfigStatus: "alerts.write",
deleteMonitorAlertV2: "alerts.write",
updateMonitorAlertV2Status: "alerts.write",
// API Keys
getAPIKeys: "api_keys.read",
createNewApiKey: "api_keys.write",
updateApiKeyStatus: "api_keys.write",
deleteApiKey: "api_keys.delete",
// Subscribers
getSubscribersByMethod: "subscribers.read",
getSubscriberWithSubscriptions: "subscribers.read",
getSubscriberCountsByMethod: "subscribers.read",
getAdminSubscribers: "subscribers.read",
deleteUserSubscription: "subscribers.write",
updateUserSubscriptionStatus: "subscribers.write",
adminUpdateSubscriptionStatus: "subscribers.write",
adminDeleteSubscriber: "subscribers.write",
adminAddSubscriber: "subscribers.write",
// Email Templates
getGeneralEmailTemplates: "email_templates.read",
getGeneralEmailTemplateById: "email_templates.read",
updateGeneralEmailTemplate: "email_templates.write",
// Images
uploadImage: "images.write",
deleteImage: "images.write",
// Roles
getRoles: "roles.read",
getAllPermissions: "roles.read",
getRolePermissions: "roles.read",
getRoleUsers: "roles.read",
createRole: "roles.write",
updateRole: "roles.write",
deleteRole: "roles.write",
updateRolePermissions: "roles.assign_permissions",
addUserToRole: "roles.assign_users",
removeUserFromRole: "roles.assign_users",
};
export const ROUTE_PERMISSION_MAP: Record<string, string | null> = {
// Monitors
"/(manage)/manage/app/monitors": "monitors.read",
"/(manage)/manage/app/monitors/[tag]": "monitors.read",
"/(manage)/manage/app/monitoring-data": "monitors.read",
// Incidents
"/(manage)/manage/app/incidents": "incidents.read",
"/(manage)/manage/app/incidents/[incident_id]": "incidents.read",
// Maintenances
"/(manage)/manage/app/maintenances": "maintenances.read",
"/(manage)/manage/app/maintenances/[id]": "maintenances.read",
// Pages
"/(manage)/manage/app/pages": "pages.read",
"/(manage)/manage/app/pages/[page_id]": "pages.read",
// Triggers
"/(manage)/manage/app/triggers": "triggers.read",
"/(manage)/manage/app/triggers/[trigger_id]": "triggers.read",
// Alerts
"/(manage)/manage/app/alerts": "alerts.read",
"/(manage)/manage/app/alerts/[alert_config_id]": "alerts.read",
"/(manage)/manage/app/alerts/logs/[alert_config_id]": "alerts.read",
// API Keys
"/(manage)/manage/app/api-keys": "api_keys.read",
// Users
"/(manage)/manage/app/users": "users.read",
// Settings
"/(manage)/manage/app/site-configurations": "settings.read",
"/(manage)/manage/app/customizations": "settings.read",
"/(manage)/manage/app/internationalization": "settings.read",
"/(manage)/manage/app/analytics-providers": "settings.read",
"/(manage)/manage/app/badges": "settings.read",
"/(manage)/manage/app/embed": "settings.read",
// Subscribers
"/(manage)/manage/app/subscriptions": "subscribers.read",
// Email Templates
"/(manage)/manage/app/templates": "email_templates.read",
// Roles
"/(manage)/manage/app/roles": "roles.read",
};
+36
View File
@@ -31,3 +31,39 @@ export default function urlResolve(resolve: ResolveFn, path: string, params?: Re
}
return resolve(path);
}
/**
* Resolves a path to an absolute URL by prefixing the site URL.
* Required for meta tags like og:image and twitter:image that need absolute URLs.
* @param resolve - The resolve function from $app/paths
* @param siteUrl - The site URL (e.g., "https://status.example.com")
* @param path - The route path or absolute URL
* @param params - Optional parameters for dynamic route segments
* @returns An absolute URL, or the resolved relative URL if siteUrl is empty
*
* @example
* ```ts
* absoluteResolve(resolve, "https://status.example.com", "/uploads/preview.png")
* // => "https://status.example.com/uploads/preview.png"
* ```
*/
export function absoluteResolve(
resolve: ResolveFn,
siteUrl: string,
path: string,
params?: Record<string, string>
): string {
// Normalize relative paths like "./assets/..." to "/assets/..." so the
// final URL doesn't contain "/./" segments (crawlers don't normalize these)
const normalizedPath = path.startsWith("./") ? path.slice(1) : path;
const resolved = urlResolve(resolve, normalizedPath, params);
// Already absolute, return as-is
if (resolved.startsWith("http://") || resolved.startsWith("https://")) {
return resolved;
}
if (!siteUrl) {
return resolved;
}
const trimmedSiteUrl = siteUrl.replace(/\/+$/, "");
return trimmedSiteUrl + (resolved.startsWith("/") ? resolved : "/" + resolved);
}
+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,
+30 -29
View File
@@ -22,39 +22,39 @@
"Day": "Den",
"Day Uptime": "Denní dostupnost",
"Days": "Dní",
"Degraded": "Zhoršený",
"DEGRADED": "ZHORŠENÝ",
"Degraded Performance": "Zhoršený výkon",
"Didn't receive the code? Resend": "Nepřišel vám kód? Odeslat znovu",
"Down": "Nedostupný",
"DOWN": "VÝPADEK",
"Degraded": "Omezený",
"DEGRADED": "OMEZENÝ",
"Degraded Performance": "Snížený výkon",
"Didn't receive the code? Resend": "Nepřišel kód? Odeslat znovu",
"Down": "Nedostupné",
"DOWN": "NEDOSTUPNÉ",
"Duration": "Trvání",
"Edit Monitor": "Upravit monitor",
"Email address": "E-mailová adresa",
"Embed Monitor": "Vložit monitor",
"Embed this monitor in your website or app": "Vložte tento monitor na svůj web nebo do aplikace",
"Embed this monitor in your website or app": "Vložte tento monitor na web nebo do aplikace",
"End Time": "Konec",
"Enter the verification code sent to your email.": "Zadejte ověřovací kód zaslaný na váš e-mail.",
"Enter the verification code sent to your email.": "Zadejte ověřovací kód zaslaný na e-mail",
"Events": "Události",
"Failed to load data": "Nepodařilo se načíst data",
"Failed to load latency data": "Nepodařilo se načíst data latence",
"Failed to load status data for this day": "Nepodařilo se načíst data stavu pro tento den",
"Failed to send verification code": "Nepodařilo se odeslat ověřovací kód",
"Failed to update preference": "Nepodařilo se aktualizovat nastavení",
"Failed to update preference": "Nepodařilo se uložit nastavení",
"Format": "Formát",
"Get badges for this monitor": "Získat odznaky pro tento monitor",
"Get notified about incidents and scheduled maintenance.": "Dostávejte upozornění na incidenty a plánovanou údržbu.",
"Get notified about incidents and scheduled maintenance.": "Dostávejte upozornění na incidenty a plánovanou údržbu",
"Get notified about incidents updates": "Dostávejte upozornění na aktualizace incidentů",
"Get notified about scheduled maintenance": "Dostávejte upozornění na plánovanou údržbu",
"Home": "Domů",
"IDENTIFIED": "IDENTIFIKOVÁNO",
"iFrame": "iFrame",
"Impact": "Dopad",
"incident": "Incident",
"incident": "incident",
"Incident": "Incident",
"Incident Updates": "Aktualizace incidentů",
"Incidents": "Incidenty",
"Included Monitors (%count)": "Zahrnutých monitorů: %count",
"Included Monitors (%count)": "Zahrnuté monitory (%count)",
"INVESTIGATING": "VYŠETŘOVÁNÍ",
"Last Updated": "Naposledy aktualizováno",
"Latency": "Latence",
@@ -62,18 +62,18 @@
"Latency Over Time": "Latence v čase",
"Latency Trend": "Trend latence",
"Latest Latency": "Poslední latence",
"Latest Status": "Posled stav",
"Latest Status": "Naposledy zjištěný stav",
"Light": "Světlý",
"Live Status": "Aktuální stav",
"Loading your preferences...": "Načítám vaše nastavení...",
"maintenance": "Údržba",
"Loading your preferences...": "Načítá nastavení...",
"maintenance": "údržba",
"Maintenance": "Údržba",
"MAINTENANCE": "ÚDRŽBA",
"Maintenance Updates": "Aktualizace údržby",
"Maintenances": "Údrždy",
"Maintenances": "Údržby",
"Major System Outage": "Závažný výpadek systému",
"Manage Site": "Spravovat stránku",
"Manage your notification preferences.": "Spravujte svá nastavení oznámení.",
"Manage your notification preferences.": "Spravujte nastavení oznámení",
"Max Latency": "Max. latence",
"maximum": "maximální",
"Maximum Latency": "Maximální latence",
@@ -82,33 +82,34 @@
"Minimum Latency": "Minimální latence",
"Minute-by-minute status data for this day": "Minutová data stavu pro tento den",
"MONITORING": "MONITOROVÁNÍ",
"Network error. Please try again.": "Chyba sítě. Zkuste to prosím znovu.",
"Network error. Please try again.": "Chyba sítě. Zkuste to znovu",
"No Events in %currentMonth": "V měsíci %currentMonth nejsou plánované žádné události",
"No events to show": "Žádné události k zobrazení",
"No incidents for this day": "Pro tento den nejsou evidovány žádné incidenty",
"No latency data available for this day": "Pro tento den nejsou k dispozici data latence",
"No latency data available for this day": "Pro tento den nejsou dostupná data latence",
"No maintenances for this day": "Pro tento den není naplánovaná žádná údržba",
"No monitors affected": "Žádné zasažené monitory",
"No monitors available.": "Žádné dostupné monitory.",
"No monitors available.": "Žádné dostupné monitory",
"No ongoing maintenances": "Žádná probíhající údržba",
"No past maintenances": "Žádná minulá údržba",
"No Status Available": "Stav není k dispozici",
"No upcoming maintenances": "Žádná nadcházející údržba",
"No Updates": "Žádné aktualizace",
"No updates yet": "Zatím bez aktualizací",
"No updates yet": "Zatím žádné aktualizace",
"NO_DATA": "Žádná data",
"Notifications": "Oznámení",
"One-time": "Jednorázově",
"Ongoing": "Probíhající",
"ONGOING": "PROBÍHAJÍCÍ",
"Ongoing Maintenances": "Probíhající údržby",
"Operational": "V provozu",
"Partial Degraded Performance": "Částečně zhoršený výkon",
"Partial Degraded Performance": "Částečně omezený provoz",
"Partial System Outage": "Částečný výpadek systému",
"Past": "Minulé",
"Per-Minute Status": "Stav po minutách",
"Pinging": "Pingování",
"Please enter a valid email address": "Zadejte platnou e-mailovou adresu",
"Please enter the 6-digit verification code": "Zadejte prosím 6místný ověřovací kód",
"Please enter the 6-digit verification code": "Zadejte 6místný ověřovací kód",
"Read less": "Zobrazit méně",
"Read more": "Zobrazit více",
"READY": "PŘIPRAVENO",
@@ -120,20 +121,20 @@
"Scheduled Windows": "Naplánované úlohy",
"Script": "Skript",
"Select Language": "Vyberte jazyk",
"Select latency metric to display": "Vyberte metriku latence k zobrazení",
"Select latency metric to display": "Vyberte metriku latence",
"Select Range": "Vyberte rozsah",
"Sending...": "Odesílání...",
"Standard": "Standardní",
"Start Time": "Začátek",
"Status": "Stav",
"Status Badge": "Odznak stavu",
"Status Embed": "Status pro vložení",
"Status Embed": "Stav pro vložení",
"Status history and latency trend": "Historie stavu a trend latence",
"Subscribe": "Odebírat",
"Subscribe": "Přihlásit se k odběru",
"Subscribe to Updates": "Odebírat aktualizace",
"Theme": "Motiv",
"There are no incidents or maintenances scheduled for this month.": "Na tento měsíc nejsou naplánované incidenty ani údržba.",
"There are no ongoing incidents or maintenance events.": "V tuto chvíli neprobíhají žádné incidenty ani údržba.",
"There are no incidents or maintenances scheduled for this month.": "Na tento měsíc nejsou naplánované žádné incidenty ani údržby",
"There are no ongoing incidents or maintenance events.": "V tuto chvíli neprobíhají žádné incidenty ani údržba",
"Timeline": "Časová osa",
"Total Incidents": "Celkem incidentů",
"Total Maintenances": "Celkem údržeb",
@@ -151,6 +152,6 @@
"Verification failed": "Ověření se nezdařilo",
"Verify": "Ověřit",
"Verifying": "Ověřování",
"We sent a 6-digit code to": "Poslali jsme 6místný kód na"
"We sent a 6-digit code to": "Odeslali jsme 6místný kód na"
}
}
+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",
+35 -34
View File
@@ -6,7 +6,7 @@
"30 Days": "30 dní",
"7 Days": "7 dní",
"90 Days": "90 dní",
"Affected Monitors (%count)": "Ovládacie monitory (%count)",
"Affected Monitors (%count)": "Ovplyvnené monitory (%count)",
"All Systems Operational": "Všetky systémy sú v prevádzke",
"average": "priemerná",
"Average Latency": "Priemerná latencia",
@@ -22,58 +22,58 @@
"Day": "Deň",
"Day Uptime": "Denná dostupnosť",
"Days": "Dní",
"Degraded": "Zhoršený",
"DEGRADED": "ZHORŠENÝ",
"Degraded Performance": "Zhoršený výkon",
"Didn't receive the code? Resend": "Nedostali ste kód? Odoslať znova",
"Down": "Nedostupný",
"DOWN": "VÝPADOK",
"Degraded": "Obmedzený",
"DEGRADED": "OBMEDZENÝ",
"Degraded Performance": "Znížený výkon",
"Didn't receive the code? Resend": "Neprišiel kód? Odoslať znova",
"Down": "Nedostupné",
"DOWN": "NEDOSTUPNÉ",
"Duration": "Trvanie",
"Edit Monitor": "Upraviť monitor",
"Email address": "E-mailová adresa",
"Embed Monitor": "Vložiť monitor",
"Embed this monitor in your website or app": "Vložte tento monitor na svoj web alebo do aplikácie",
"Embed this monitor in your website or app": "Vložte tento monitor na web alebo do aplikácie",
"End Time": "Koniec",
"Enter the verification code sent to your email.": "Zadajte overovací kód zaslaný na váš e-mail.",
"Enter the verification code sent to your email.": "Zadajte overovací kód zaslaný na e-mail",
"Events": "Udalosti",
"Failed to load data": "Nepodarilo sa načítať dáta",
"Failed to load latency data": "Nepodarilo sa načítať dáta latencie",
"Failed to load status data for this day": "Nepodarilo sa načítať dáta stavu pre tento deň",
"Failed to load status data for this day": "Nepodarilo sa načítať stavové dáta pre tento deň",
"Failed to send verification code": "Nepodarilo sa odoslať overovací kód",
"Failed to update preference": "Nepodarilo sa aktualizovať nastavenia",
"Failed to update preference": "Nepodarilo sa uložiť nastavenie",
"Format": "Formát",
"Get badges for this monitor": "Získať odznaky pre tento monitor",
"Get notified about incidents and scheduled maintenance.": "Dostávajte upozornenia na incidenty a plánovanú údržbu.",
"Get notified about incidents and scheduled maintenance.": "Dostávajte upozornenia na incidenty a plánovanú údržbu",
"Get notified about incidents updates": "Dostávajte upozornenia na aktualizácie incidentov",
"Get notified about scheduled maintenance": "Dostávajte upozornenia na plánovanú údržbu",
"Home": "Domov",
"IDENTIFIED": "IDENTIFIKOVANÉ",
"iFrame": "iFrame",
"Impact": "Dopad",
"incident": "Incident",
"incident": "incident",
"Incident": "Incident",
"Incident Updates": "Aktualizácie incidentov",
"Incidents": "Incidenty",
"Included Monitors (%count)": "Zahrnutých monitorov: %count",
"INVESTIGATING": "VYŠETROVANIE",
"Included Monitors (%count)": "Zahrnuté monitory (%count)",
"INVESTIGATING": "PREBIEHA VYŠETROVANIE",
"Last Updated": "Naposledy aktualizované",
"Latency": "Latencia",
"Latency Embed": "Vložená latencia",
"Latency Over Time": "Latencia v čase",
"Latency Trend": "Trend latencie",
"Latest Latency": "Posledná latencia",
"Latest Status": "Posledný stav",
"Latest Status": "Naposledy zistený stav",
"Light": "Svetlý",
"Live Status": "Aktuálny stav",
"Loading your preferences...": "Načítavam vaše nastavenia...",
"maintenance": "Údržba",
"Loading your preferences...": "Načítavanie nastavení...",
"maintenance": "údržba",
"Maintenance": "Údržba",
"MAINTENANCE": "ÚDRŽBA",
"Maintenance Updates": "Aktualizácie údržby",
"Maintenances": "Údržby",
"Major System Outage": "Závažný výpadok systému",
"Manage Site": "Spravovať stránku",
"Manage your notification preferences.": "Spravujte svoje nastavenia oznámení.",
"Manage your notification preferences.": "Spravujte nastavenia upozornení",
"Max Latency": "Max. latencia",
"maximum": "maximálna",
"Maximum Latency": "Maximálna latencia",
@@ -82,27 +82,28 @@
"Minimum Latency": "Minimálna latencia",
"Minute-by-minute status data for this day": "Minútové dáta stavu pre tento deň",
"MONITORING": "MONITOROVANIE",
"Network error. Please try again.": "Chyba siete. Skúste to znova.",
"No Events in %currentMonth": "V mesiaci %currentMonth nie sú plánované žiadne udalosti",
"Network error. Please try again.": "Chyba siete. Skúste to znova",
"No Events in %currentMonth": "V mesiaci %currentMonth nie sú žiadne udalosti",
"No events to show": "Žiadne udalosti na zobrazenie",
"No incidents for this day": "Pre tento deň nie sú evidované žiadne incidenty",
"No latency data available for this day": "Pre tento deň nie sú k dispozícii dáta latencie",
"No latency data available for this day": "Pre tento deň nie sú dostupné dáta latencie",
"No maintenances for this day": "Pre tento deň nie je naplánovaná žiadna údržba",
"No monitors affected": "Žiadne zasiahnuté monitory",
"No monitors available.": "Žiadne dostupné monitory.",
"No monitors affected": "Žiadne ovplyvnené monitory",
"No monitors available.": "Žiadne dostupné monitory",
"No ongoing maintenances": "Žiadna prebiehajúca údržba",
"No past maintenances": "Žiadna minula údržba",
"No past maintenances": "Žiadna minulá údržba",
"No Status Available": "Stav nie je k dispozícii",
"No upcoming maintenances": "Žiadna nadchádzajúca údržba",
"No Updates": "Žiadne aktualizácie",
"No updates yet": "Zatiaľ bez aktualizácií",
"No updates yet": "Zatiaľ žiadne aktualizácie",
"NO_DATA": "Žiadne dáta",
"Notifications": "Oznámenia",
"Notifications": "Upozornenia",
"One-time": "Jednorazovo",
"Ongoing": "Prebiehajúce",
"ONGOING": "PREBIEHAJÚCE",
"Ongoing Maintenances": "Prebiehajúce údržby",
"Operational": "V prevádzke",
"Partial Degraded Performance": "Čiastočne zhoršený výkon",
"Partial Degraded Performance": "Čiastočne obmedzený výkon",
"Partial System Outage": "Čiastočný výpadok systému",
"Past": "Minulé",
"Per-Minute Status": "Stav po minútach",
@@ -120,20 +121,20 @@
"Scheduled Windows": "Naplánované úlohy",
"Script": "Skript",
"Select Language": "Vyberte jazyk",
"Select latency metric to display": "Vyberte metriku latencie na zobrazenie",
"Select latency metric to display": "Vyberte metriku latencie",
"Select Range": "Vyberte rozsah",
"Sending...": "Odosielanie...",
"Standard": "Štandardný",
"Start Time": "Začiatok",
"Status": "Stav",
"Status Badge": "Odznak stavu",
"Status Embed": "Status na vloženie",
"Status Embed": "Stav na vloženie",
"Status history and latency trend": "História stavu a trend latencie",
"Subscribe": "Odober",
"Subscribe": "Prihlásiť sa na odber",
"Subscribe to Updates": "Odoberať aktualizácie",
"Theme": "Motív",
"There are no incidents or maintenances scheduled for this month.": "Na tento mesiac nie sú naplánované incidenty ani údržba.",
"There are no ongoing incidents or maintenance events.": "V tejto chvíli neprebiehajú žiadne incidenty ani údržba.",
"There are no incidents or maintenances scheduled for this month.": "Na tento mesiac nie sú naplánované žiadne incidenty ani údržby",
"There are no ongoing incidents or maintenance events.": "Momentálne neprebiehajú žiadne incidenty ani údržba",
"Timeline": "Časová os",
"Total Incidents": "Celkom incidentov",
"Total Maintenances": "Celkom údržieb",
@@ -151,6 +152,6 @@
"Verification failed": "Overenie zlyhalo",
"Verify": "Overiť",
"Verifying": "Overovanie",
"We sent a 6-digit code to": "Poslali sme 6-miestny kód na"
"We sent a 6-digit code to": "Odoslali sme 6-miestny kód na"
}
}
+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 {
@@ -159,6 +159,7 @@ export interface PageDashboardData {
pageStatus: { statusSummary: string; statusClass: string };
ongoingIncidents: IncidentForMonitorListWithComments[];
ongoingMaintenances: MaintenanceEventsMonitorList[];
upcomingMaintenances: MaintenanceEventsMonitorList[];
monitorTags: string[];
monitorGroupMembersByTag: Record<string, string[]>;
pageDetails: PageRecordTyped;
@@ -366,6 +367,7 @@ export const GetPageDashboardData = async (
pageStatus: BuildPageStatus([], nowTs),
ongoingIncidents: [],
ongoingMaintenances: [],
upcomingMaintenances: [],
monitorTags,
monitorGroupMembersByTag: {},
pageDetails: pageDetailsTyped,
@@ -376,7 +378,7 @@ export const GetPageDashboardData = async (
}
const eventSettings = layoutData.eventDisplaySettings;
// Fetch all dashboard data in parallel (respecting feature toggles)
const [latestData, parsedMonitors, ongoingIncidents, ongoingMaintenances] = await Promise.all([
const [latestData, parsedMonitors, ongoingIncidents, ongoingMaintenances, upcomingMaintenances] = await Promise.all([
GetLatestMonitoringDataAllActive(monitorTags),
GetMonitorsParsed({ tags: monitorTags, status: "ACTIVE", is_hidden: "NO" }),
eventSettings.incidents.enabled && eventSettings.incidents.ongoing.show
@@ -385,6 +387,13 @@ export const GetPageDashboardData = async (
eventSettings.maintenances.enabled && eventSettings.maintenances.ongoing.show
? GetOngoingMaintenances(monitorTags, nowTs)
: Promise.resolve([] as MaintenanceEventsMonitorList[]),
eventSettings.maintenances.enabled && eventSettings.maintenances.upcoming.show
? GetUpcomingMaintenanceEventsForMonitorList(
monitorTags,
eventSettings.maintenances.upcoming.maxCount,
eventSettings.maintenances.upcoming.daysInFuture,
)
: Promise.resolve([] as MaintenanceEventsMonitorList[]),
]);
const pageStatus = BuildPageStatus(latestData, nowTs);
@@ -403,6 +412,7 @@ export const GetPageDashboardData = async (
pageStatus,
ongoingIncidents,
ongoingMaintenances,
upcomingMaintenances,
monitorTags,
monitorGroupMembersByTag,
pageDetails: pageDetailsTyped,
@@ -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;
@@ -16,6 +16,7 @@ import { maintenanceToVariables, siteDataToVariables } from "../notification/not
import { GetAllSiteData } from "./controller.js";
import subscriberQueue from "../queues/subscriberQueue.js";
import GC from "../../global-constants";
import seedSiteData from "../db/seedSiteData.js";
// ============ Input Interfaces ============
@@ -78,6 +79,7 @@ export interface MaintenanceWithEvents extends MaintenanceWithMonitors {
export function determineEventStatus(
eventStartTimestamp: number,
eventEndTimestamp: number,
reminderBufferSeconds: number = 3600,
): "SCHEDULED" | "READY" | "ONGOING" | "COMPLETED" | "CANCELLED" {
const nowTimestamp = Math.floor(Date.now() / 1000);
@@ -87,38 +89,90 @@ export function determineEventStatus(
if (nowTimestamp >= eventStartTimestamp) {
return "ONGOING";
}
// 60 minutes = 3600 seconds
if (eventStartTimestamp - nowTimestamp <= 3600) {
if (eventStartTimestamp - nowTimestamp <= reminderBufferSeconds) {
return "READY";
}
return "SCHEDULED";
}
// ============ Helper to create a maintenance event with notification ============
export const CreateMaintenanceEventWithNotification = async (
maintenance_id: number,
start_date_time: number,
end_date_time: number,
title: string,
description: string | null,
): Promise<MaintenanceEventRecord> => {
const siteData = await GetAllSiteData();
const notificationSettings =
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
const reminderBufferSeconds = notificationSettings.reminder_buffer_hours * 3600;
const event = await db.createMaintenanceEvent({
maintenance_id,
start_date_time,
end_date_time,
status: determineEventStatus(start_date_time, end_date_time, reminderBufferSeconds),
});
try {
if (notificationSettings.event_types.created) {
const siteVars = siteDataToVariables(siteData);
const siteUrl = siteVars.site_url;
const monitors = await db.getMonitorsByMaintenanceId(maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const eventDetailed: MaintenanceEventRecordDetailed = {
id: event.id,
maintenance_id,
start_date_time,
end_date_time,
status: event.status as MaintenanceEventRecordDetailed["status"],
created_at: new Date(),
updated_at: new Date(),
title,
description,
};
const update = maintenanceToVariables(
eventDetailed,
monitorNames,
"**has been created**",
"created",
"Maintenance Created",
siteUrl,
);
await subscriberQueue.push(update);
}
} catch (err) {
console.error(`Error sending created notification for maintenance event ${event.id}:`, err);
}
return event;
};
// ============ Helper to generate upcoming events from RRULE ============
/**
* Generate maintenance events for the next N days based on the RRULE
* Generate maintenance events based on the RRULE
* @param maintenance_id - The maintenance record ID
* @param start_date_time - Unix timestamp for the DTSTART
* @param rrule - The RRULE string (e.g., FREQ=WEEKLY;BYDAY=SU)
* @param duration_seconds - Duration of each maintenance window
* @param daysAhead - Number of days to look ahead (default 7)
* @param count - Maximum number of events to create (default 1)
*/
export const GenerateMaintenanceEvents = async (
maintenance_id: number,
start_date_time: number,
rrule: string,
duration_seconds: number,
daysAhead: number = 7,
count: number = 1,
): Promise<MaintenanceEventRecord[]> => {
const createdEvents: MaintenanceEventRecord[] = [];
// Convert start timestamp to Date (UTC)
const dtstart = new Date(start_date_time * 1000);
// Define the window to generate events
const now = new Date();
const windowEnd = addDays(now, daysAhead);
try {
// Build the full RRULE string with DTSTART
@@ -127,22 +181,30 @@ export const GenerateMaintenanceEvents = async (
// Parse the RRULE
const rule = rrulestr(fullRrule);
// Get occurrences between now and window end
// For one-time (COUNT=1), we use dtstart as the reference
let occurrences: Date[];
// Get occurrences based on count
let occurrences: Date[] = [];
if (rrule.includes("COUNT=1")) {
// One-time maintenance: only create event if start_date_time is in the future or within window
if (dtstart >= now || (dtstart <= windowEnd && dtstart >= addDays(now, -1))) {
// One-time maintenance: only create event if start_date_time is recent or in the future
if (dtstart >= now || dtstart >= addDays(now, -1)) {
occurrences = [dtstart];
} else {
occurrences = [];
}
} else {
// Recurring: get all occurrences in the window
occurrences = rule.between(now, windowEnd, true);
// Recurring: get the next `count` occurrences from now
let searchFrom = now;
for (let i = 0; i < count; i++) {
const next = rule.after(searchFrom, i === 0);
if (!next) break;
occurrences.push(next);
searchFrom = new Date(next.getTime() + 1000);
}
}
// Fetch maintenance info for notifications
const maintenance = await db.getMaintenanceById(maintenance_id);
const maintenanceTitle = maintenance?.title || "";
const maintenanceDescription = maintenance?.description || null;
// Create events for each occurrence
for (const occurrence of occurrences) {
const eventStart = Math.floor(occurrence.getTime() / 1000);
@@ -153,12 +215,13 @@ export const GenerateMaintenanceEvents = async (
const alreadyExists = existing.some((e) => e.start_date_time === eventStart);
if (!alreadyExists) {
const event = await db.createMaintenanceEvent({
const event = await CreateMaintenanceEventWithNotification(
maintenance_id,
start_date_time: eventStart,
end_date_time: eventEnd,
status: determineEventStatus(eventStart, eventEnd),
});
eventStart,
eventEnd,
maintenanceTitle,
maintenanceDescription,
);
createdEvents.push(event);
}
}
@@ -202,8 +265,8 @@ export const CreateMaintenance = async (data: CreateMaintenanceInput): Promise<{
await db.addMonitorsToMaintenanceWithStatus(maintenance.id, data.monitors);
}
// Generate initial events for the next 7 days
await GenerateMaintenanceEvents(maintenance.id, data.start_date_time, data.rrule, data.duration_seconds, 7);
// Generate initial events
await GenerateMaintenanceEvents(maintenance.id, data.start_date_time, data.rrule, data.duration_seconds, 1);
return {
maintenance_id: maintenance.id,
@@ -336,7 +399,7 @@ export const UpdateMaintenance = async (id: number, data: UpdateMaintenanceInput
}
}
// Regenerate the event
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 7);
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 1);
} else {
// For recurring maintenances: delete future SCHEDULED events and regenerate
for (const event of events) {
@@ -345,8 +408,8 @@ export const UpdateMaintenance = async (id: number, data: UpdateMaintenanceInput
await db.deleteMaintenanceEvent(event.id);
}
}
// Regenerate events for the next 7 days
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 7);
// Regenerate events
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 1);
}
}
}
@@ -377,11 +440,16 @@ export const CreateMaintenanceEvent = async (data: CreateMaintenanceEventInput):
throw new Error("End date/time must be after start date/time");
}
const siteData = await GetAllSiteData();
const notificationSettings =
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
const reminderBufferSeconds = notificationSettings.reminder_buffer_hours * 3600;
const event = await db.createMaintenanceEvent({
maintenance_id: data.maintenance_id,
start_date_time: data.start_date_time,
end_date_time: data.end_date_time,
status: determineEventStatus(data.start_date_time, data.end_date_time),
status: determineEventStatus(data.start_date_time, data.end_date_time, reminderBufferSeconds),
});
return event;
@@ -534,20 +602,23 @@ export const formatDurationSeconds = (seconds: number): string => {
/**
* Update maintenance event statuses based on current time:
* 1. SCHEDULED events starting within 60 minutes → READY
* 1. SCHEDULED events starting within the reminder buffer → READY
* 2. READY events where current time is within start/end → ONGOING
* 3. ONGOING events where end_date_time has passed → COMPLETED
*/
export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
const currentTimestamp = GetMinuteStartNowTimestampUTC();
const sixtyMinutesInSeconds = 60 * 60;
const siteData = await GetAllSiteData();
const siteVars = siteDataToVariables(siteData);
const siteUrl = siteVars.site_url;
//get global maintenance notification settings
const notificationSettings =
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
const reminderBufferSeconds = notificationSettings.reminder_buffer_hours * 3600;
try {
// 1. Mark SCHEDULED events starting within 60 minutes as READY
const scheduledEvents = await db.getScheduledEventsStartingSoon(currentTimestamp, sixtyMinutesInSeconds);
// 1. Mark SCHEDULED events starting within the reminder buffer as READY
const scheduledEvents = await db.getScheduledEventsStartingSoon(currentTimestamp, reminderBufferSeconds);
for (const event of scheduledEvents) {
await db.updateMaintenanceEventStatus(event.id, GC.READY);
console.log(`Maintenance event ${event.id} marked as READY (starts at ${event.start_date_time})`);
@@ -557,15 +628,17 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
new Date(event.start_date_time * 1000),
new Date(currentTimestamp * 1000),
);
const update = maintenanceToVariables(
event,
monitorNames,
`**is starting in ${timeUntilStart}**`,
"starting_soon",
"Maintenance Starting Soon",
siteUrl,
);
await subscriberQueue.push(update);
if (notificationSettings.event_types.reminder) {
const update = maintenanceToVariables(
event,
monitorNames,
`**is starting in ${timeUntilStart}**`,
"starting_soon",
"Maintenance Starting Soon",
siteUrl,
);
await subscriberQueue.push(update);
}
}
// 2. Catch-up: SCHEDULED events that missed the READY window and already started → ONGOING
@@ -575,15 +648,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
console.log(`Maintenance event ${event.id} marked as ONGOING (catch-up from SCHEDULED)`);
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const update = maintenanceToVariables(
event,
monitorNames,
"**is now in progress**",
"ongoing",
"Maintenance In Progress",
siteUrl,
);
await subscriberQueue.push(update);
if (notificationSettings.event_types.started) {
const update = maintenanceToVariables(
event,
monitorNames,
"**is now in progress**",
"ongoing",
"Maintenance In Progress",
siteUrl,
);
await subscriberQueue.push(update);
}
}
// 3. Mark READY events that are now in progress as ONGOING
@@ -593,15 +669,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
console.log(`Maintenance event ${event.id} marked as ONGOING`);
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const update = maintenanceToVariables(
event,
monitorNames,
"**is now in progress**",
"ongoing",
"Maintenance In Progress",
siteUrl,
);
await subscriberQueue.push(update);
if (notificationSettings.event_types.started) {
const update = maintenanceToVariables(
event,
monitorNames,
"**is now in progress**",
"ongoing",
"Maintenance In Progress",
siteUrl,
);
await subscriberQueue.push(update);
}
}
// 4. Mark ONGOING events that have ended as COMPLETED
@@ -611,15 +690,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
console.log(`Maintenance event ${event.id} marked as COMPLETED`);
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const update = maintenanceToVariables(
event,
monitorNames,
"**has been completed**",
"completed",
"Maintenance Completed",
siteUrl,
);
await subscriberQueue.push(update);
if (notificationSettings.event_types.ended) {
const update = maintenanceToVariables(
event,
monitorNames,
"**has been completed**",
"completed",
"Maintenance Completed",
siteUrl,
);
await subscriberQueue.push(update);
}
}
} catch (error) {
console.error("Error updating maintenance event statuses:", error);
@@ -127,19 +127,20 @@ export async function CreateMonitorAlertConfig(
// Validate input
validateMonitorAlertConfigInput(data);
if (!data.monitor_tag) {
throw new Error("monitor_tag is required");
if (!data.monitor_tags || data.monitor_tags.length === 0) {
throw new Error("At least one monitor is required");
}
// Check if monitor exists
const monitor = await db.getMonitorByTag(data.monitor_tag);
if (!monitor) {
throw new Error(`Monitor with tag '${data.monitor_tag}' not found`);
// Check if all monitors exist
for (const tag of data.monitor_tags) {
const monitor = await db.getMonitorByTag(tag);
if (!monitor) {
throw new Error(`Monitor with tag '${tag}' not found`);
}
}
// Prepare insert data
const insertData: MonitorAlertConfigInsert = {
monitor_tag: data.monitor_tag,
alert_for: data.alert_for,
alert_value: data.alert_value,
failure_threshold: data.failure_threshold,
@@ -153,6 +154,9 @@ export async function CreateMonitorAlertConfig(
// Insert alert config
const id = await db.insertMonitorAlertConfig(insertData);
// Add monitors to junction table
await db.addMonitorsToAlertConfig(id, data.monitor_tags);
// Add triggers if provided
if (data.trigger_ids && data.trigger_ids.length > 0) {
await db.addTriggersToMonitorAlertConfig(id, data.trigger_ids);
@@ -194,6 +198,19 @@ export async function UpdateMonitorAlertConfig(
validateAlertValue(alertFor, data.alert_value);
}
// Validate monitor_tags if provided
if (data.monitor_tags !== undefined) {
if (data.monitor_tags.length === 0) {
throw new Error("At least one monitor is required");
}
for (const tag of data.monitor_tags) {
const monitor = await db.getMonitorByTag(tag);
if (!monitor) {
throw new Error(`Monitor with tag '${tag}' not found`);
}
}
}
// Prepare update data
const updateData: MonitorAlertConfigUpdate = {};
if (data.alert_for !== undefined) updateData.alert_for = data.alert_for;
@@ -210,6 +227,11 @@ export async function UpdateMonitorAlertConfig(
await db.updateMonitorAlertConfig(data.id, updateData);
}
// Update monitors if provided
if (data.monitor_tags !== undefined) {
await db.replaceAlertConfigMonitors(data.id, data.monitor_tags);
}
// Update triggers if provided
if (data.trigger_ids !== undefined) {
await db.replaceMonitorAlertConfigTriggers(data.id, data.trigger_ids);
@@ -420,6 +442,7 @@ function validateAlertStatus(value: string): asserts value is MonitorAlertStatus
*/
export async function CreateMonitorAlertV2(
configId: number,
monitorTag?: string | null,
incidentId?: number | null,
): Promise<MonitorAlertV2Record> {
// Check if config exists
@@ -438,6 +461,7 @@ export async function CreateMonitorAlertV2(
const insertData: MonitorAlertV2Insert = {
config_id: configId,
monitor_tag: monitorTag || null,
incident_id: incidentId || null,
alert_status: "TRIGGERED",
};
@@ -20,6 +20,7 @@ import type {
SiteDateTimeFormat,
SiteSubscriptionsSettings,
SitemapXMLConfig,
GlobalMaintenanceNotificationSettings,
} from "../../types/site.js";
export interface SiteDataTransformed {
@@ -66,6 +67,7 @@ export interface SiteDataTransformed {
metaSiteTitle?: string;
metaSiteDescription?: string;
sitemap?: SitemapXMLConfig;
globalMaintenanceNotificationSettings?: GlobalMaintenanceNotificationSettings;
}
export function InsertKeyValue(key: string, value: string): Promise<number[]> {
@@ -106,6 +108,22 @@ export const GetLocaleFromCookie = (site: SiteDataTransformed, cookies: Cookies)
return selectedLang;
};
/**
* Returns the site URL used for building absolute public URLs, without a trailing slash.
* Prefers the configured siteURL and falls back to the ORIGIN env var; only absolute
* http(s) values are returned. Returns an empty string when neither is usable, in which
* case callers degrade to a relative path.
*/
export const GetSiteURL = async (): Promise<string> => {
const siteURL = await GetSiteDataByKey("siteURL");
for (const candidate of [siteURL, process.env.ORIGIN]) {
if (typeof candidate === "string" && /^https?:\/\//i.test(candidate)) {
return candidate.replace(/\/+$/, "");
}
}
return "";
};
export const GetSiteLogoURL = async (siteURL: string, logo: string, base: string): Promise<string> => {
if (logo.startsWith("http")) {
return logo;
@@ -136,14 +154,17 @@ export const GetSiteDataByKey = async (key: string): Promise<unknown> => {
return data.value;
};
/** Checks the env vars required for setup, without touching the database. */
export const HasRequiredEnv = (): boolean => {
return (
process.env.KENER_SECRET_KEY !== undefined &&
process.env.ORIGIN !== undefined &&
process.env.REDIS_URL !== undefined
);
};
export const IsSetupComplete = async (): Promise<boolean> => {
if (process.env.KENER_SECRET_KEY === undefined) {
return false;
}
if (process.env.ORIGIN === undefined) {
return false;
}
if (process.env.REDIS_URL === undefined) {
if (!HasRequiredEnv()) {
return false;
}
let data = await db.getAllSiteData();
+7 -2
View File
@@ -43,12 +43,12 @@ export const siteDataKeys: SiteDataKey[] = [
},
{
key: "favicon",
isValid: (value) => typeof value === "string" && value.trim().length > 0,
isValid: (value) => typeof value === "string",
data_type: "string",
},
{
key: "logo",
isValid: (value) => typeof value === "string" && value.trim().length > 0,
isValid: (value) => typeof value === "string",
data_type: "string",
},
{
@@ -291,4 +291,9 @@ export const siteDataKeys: SiteDataKey[] = [
isValid: IsValidJSONString,
data_type: "object",
},
{
key: "globalMaintenanceNotificationSettings",
isValid: IsValidJSONString,
data_type: "object",
},
];
+282 -61
View File
@@ -2,7 +2,7 @@ import db from "../db/db.js";
import type { PaginationInput } from "$lib/types/common";
import { GenerateToken, HashPassword, ValidatePassword, VerifyToken } from "./commonController.js";
import type { Cookies } from "@sveltejs/kit";
import type { UserRecordPublic, UserRecordDashboard } from "../types/db.js";
import type { UserRecordPublic, UserRecordDashboard, RoleRecord } from "../types/db.js";
import { GetAllSiteData } from "./controller.js";
import { siteDataToVariables } from "../notification/notification_utils.js";
import sendEmail from "../notification/email_notification.js";
@@ -16,7 +16,7 @@ export interface UserUpdateInput {
interface ManualUserUpdateInput {
updateType: string;
role?: string;
role_ids?: string[];
is_active?: number;
password?: string;
passwordPlain?: string;
@@ -32,7 +32,7 @@ interface NewUserInput {
name: string;
password: string;
plainPassword: string;
role: string;
role_ids: string[];
}
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -65,12 +65,18 @@ const validateNameOrThrow = (name: string): string => {
return normalizedName;
};
export const GetAllUsersPaginated = async (data: PaginationInput): Promise<UserRecordPublic[]> => {
return await db.getUsersPaginated(data.page, data.limit);
export const GetAllUsersPaginated = async (
data: PaginationInput,
filter?: { is_active?: number },
): Promise<UserRecordPublic[]> => {
return await db.getUsersPaginated(data.page, data.limit, filter);
};
export const GetAllUsersPaginatedDashboard = async (data: PaginationInput): Promise<UserRecordDashboard[]> => {
const users = await db.getUsersPaginated(data.page, data.limit);
export const GetAllUsersPaginatedDashboard = async (
data: PaginationInput,
filter?: { is_active?: number },
): Promise<UserRecordDashboard[]> => {
const users = await db.getUsersPaginated(data.page, data.limit, filter);
if (users.length === 0) return [];
// Batch fetch password statuses for all users
@@ -88,8 +94,8 @@ export const GetAllUsers = async () => {
return await db.getAllUsers();
};
export const GetUsersCount = async () => {
return await db.getUsersCount();
export const GetUsersCount = async (filter?: { is_active?: number }) => {
return await db.getTotalUsers(filter);
};
export const GetUserPasswordHashById = async (id: number) => {
@@ -145,14 +151,20 @@ export const UpdateUserData = async (data: UserUpdateInput): Promise<number> =>
}
};
export const CreateNewUser = async (currentUser: { role: string }, data: NewUserInput): Promise<number[]> => {
let acceptedRoles = ["member", "editor"];
if (!acceptedRoles.includes(data.role)) {
throw new Error("Invalid role");
export const CreateNewUser = async (data: NewUserInput): Promise<number[]> => {
if (!data.role_ids || data.role_ids.length === 0) {
throw new Error("At least one role is required");
}
if (currentUser.role === "member") {
throw new Error("Only admins and editors can create new users");
// Validate all role_ids exist and are active
for (const roleId of data.role_ids) {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" does not exist`);
}
if (role.status !== "ACTIVE") {
throw new Error(`Role "${roleId}" is not active`);
}
}
const normalizedEmail = validateEmailOrThrow(data.email);
@@ -163,11 +175,6 @@ export const CreateNewUser = async (currentUser: { role: string }, data: NewUser
throw new Error("Password cannot be empty");
}
//if data.role empty, throw error
if (!!!data.role) {
throw new Error("Role cannot be empty");
}
//if data.password not equal to data.plainPassword, throw error
if (data.password !== data.plainPassword) {
throw new Error("Passwords do not match");
@@ -182,7 +189,7 @@ export const CreateNewUser = async (currentUser: { role: string }, data: NewUser
email: normalizedEmail,
password_hash: await HashPassword(data.password),
name: normalizedName,
role: data.role,
role_ids: data.role_ids,
};
return await db.insertUser(user);
};
@@ -202,7 +209,7 @@ export const CreateFirstUser = async (data: { email: string; name: string; passw
email: normalizedEmail,
password_hash: await HashPassword(data.password),
name: normalizedName,
role: "admin",
role_ids: ["admin"],
is_owner: "YES",
};
return await db.insertUser(user);
@@ -229,33 +236,34 @@ export const UpdatePassword = async (data: PasswordUpdateInput): Promise<number>
});
};
const VALID_ROLES = ["admin", "editor", "member"] as const;
export const ManualUpdateUserData = async (
byUser: { id: number; role: string; is_owner: string },
forUserId: number,
data: ManualUserUpdateInput,
): Promise<number | undefined> => {
export const ManualUpdateUserData = async (forUserId: number, data: ManualUserUpdateInput): Promise<number | void> => {
let forUser = await db.getUserById(forUserId);
if (!forUser) {
throw new Error("User not found");
}
//only admins can update
if (byUser.role !== "admin") {
throw new Error("You do not have permission to update user");
}
// non-owner admins cannot modify other admins (self-updates are allowed)
if (forUser.role === "admin" && byUser.is_owner !== "YES" && forUser.id !== byUser.id) {
throw new Error("Only the owner can modify other admins");
}
if (data.updateType == "role") {
if (!data.role) throw new Error("Role is required");
if (!VALID_ROLES.includes(data.role as (typeof VALID_ROLES)[number])) {
throw new Error(`Invalid role. Must be one of: ${VALID_ROLES.join(", ")}`);
if (!data.role_ids || data.role_ids.length === 0) throw new Error("At least one role is required");
// Owner must always retain the admin role
if (forUser.is_owner === "YES" && !data.role_ids.includes("admin")) {
throw new Error("Owner must retain the admin role");
}
return await db.updateUserRole(forUser.id, data.role);
// Validate all role_ids exist and are active
for (const roleId of data.role_ids) {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" does not exist`);
}
if (role.status !== "ACTIVE") {
throw new Error(`Role "${roleId}" is not active`);
}
}
return await db.updateUserRoles(forUser.id, data.role_ids);
} else if (data.updateType == "is_active") {
if (data.is_active === undefined) throw new Error("is_active is required");
// Owner cannot be deactivated
if (forUser.is_owner === "YES" && data.is_active === 0) {
throw new Error("Owner account cannot be deactivated");
}
return await db.updateUserIsActive(forUser.id, data.is_active);
} else if (data.updateType == "password") {
if (!data.password || !data.passwordPlain) throw new Error("Password is required");
@@ -297,15 +305,20 @@ export const GetTotalUserPages = async (limit: number): Promise<number> => {
};
//send invitation email to user for account creation
export const SendInvitationEmail = async (email: string, role: string, name: string, currentUserRole: string) => {
if (currentUserRole === "member") {
throw new Error("Only admins and editors can create new users");
export const SendInvitationEmail = async (email: string, role_ids: string[], name: string) => {
if (!role_ids || role_ids.length === 0) {
throw new Error("At least one role is required");
}
// Admins can add admin, editor, member; Editors can only add editor, member
const acceptedRoles = currentUserRole === "admin" ? ["admin", "editor", "member"] : ["editor", "member"];
if (!acceptedRoles.includes(role)) {
throw new Error("Invalid role");
// Validate all role_ids exist and are active
for (const roleId of role_ids) {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" does not exist`);
}
if (role.status !== "ACTIVE") {
throw new Error(`Role "${roleId}" is not active`);
}
}
const normalizedEmail = validateEmailOrThrow(email);
@@ -323,7 +336,7 @@ export const SendInvitationEmail = async (email: string, role: string, name: str
email: normalizedEmail,
password_hash: "",
name: normalizedName,
role,
role_ids: role_ids,
is_active: 0,
});
} catch (error: unknown) {
@@ -364,11 +377,7 @@ export const SendInvitationEmail = async (email: string, role: string, name: str
};
//resend invitation email to existing user with blank password
export const ResendInvitationEmail = async (email: string, currentUserRole: string) => {
if (currentUserRole === "member") {
throw new Error("Only admins and editors can resend invitations");
}
export const ResendInvitationEmail = async (email: string) => {
const normalizedEmail = validateEmailOrThrow(email);
const user = await db.getUserByEmail(normalizedEmail);
@@ -410,17 +419,11 @@ export const ResendInvitationEmail = async (email: string, currentUserRole: stri
};
// send verification email with verification link
export const SendVerificationEmail = async (toUserId: number, currentUser: { id: number; role: string }) => {
export const SendVerificationEmail = async (toUserId: number, currentUserId: number) => {
if (!toUserId) {
throw new Error("User ID is required");
}
// Only admins/editors can send verification to other users.
// Members can only send verification email to themselves.
if (currentUser.role === "member" && currentUser.id !== toUserId) {
throw new Error("You do not have permission to send verification email for this user");
}
const user = await db.getUserById(toUserId);
if (!user) {
throw new Error("User not found");
@@ -458,3 +461,221 @@ export const SendVerificationEmail = async (toUserId: number, currentUser: { id:
template.template_text_body || "",
);
};
const RESTRICTED_ROLE_IDS = ["admin", "editor", "member"];
const ROLE_ID_REGEX = /^[a-z0-9_-]+$/;
const normalizeRoleId = (id: string): string => {
return id.trim().toLowerCase().replace(/\s+/g, "_");
};
export const CreateRole = async (data: { role_id: string; name: string }): Promise<RoleRecord> => {
const roleId = normalizeRoleId(data.role_id || "");
const roleName = data.name?.trim();
if (!roleId) {
throw new Error("Role ID is required");
}
if (!ROLE_ID_REGEX.test(roleId)) {
throw new Error("Role ID can only contain lowercase letters, numbers, underscores, and hyphens");
}
if (!roleName) {
throw new Error("Role name is required");
}
if (RESTRICTED_ROLE_IDS.includes(roleId)) {
throw new Error(`Role ID "${roleId}" is restricted and cannot be used`);
}
const existing = await db.getRoleById(roleId);
if (existing) {
throw new Error(`Role with ID "${roleId}" already exists`);
}
await db.insertRole({ id: roleId, role_name: roleName });
const created = await db.getRoleById(roleId);
if (!created) {
throw new Error("Failed to create role");
}
return created;
};
export const UpdateRole = async (roleId: string, data: { name?: string; status?: string }): Promise<RoleRecord> => {
if (!roleId) {
throw new Error("Role ID is required");
}
const existing = await db.getRoleById(roleId);
if (!existing) {
throw new Error(`Role "${roleId}" not found`);
}
if (existing.readonly === 1) {
throw new Error("Readonly roles cannot be updated");
}
const updates: { role_name?: string; status?: string } = {};
if (data.name !== undefined) {
const trimmed = data.name.trim();
if (!trimmed) {
throw new Error("Role name cannot be empty");
}
updates.role_name = trimmed;
}
if (data.status !== undefined) {
if (data.status !== "ACTIVE" && data.status !== "INACTIVE") {
throw new Error("Status must be ACTIVE or INACTIVE");
}
updates.status = data.status;
}
if (Object.keys(updates).length === 0) {
throw new Error("No valid fields to update");
}
await db.updateRole(roleId, updates);
const updated = await db.getRoleById(roleId);
if (!updated) {
throw new Error("Failed to retrieve updated role");
}
return updated;
};
export const DeleteRole = async (
roleId: string,
options: { action: "migrate"; targetRoleId: string } | { action: "remove" },
): Promise<{ success: true }> => {
if (!roleId) {
throw new Error("Role ID is required");
}
const existing = await db.getRoleById(roleId);
if (!existing) {
throw new Error(`Role "${roleId}" not found`);
}
if (existing.readonly === 1) {
throw new Error("Readonly roles cannot be deleted");
}
if (options.action === "migrate") {
const targetRoleId = options.targetRoleId?.trim();
if (!targetRoleId) {
throw new Error("Target role ID is required for migration");
}
if (targetRoleId === roleId) {
throw new Error("Target role cannot be the same as the role being deleted");
}
const targetRole = await db.getRoleById(targetRoleId);
if (!targetRole) {
throw new Error(`Target role "${targetRoleId}" not found`);
}
if (targetRole.status !== "ACTIVE") {
throw new Error("Cannot migrate users to an inactive role");
}
await db.migrateUsersRole(roleId, targetRoleId);
}
// CASCADE on FK will clean up users_roles and roles_permissions
await db.deleteRole(roleId);
return { success: true };
};
export const GetAllRoles = async (): Promise<RoleRecord[]> => {
return await db.getAllRoles();
};
export const GetAllPermissions = async () => {
return await db.getAllPermissions();
};
export const GetRolePermissions = async (roleId: string) => {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" not found`);
}
return await db.getRolePermissions(roleId);
};
export const UpdateRolePermissions = async (roleId: string, permissionIds: string[]) => {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" not found`);
}
if (role.readonly === 1) {
throw new Error("Readonly roles cannot have their permissions modified");
}
// Get current permissions
const current = await db.getRolePermissions(roleId);
const currentIds = new Set(current.map((p) => p.permissions_id));
const desiredIds = new Set(permissionIds);
// Add new permissions
for (const pid of permissionIds) {
if (!currentIds.has(pid)) {
await db.addRolePermission(roleId, pid);
}
}
// Remove old permissions
for (const pid of currentIds) {
if (!desiredIds.has(pid)) {
await db.removeRolePermission(roleId, pid);
}
}
return await db.getRolePermissions(roleId);
};
export const GetRoleUsers = async (roleId: string) => {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" not found`);
}
return await db.getUsersByRoleId(roleId);
};
export const AddUserToRole = async (roleId: string, userId: number) => {
const role = await db.getRoleById(roleId);
if (!role) {
throw new Error(`Role "${roleId}" not found`);
}
if (role.status !== "ACTIVE") {
throw new Error(`Role "${roleId}" is not active`);
}
// Check if user already in role
const users = await db.getUsersByRoleId(roleId);
if (users.some((u) => u.id === userId)) {
throw new Error("User is already assigned to this role");
}
await db.addUserToRole(roleId, userId);
return { success: true };
};
export const RemoveUserFromRole = async (roleId: string, userId: number) => {
if (roleId === "admin") {
const user = await db.getUserById(userId);
if (user && user.is_owner === "YES") {
throw new Error("The owner cannot be removed from the admin role");
}
}
await db.removeUserFromRole(roleId, userId);
return { success: true };
};
export const GetUserPermissions = async (userId: number): Promise<Set<string>> => {
const permissionIds = await db.getUserPermissionIds(userId);
return new Set(permissionIds);
};
export const RequirePermission = (userPermissions: Set<string>, permissionId: string): void => {
if (!userPermissions.has(permissionId)) {
throw new Error("You do not have permission to perform this action");
}
};
+62 -2
View File
@@ -115,11 +115,29 @@ class DbImpl {
getUsersPaginated!: UsersRepository["getUsersPaginated"];
getTotalUsers!: UsersRepository["getTotalUsers"];
updateUserName!: UsersRepository["updateUserName"];
updateUserRole!: UsersRepository["updateUserRole"];
updateUserRoles!: UsersRepository["updateUserRoles"];
updateUserIsActive!: UsersRepository["updateUserIsActive"];
updateUserPasswordById!: UsersRepository["updateUserPasswordById"];
updateIsVerified!: UsersRepository["updateIsVerified"];
// ============ Roles ============
getRoleById!: UsersRepository["getRoleById"];
getAllRoles!: UsersRepository["getAllRoles"];
insertRole!: UsersRepository["insertRole"];
updateRole!: UsersRepository["updateRole"];
deleteRole!: UsersRepository["deleteRole"];
getUsersCountByRoleId!: UsersRepository["getUsersCountByRoleId"];
migrateUsersRole!: UsersRepository["migrateUsersRole"];
getRolePermissions!: UsersRepository["getRolePermissions"];
getAllPermissions!: UsersRepository["getAllPermissions"];
addRolePermission!: UsersRepository["addRolePermission"];
removeRolePermission!: UsersRepository["removeRolePermission"];
getUsersByRoleId!: UsersRepository["getUsersByRoleId"];
addUserToRole!: UsersRepository["addUserToRole"];
removeUserFromRole!: UsersRepository["removeUserFromRole"];
getUserPermissionIds!: UsersRepository["getUserPermissionIds"];
getUserRoleIds!: UsersRepository["getUserRoleIds"];
// ============ API Keys ============
createNewApiKey!: UsersRepository["createNewApiKey"];
updateApiKeyStatus!: UsersRepository["updateApiKeyStatus"];
@@ -292,6 +310,12 @@ class DbImpl {
isTriggerUsedInMonitorAlertConfig!: MonitorAlertConfigRepository["isTriggerUsedInMonitorAlertConfig"];
getMonitorAlertConfigsByTriggerId!: MonitorAlertConfigRepository["getMonitorAlertConfigsByTriggerId"];
// ============ Monitor Alert Config Monitors ============
addMonitorsToAlertConfig!: MonitorAlertConfigRepository["addMonitorsToAlertConfig"];
removeAllMonitorsFromAlertConfig!: MonitorAlertConfigRepository["removeAllMonitorsFromAlertConfig"];
replaceAlertConfigMonitors!: MonitorAlertConfigRepository["replaceAlertConfigMonitors"];
getAlertConfigMonitorTags!: MonitorAlertConfigRepository["getAlertConfigMonitorTags"];
// ============ Monitor Alerts V2 ============
insertMonitorAlertV2!: MonitorAlertConfigRepository["insertMonitorAlertV2"];
updateMonitorAlertV2!: MonitorAlertConfigRepository["updateMonitorAlertV2"];
@@ -454,7 +478,7 @@ class DbImpl {
this.getUsersPaginated = this.users.getUsersPaginated.bind(this.users);
this.getTotalUsers = this.users.getTotalUsers.bind(this.users);
this.updateUserName = this.users.updateUserName.bind(this.users);
this.updateUserRole = this.users.updateUserRole.bind(this.users);
this.updateUserRoles = this.users.updateUserRoles.bind(this.users);
this.updateUserIsActive = this.users.updateUserIsActive.bind(this.users);
this.updateUserPasswordById = this.users.updateUserPasswordById.bind(this.users);
this.updateIsVerified = this.users.updateIsVerified.bind(this.users);
@@ -463,6 +487,24 @@ class DbImpl {
this.deleteApiKey = this.users.deleteApiKey.bind(this.users);
this.getApiKeyByHashedKey = this.users.getApiKeyByHashedKey.bind(this.users);
this.getAllApiKeys = this.users.getAllApiKeys.bind(this.users);
// Roles
this.getRoleById = this.users.getRoleById.bind(this.users);
this.getAllRoles = this.users.getAllRoles.bind(this.users);
this.insertRole = this.users.insertRole.bind(this.users);
this.updateRole = this.users.updateRole.bind(this.users);
this.deleteRole = this.users.deleteRole.bind(this.users);
this.getUsersCountByRoleId = this.users.getUsersCountByRoleId.bind(this.users);
this.migrateUsersRole = this.users.migrateUsersRole.bind(this.users);
this.getRolePermissions = this.users.getRolePermissions.bind(this.users);
this.getAllPermissions = this.users.getAllPermissions.bind(this.users);
this.addRolePermission = this.users.addRolePermission.bind(this.users);
this.removeRolePermission = this.users.removeRolePermission.bind(this.users);
this.getUsersByRoleId = this.users.getUsersByRoleId.bind(this.users);
this.addUserToRole = this.users.addUserToRole.bind(this.users);
this.removeUserFromRole = this.users.removeUserFromRole.bind(this.users);
this.getUserPermissionIds = this.users.getUserPermissionIds.bind(this.users);
this.getUserRoleIds = this.users.getUserRoleIds.bind(this.users);
}
private bindSiteDataMethods(): void {
@@ -692,6 +734,14 @@ class DbImpl {
this.monitorAlertConfig,
);
// Monitor Alert Config Monitors
this.addMonitorsToAlertConfig = this.monitorAlertConfig.addMonitorsToAlertConfig.bind(this.monitorAlertConfig);
this.removeAllMonitorsFromAlertConfig = this.monitorAlertConfig.removeAllMonitorsFromAlertConfig.bind(
this.monitorAlertConfig,
);
this.replaceAlertConfigMonitors = this.monitorAlertConfig.replaceAlertConfigMonitors.bind(this.monitorAlertConfig);
this.getAlertConfigMonitorTags = this.monitorAlertConfig.getAlertConfigMonitorTags.bind(this.monitorAlertConfig);
// Monitor Alerts V2
this.insertMonitorAlertV2 = this.monitorAlertConfig.insertMonitorAlertV2.bind(this.monitorAlertConfig);
this.updateMonitorAlertV2 = this.monitorAlertConfig.updateMonitorAlertV2.bind(this.monitorAlertConfig);
@@ -784,6 +834,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();
}
@@ -7,6 +7,8 @@ import type {
MonitorAlertConfigTriggerRecord,
MonitorAlertConfigTriggerInsert,
MonitorAlertConfigWithTriggers,
MonitorAlertConfigMonitorRecord,
MonitorAlertConfigMonitorInsert,
TriggerRecord,
MonitorAlertV2Record,
MonitorAlertV2Insert,
@@ -29,7 +31,7 @@ export class MonitorAlertConfigRepository extends BaseRepository {
async insertMonitorAlertConfig(data: MonitorAlertConfigInsert): Promise<number> {
const dbType = GetDbType();
const insertData = {
monitor_tag: data.monitor_tag,
monitor_tag: data.monitor_tag || null,
alert_for: data.alert_for,
alert_value: data.alert_value,
failure_threshold: data.failure_threshold,
@@ -88,29 +90,35 @@ export class MonitorAlertConfigRepository extends BaseRepository {
* Get monitor alert configs with optional filtering
*/
async getMonitorAlertConfigs(filter: MonitorAlertConfigFilter): Promise<MonitorAlertConfigRecord[]> {
let query = this.knex("monitor_alerts_config").whereRaw("1=1");
let query = this.knex("monitor_alerts_config as mac").select("mac.*").whereRaw("1=1");
if (filter.id !== undefined) {
query = query.andWhere("id", filter.id);
query = query.andWhere("mac.id", filter.id);
}
if (filter.monitor_tag !== undefined) {
query = query.andWhere("monitor_tag", filter.monitor_tag);
query = query
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.andWhere("macm.monitor_tag", filter.monitor_tag);
}
if (filter.alert_for !== undefined) {
query = query.andWhere("alert_for", filter.alert_for);
query = query.andWhere("mac.alert_for", filter.alert_for);
}
if (filter.is_active !== undefined) {
query = query.andWhere("is_active", filter.is_active);
query = query.andWhere("mac.is_active", filter.is_active);
}
return await query.orderBy("id", "desc");
return await query.orderBy("mac.id", "desc");
}
/**
* Get all monitor alert configs for a specific monitor tag
*/
async getMonitorAlertConfigsByMonitorTag(monitorTag: string): Promise<MonitorAlertConfigRecord[]> {
return await this.knex("monitor_alerts_config").where({ monitor_tag: monitorTag }).orderBy("id", "desc");
return await this.knex("monitor_alerts_config as mac")
.select("mac.*")
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.where("macm.monitor_tag", monitorTag)
.orderBy("mac.id", "desc");
}
/**
@@ -124,9 +132,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
* Get all active monitor alert configs for a specific monitor
*/
async getActiveMonitorAlertConfigsByMonitorTag(monitorTag: string): Promise<MonitorAlertConfigRecord[]> {
return await this.knex("monitor_alerts_config")
.where({ monitor_tag: monitorTag, is_active: "YES" })
.orderBy("id", "desc");
return await this.knex("monitor_alerts_config as mac")
.select("mac.*")
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.where("macm.monitor_tag", monitorTag)
.andWhere("mac.is_active", "YES")
.orderBy("mac.id", "desc");
}
/**
@@ -140,26 +151,51 @@ export class MonitorAlertConfigRepository extends BaseRepository {
* Delete all monitor alert configs for a specific monitor tag
*/
async deleteMonitorAlertConfigsByMonitorTag(monitorTag: string): Promise<number> {
return await this.knex("monitor_alerts_config").where({ monitor_tag: monitorTag }).del();
// Find all config IDs that have this monitor tag in the junction table
const configIds = await this.knex("monitor_alerts_config_monitors")
.select("monitor_alerts_id")
.where({ monitor_tag: monitorTag });
if (configIds.length === 0) return 0;
// Remove the monitor from the junction table
await this.knex("monitor_alerts_config_monitors").where({ monitor_tag: monitorTag }).del();
// Delete any configs that now have zero monitors
const ids = configIds.map((r: { monitor_alerts_id: number }) => r.monitor_alerts_id);
let deletedCount = 0;
for (const id of ids) {
const remainingMonitors = await this.knex("monitor_alerts_config_monitors")
.count("* as count")
.where({ monitor_alerts_id: id })
.first<CountResult>();
if (Number(remainingMonitors?.count) === 0) {
await this.knex("monitor_alerts_config").where({ id }).del();
deletedCount++;
}
}
return deletedCount;
}
/**
* Count monitor alert configs with optional filtering
*/
async getMonitorAlertConfigsCount(filter: MonitorAlertConfigFilter): Promise<CountResult | undefined> {
let query = this.knex("monitor_alerts_config").count("* as count");
let query = this.knex("monitor_alerts_config as mac").count("* as count");
if (filter.id !== undefined) {
query = query.andWhere("id", filter.id);
query = query.andWhere("mac.id", filter.id);
}
if (filter.monitor_tag !== undefined) {
query = query.andWhere("monitor_tag", filter.monitor_tag);
query = query
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.andWhere("macm.monitor_tag", filter.monitor_tag);
}
if (filter.alert_for !== undefined) {
query = query.andWhere("alert_for", filter.alert_for);
query = query.andWhere("mac.alert_for", filter.alert_for);
}
if (filter.is_active !== undefined) {
query = query.andWhere("is_active", filter.is_active);
query = query.andWhere("mac.is_active", filter.is_active);
}
return await query.first<CountResult>();
@@ -174,29 +210,33 @@ export class MonitorAlertConfigRepository extends BaseRepository {
filter?: MonitorAlertConfigFilter,
): Promise<{ configs: MonitorAlertConfigRecord[]; total: number }> {
// Build count query
let countQuery = this.knex("monitor_alerts_config").count("* as count");
let countQuery = this.knex("monitor_alerts_config as mac").count("* as count");
if (filter?.monitor_tag) {
countQuery = countQuery.where("monitor_tag", filter.monitor_tag);
countQuery = countQuery
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.where("macm.monitor_tag", filter.monitor_tag);
}
if (filter?.is_active) {
countQuery = countQuery.andWhere("is_active", filter.is_active);
countQuery = countQuery.andWhere("mac.is_active", filter.is_active);
}
if (filter?.alert_for) {
countQuery = countQuery.andWhere("alert_for", filter.alert_for);
countQuery = countQuery.andWhere("mac.alert_for", filter.alert_for);
}
const totalResult = await countQuery.first<CountResult>();
const total = totalResult ? Number(totalResult.count) : 0;
// Build paginated query
let query = this.knex("monitor_alerts_config").orderBy("id", "desc");
let query = this.knex("monitor_alerts_config as mac").select("mac.*").orderBy("mac.id", "desc");
if (filter?.monitor_tag) {
query = query.where("monitor_tag", filter.monitor_tag);
query = query
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.where("macm.monitor_tag", filter.monitor_tag);
}
if (filter?.is_active) {
query = query.andWhere("is_active", filter.is_active);
query = query.andWhere("mac.is_active", filter.is_active);
}
if (filter?.alert_for) {
query = query.andWhere("alert_for", filter.alert_for);
query = query.andWhere("mac.alert_for", filter.alert_for);
}
const configs = await query.limit(limit).offset((page - 1) * limit);
@@ -278,6 +318,51 @@ export class MonitorAlertConfigRepository extends BaseRepository {
}
}
// ============ Monitor Alert Config Monitors CRUD ============
/**
* Add multiple monitors to an alert config
*/
async addMonitorsToAlertConfig(alertConfigId: number, monitorTags: string[]): Promise<void> {
if (monitorTags.length === 0) return;
const inserts = monitorTags.map((monitorTag) => ({
monitor_alerts_id: alertConfigId,
monitor_tag: monitorTag,
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
}));
await this.knex("monitor_alerts_config_monitors").insert(inserts);
}
/**
* Remove all monitors from an alert config
*/
async removeAllMonitorsFromAlertConfig(alertConfigId: number): Promise<number> {
return await this.knex("monitor_alerts_config_monitors").where({ monitor_alerts_id: alertConfigId }).del();
}
/**
* Replace all monitors for an alert config (remove old, add new)
*/
async replaceAlertConfigMonitors(alertConfigId: number, monitorTags: string[]): Promise<void> {
await this.removeAllMonitorsFromAlertConfig(alertConfigId);
if (monitorTags.length > 0) {
await this.addMonitorsToAlertConfig(alertConfigId, monitorTags);
}
}
/**
* Get monitor tags for an alert config
*/
async getAlertConfigMonitorTags(alertConfigId: number): Promise<string[]> {
const records = await this.knex("monitor_alerts_config_monitors")
.select("monitor_tag")
.where({ monitor_alerts_id: alertConfigId });
return records.map((r: { monitor_tag: string }) => r.monitor_tag);
}
// ============ Composite / Join Operations ============
/**
@@ -292,9 +377,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
.select("t.*")
.where("mact.monitor_alerts_id", id);
const monitorTags = await this.getAlertConfigMonitorTags(id);
return {
...config,
triggers: triggerRecords as TriggerRecord[],
monitor_tags: monitorTags,
};
}
@@ -311,9 +399,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
.select("t.*")
.where("mact.monitor_alerts_id", config.id);
const monitorTags = await this.getAlertConfigMonitorTags(config.id);
result.push({
...config,
triggers: triggerRecords as TriggerRecord[],
monitor_tags: monitorTags,
});
}
@@ -333,9 +424,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
.select("t.*")
.where("mact.monitor_alerts_id", config.id);
const monitorTags = await this.getAlertConfigMonitorTags(config.id);
result.push({
...config,
triggers: triggerRecords as TriggerRecord[],
monitor_tags: monitorTags,
});
}
@@ -373,6 +467,7 @@ export class MonitorAlertConfigRepository extends BaseRepository {
const dbType = GetDbType();
const insertData: Record<string, unknown> = {
config_id: data.config_id,
monitor_tag: data.monitor_tag || null,
incident_id: data.incident_id || null,
alert_status: data.alert_status,
created_at: this.knex.fn.now(),
@@ -432,6 +527,9 @@ export class MonitorAlertConfigRepository extends BaseRepository {
if (filter.config_id !== undefined) {
query = query.andWhere("config_id", filter.config_id);
}
if (filter.monitor_tag !== undefined) {
query = query.andWhere("monitor_tag", filter.monitor_tag);
}
if (filter.incident_id !== undefined) {
query = query.andWhere("incident_id", filter.incident_id);
}
+12 -3
View File
@@ -10,6 +10,15 @@ 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];
/**
* Repository for monitoring data operations
*/
@@ -262,7 +271,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 +297,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 +319,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);
})
+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);
}
}
+9
View File
@@ -168,6 +168,15 @@ const seedSiteData = {
mode: "off",
urls: [],
},
globalMaintenanceNotificationSettings: {
event_types: {
created: false,
reminder: true,
started: true,
ended: true,
},
reminder_buffer_hours: 1,
},
};
export default seedSiteData;
@@ -10,9 +10,11 @@ export function alertToVariables(
config: MonitorAlertConfigRecord,
alert: MonitorAlertV2Record,
siteVars: SiteDataForNotification,
monitorTag?: string,
): AlertVariableMap {
const createdAtDate = alert.created_at instanceof Date ? alert.created_at : new Date(alert.created_at);
const alert_name = `Alert ${config.monitor_tag} for ${config.alert_for} ${config.alert_value} ${alert.alert_status} at ${createdAtDate.toISOString()}`;
const effectiveMonitorTag = monitorTag || config.monitor_tag || "unknown";
const alert_name = `Alert ${effectiveMonitorTag} for ${config.alert_for} ${config.alert_value} ${alert.alert_status} at ${createdAtDate.toISOString()}`;
return {
alert_id: alert.id,
@@ -24,7 +26,7 @@ export function alertToVariables(
alert_message: config.alert_description || "",
alert_source: GC.ALERT,
alert_timestamp: createdAtDate.toISOString(),
alert_cta_url: siteVars.site_url + "monitors/" + config.monitor_tag,
alert_cta_url: siteVars.site_url + "monitors/" + effectiveMonitorTag,
alert_cta_text: "Open Alert Details",
alert_incident_id: alert.incident_id ? alert.incident_id : undefined,
alert_incident_url: alert.incident_id ? siteVars.site_url + "incidents/" + alert.incident_id : undefined,
+344
View File
@@ -0,0 +1,344 @@
import type { PageSettings, PageSettingsPatch } from "$lib/types/api";
import GC from "$lib/global-constants";
// Stored page_settings_json keys differ from the API contract for the meta
// fields: the manage UI writes camelCase (metaPageTitle, metaPageDescription,
// socialPagePreviewImage) while the v4 API exposes snake_case. The mapping
// lives here, at the storage boundary.
interface StoredPageSettings {
incidents?: unknown;
include_maintenances?: unknown;
monitor_status_history_days?: { desktop?: number; mobile?: number };
monitor_layout_style?: string;
metaPageTitle?: string;
metaPageDescription?: string;
socialPagePreviewImage?: string;
[key: string]: unknown;
}
const HISTORY_DAYS_MIN = GC.STATUS_HISTORY_DAYS_MIN;
const HISTORY_DAYS_MAX = GC.STATUS_HISTORY_DAYS_MAX;
export function getDefaultPageSettings(): PageSettings {
return {
incidents: {
enabled: true,
ongoing: { show: true },
resolved: { show: true, max_count: 5, days_in_past: 7 },
},
include_maintenances: {
enabled: true,
ongoing: {
show: true,
past: { show: true, max_count: 5, days_in_past: 7 },
upcoming: { show: true, max_count: 5, days_in_future: 30 },
},
},
monitor_status_history_days: {
desktop: GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP,
mobile: GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE,
},
monitor_layout_style: GC.DEFAULT_MONITOR_LAYOUT_STYLE,
};
}
function parseStored(storedJson: string | null | undefined): StoredPageSettings {
if (!storedJson) return {};
try {
const parsed = JSON.parse(storedJson);
return typeof parsed === "object" && parsed !== null ? (parsed as StoredPageSettings) : {};
} catch {
return {};
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
// Recursively merges patch into base: objects merge key-by-key, everything
// else replaces. Keys absent from the patch — including ones this module does
// not know about — are left untouched.
function deepMerge(base: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) continue;
const current = result[key];
result[key] = isPlainObject(current) && isPlainObject(value) ? deepMerge(current, value) : value;
}
return result;
}
export function mergePageSettings(defaults: PageSettings, partial?: PageSettingsPatch): PageSettings {
if (!partial) {
return defaults;
}
const merged: PageSettings = {
incidents: {
enabled: partial.incidents?.enabled ?? defaults.incidents.enabled,
ongoing: {
show: partial.incidents?.ongoing?.show ?? defaults.incidents.ongoing.show,
},
resolved: {
show: partial.incidents?.resolved?.show ?? defaults.incidents.resolved.show,
max_count: partial.incidents?.resolved?.max_count ?? defaults.incidents.resolved.max_count,
days_in_past: partial.incidents?.resolved?.days_in_past ?? defaults.incidents.resolved.days_in_past,
},
},
include_maintenances: {
enabled: partial.include_maintenances?.enabled ?? defaults.include_maintenances.enabled,
ongoing: {
show: partial.include_maintenances?.ongoing?.show ?? defaults.include_maintenances.ongoing.show,
past: {
show: partial.include_maintenances?.ongoing?.past?.show ?? defaults.include_maintenances.ongoing.past.show,
max_count:
partial.include_maintenances?.ongoing?.past?.max_count ??
defaults.include_maintenances.ongoing.past.max_count,
days_in_past:
partial.include_maintenances?.ongoing?.past?.days_in_past ??
defaults.include_maintenances.ongoing.past.days_in_past,
},
upcoming: {
show:
partial.include_maintenances?.ongoing?.upcoming?.show ??
defaults.include_maintenances.ongoing.upcoming.show,
max_count:
partial.include_maintenances?.ongoing?.upcoming?.max_count ??
defaults.include_maintenances.ongoing.upcoming.max_count,
days_in_future:
partial.include_maintenances?.ongoing?.upcoming?.days_in_future ??
defaults.include_maintenances.ongoing.upcoming.days_in_future,
},
},
},
monitor_status_history_days: {
desktop: partial.monitor_status_history_days?.desktop ?? defaults.monitor_status_history_days.desktop,
mobile: partial.monitor_status_history_days?.mobile ?? defaults.monitor_status_history_days.mobile,
},
monitor_layout_style: partial.monitor_layout_style ?? defaults.monitor_layout_style,
};
const metaPageTitle = partial.meta_page_title ?? defaults.meta_page_title;
const metaPageDescription = partial.meta_page_description ?? defaults.meta_page_description;
const socialPagePreviewImage = partial.social_page_preview_image ?? defaults.social_page_preview_image;
if (metaPageTitle !== undefined) merged.meta_page_title = metaPageTitle;
if (metaPageDescription !== undefined) merged.meta_page_description = metaPageDescription;
if (socialPagePreviewImage !== undefined) merged.social_page_preview_image = socialPagePreviewImage;
return merged;
}
function isValidHistoryDays(value: unknown): boolean {
return Number.isInteger(value) && (value as number) >= HISTORY_DAYS_MIN && (value as number) <= HISTORY_DAYS_MAX;
}
const boolOrUndefined = (value: unknown): boolean | undefined => (typeof value === "boolean" ? value : undefined);
const countOrUndefined = (value: unknown): number | undefined =>
Number.isInteger(value) && (value as number) >= 0 ? (value as number) : undefined;
// Read-side sanitizers: keep only correctly-typed leaves from stored event
// branches so wrong-typed values (e.g. enabled: "yes" from manual edits or
// older versions) never override defaults in API responses
function sanitizeStoredIncidents(value: unknown): PageSettingsPatch["incidents"] {
if (!isPlainObject(value)) return undefined;
const ongoing = isPlainObject(value.ongoing) ? value.ongoing : {};
const resolved = isPlainObject(value.resolved) ? value.resolved : {};
return {
enabled: boolOrUndefined(value.enabled),
ongoing: { show: boolOrUndefined(ongoing.show) },
resolved: {
show: boolOrUndefined(resolved.show),
max_count: countOrUndefined(resolved.max_count),
days_in_past: countOrUndefined(resolved.days_in_past),
},
};
}
function sanitizeStoredMaintenances(value: unknown): PageSettingsPatch["include_maintenances"] {
if (!isPlainObject(value)) return undefined;
const ongoing = isPlainObject(value.ongoing) ? value.ongoing : {};
const past = isPlainObject(ongoing.past) ? ongoing.past : {};
const upcoming = isPlainObject(ongoing.upcoming) ? ongoing.upcoming : {};
return {
enabled: boolOrUndefined(value.enabled),
ongoing: {
show: boolOrUndefined(ongoing.show),
past: {
show: boolOrUndefined(past.show),
max_count: countOrUndefined(past.max_count),
days_in_past: countOrUndefined(past.days_in_past),
},
upcoming: {
show: boolOrUndefined(upcoming.show),
max_count: countOrUndefined(upcoming.max_count),
days_in_future: countOrUndefined(upcoming.days_in_future),
},
},
};
}
function isValidLayoutStyle(value: unknown): value is PageSettings["monitor_layout_style"] {
return (GC.MONITOR_LAYOUT_STYLES as readonly string[]).includes(value as string);
}
/**
* Builds the API view of stored settings: defaults overlaid with stored
* values. Stored values that violate the API contract (unknown layout style,
* out-of-range days — e.g. from manual edits or older versions) are ignored
* so responses stay schema-compliant.
*/
export function toApiPageSettings(storedJson: string | null | undefined): PageSettings {
const stored = parseStored(storedJson);
const storedDays = isPlainObject(stored.monitor_status_history_days) ? stored.monitor_status_history_days : {};
const fromStore: PageSettingsPatch = {
incidents: sanitizeStoredIncidents(stored.incidents),
include_maintenances: sanitizeStoredMaintenances(stored.include_maintenances),
monitor_status_history_days: {
desktop: isValidHistoryDays(storedDays.desktop) ? (storedDays.desktop as number) : undefined,
mobile: isValidHistoryDays(storedDays.mobile) ? (storedDays.mobile as number) : undefined,
},
monitor_layout_style: isValidLayoutStyle(stored.monitor_layout_style) ? stored.monitor_layout_style : undefined,
meta_page_title: typeof stored.metaPageTitle === "string" ? stored.metaPageTitle : undefined,
meta_page_description: typeof stored.metaPageDescription === "string" ? stored.metaPageDescription : undefined,
social_page_preview_image:
typeof stored.socialPagePreviewImage === "string" ? stored.socialPagePreviewImage : undefined,
};
return mergePageSettings(getDefaultPageSettings(), fromStore);
}
/**
* Deep-merges a partial API payload into the stored settings JSON and returns
* the new JSON string. Only keys present in the patch are written; everything
* else in the stored JSON — including nested keys and top-level keys this
* module does not know about — is preserved, so an API write can never wipe
* settings written by other parts of the app, and clients may persist extra
* keys (the schema allows additional properties).
*/
export function applyPageSettingsPatch(
storedJson: string | null | undefined,
patch: PageSettingsPatch | undefined,
): string {
const stored = parseStored(storedJson);
if (!patch) {
return JSON.stringify(stored);
}
// Map the API's snake_case meta fields to their stored camelCase keys; all
// other keys are stored under their API names
const { meta_page_title, meta_page_description, social_page_preview_image, ...rest } = patch;
const mappedPatch: Record<string, unknown> = { ...rest };
if (meta_page_title !== undefined) mappedPatch.metaPageTitle = meta_page_title;
if (meta_page_description !== undefined) mappedPatch.metaPageDescription = meta_page_description;
if (social_page_preview_image !== undefined) mappedPatch.socialPagePreviewImage = social_page_preview_image;
return JSON.stringify(deepMerge(stored, mappedPatch));
}
/**
* Validates a partial page_settings payload from the API. Returns an error
* message, or null when valid. Bounds mirror the manage UI (history days
* 1-365, layout style one of the four shipped styles).
*/
export function validatePageSettings(partial: unknown): string | null {
if (partial === undefined) return null;
if (typeof partial !== "object" || partial === null || Array.isArray(partial)) {
return "page_settings must be an object";
}
const settings = partial as PageSettingsPatch;
// The event display branches and their known sub-objects must be objects;
// anything else would be deep-merged into storage as-is
if (settings.incidents !== undefined) {
if (!isPlainObject(settings.incidents)) {
return "incidents must be an object";
}
for (const key of ["ongoing", "resolved"] as const) {
if (settings.incidents[key] !== undefined && !isPlainObject(settings.incidents[key])) {
return `incidents.${key} must be an object`;
}
}
}
if (settings.include_maintenances !== undefined) {
if (!isPlainObject(settings.include_maintenances)) {
return "include_maintenances must be an object";
}
const ongoing = settings.include_maintenances.ongoing;
if (ongoing !== undefined) {
if (!isPlainObject(ongoing)) {
return "include_maintenances.ongoing must be an object";
}
for (const key of ["past", "upcoming"] as const) {
if (ongoing[key] !== undefined && !isPlainObject(ongoing[key])) {
return `include_maintenances.ongoing.${key} must be an object`;
}
}
}
}
// Leaf types inside the event branches must match the schema
const leafChecks: Array<{ path: readonly string[]; kind: "boolean" | "count" }> = [
{ path: ["incidents", "enabled"], kind: "boolean" },
{ path: ["incidents", "ongoing", "show"], kind: "boolean" },
{ path: ["incidents", "resolved", "show"], kind: "boolean" },
{ path: ["incidents", "resolved", "max_count"], kind: "count" },
{ path: ["incidents", "resolved", "days_in_past"], kind: "count" },
{ path: ["include_maintenances", "enabled"], kind: "boolean" },
{ path: ["include_maintenances", "ongoing", "show"], kind: "boolean" },
{ path: ["include_maintenances", "ongoing", "past", "show"], kind: "boolean" },
{ path: ["include_maintenances", "ongoing", "past", "max_count"], kind: "count" },
{ path: ["include_maintenances", "ongoing", "past", "days_in_past"], kind: "count" },
{ path: ["include_maintenances", "ongoing", "upcoming", "show"], kind: "boolean" },
{ path: ["include_maintenances", "ongoing", "upcoming", "max_count"], kind: "count" },
{ path: ["include_maintenances", "ongoing", "upcoming", "days_in_future"], kind: "count" },
];
for (const { path, kind } of leafChecks) {
let value: unknown = settings;
for (const key of path) {
if (!isPlainObject(value)) {
value = undefined;
break;
}
value = value[key];
}
if (value === undefined) continue;
if (kind === "boolean" && typeof value !== "boolean") {
return `${path.join(".")} must be a boolean`;
}
if (kind === "count" && !(Number.isInteger(value) && (value as number) >= 0)) {
return `${path.join(".")} must be a non-negative integer`;
}
}
if (settings.monitor_layout_style !== undefined) {
if (!GC.MONITOR_LAYOUT_STYLES.includes(settings.monitor_layout_style)) {
return `monitor_layout_style must be one of: ${GC.MONITOR_LAYOUT_STYLES.join(", ")}`;
}
}
if (settings.monitor_status_history_days !== undefined) {
const days = settings.monitor_status_history_days;
if (typeof days !== "object" || days === null || Array.isArray(days)) {
return "monitor_status_history_days must be an object";
}
for (const key of ["desktop", "mobile"] as const) {
const value = days[key];
if (value !== undefined) {
if (!Number.isInteger(value) || value < HISTORY_DAYS_MIN || value > HISTORY_DAYS_MAX) {
return `monitor_status_history_days.${key} must be an integer between ${HISTORY_DAYS_MIN} and ${HISTORY_DAYS_MAX}`;
}
}
}
}
for (const key of ["meta_page_title", "meta_page_description", "social_page_preview_image"] as const) {
const value = settings[key];
if (value !== undefined && typeof value !== "string") {
return `${key} must be a string`;
}
}
return null;
}
+9 -8
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";
@@ -141,8 +139,9 @@ async function sendAlertNotifications(
activeAlert: MonitorAlertV2Record,
monitor_alerts_configured: MonitorAlertConfigRecord,
templateSiteVars: SiteDataForNotification,
monitorTag?: string,
): Promise<void> {
const templateAlertVars = alertToVariables(monitor_alerts_configured, activeAlert, templateSiteVars);
const templateAlertVars = alertToVariables(monitor_alerts_configured, activeAlert, templateSiteVars, monitorTag);
const triggers = await GetTriggersByMonitorAlertConfigId(monitor_alerts_configured.id);
for (let i = 0; i < triggers.length; i++) {
@@ -233,9 +232,10 @@ const addWorker = () => {
return;
}
// Get existing alerts
// Get existing alerts for this specific monitor + config combination
let alertsExisting = await GetMonitorAlertsV2({
config_id: monitor_alerts_configured.id,
monitor_tag: monitor_tag,
alert_status: GC.TRIGGERED,
});
let activeAlert = null;
@@ -244,9 +244,9 @@ const addWorker = () => {
}
if (isAffected) {
// Trigger alert if not already active
// Trigger alert if not already active for this monitor
if (!activeAlert) {
activeAlert = await CreateMonitorAlertV2(monitor_alerts_configured.id);
activeAlert = await CreateMonitorAlertV2(monitor_alerts_configured.id, monitor_tag);
if (monitor_alerts_configured.create_incident === GC.YES) {
let newIncidentNumber = await createNewIncident(
activeAlert,
@@ -261,7 +261,7 @@ const addWorker = () => {
}
}
// Send triggered alert notifications
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars);
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars, monitor_tag);
}
} else {
// Resolve any existing alert
@@ -302,7 +302,7 @@ const addWorker = () => {
}
// Send resolution notifications
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars);
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars, monitor_tag);
}
}
} catch (error) {
@@ -348,6 +348,7 @@ export const push = async (monitor_tag: string, ts: number, status: string, opti
monitor_tag: monitor.tag,
is_active: GC.YES,
});
if (monitorAlertsConfigurations.length === 0) {
return;
}
+1 -1
View File
@@ -46,7 +46,7 @@ const addWorker = () => {
fromEmail,
templateTextBody,
);
console.log(`📧 Email sent to ${toEmails}`);
// console.log(`📧 Email sent to ${toEmails}`);
} catch (error) {
console.error(`Failed to send email to ${toEmails}:`, error);
throw error; // Re-throw to trigger retry
@@ -42,6 +42,7 @@ const addWorker = () => {
});
if (!dbRes) {
console.error("Failed to insert monitoring data for monitorTag:", monitorTag, "timestamp:", ts);
throw new Error("Failed to insert monitoring data");
}
@@ -52,7 +53,6 @@ const addWorker = () => {
latency: latency,
type: type,
});
alertingQueue.push(monitorTag, ts, status);
return dbRes;
@@ -4,7 +4,7 @@ import db from "../db/db.js";
import { rrulestr } from "rrule";
import { addDays } from "date-fns";
import type { MaintenanceRecord, MaintenanceEventRecord } from "../types/db.js";
import { determineEventStatus } from "../controllers/maintenanceController.js";
import { determineEventStatus, CreateMaintenanceEventWithNotification } from "../controllers/maintenanceController.js";
let maintenanceSchedulerQueue: Queue | null = null;
let worker: Worker | null = null;
@@ -58,12 +58,13 @@ const generateEventsForMaintenance = async (maintenance: MaintenanceRecord): Pro
// Check if event already exists for this start time
if (!existingStartTimes.has(eventStart)) {
await db.createMaintenanceEvent({
maintenance_id: maintenance.id,
start_date_time: eventStart,
end_date_time: eventEnd,
status: determineEventStatus(eventStart, eventEnd),
});
await CreateMaintenanceEventWithNotification(
maintenance.id,
eventStart,
eventEnd,
maintenance.title,
maintenance.description || null,
);
eventsCreated++;
console.log(
`Created maintenance event for "${maintenance.title}" at ${new Date(eventStart * 1000).toISOString()}`,
+13 -6
View File
@@ -154,25 +154,32 @@ class ApiCall {
}
}
if (!!!evalResp) {
if (!evalResp || typeof evalResp !== "object") {
evalResp = {
status: GC.DOWN,
latency: latency,
type: GC.ERROR,
};
} else if (
!!!evalResp.status ||
([GC.UP, GC.DOWN, GC.DEGRADED, GC.MAINTENANCE] as string[]).indexOf(evalResp.status) === -1
) {
errorMessage += " | Eval must return an object with 'status' and 'latency' fields, got no response";
} else if (evalResp.status === undefined) {
evalResp = {
status: GC.DOWN,
latency: latency,
type: GC.ERROR,
};
errorMessage += ` | Eval must return an object with a 'status' field (one of: ${GC.UP}, ${GC.DOWN}, ${GC.DEGRADED}, ${GC.MAINTENANCE}), but 'status' was missing`;
} else if (([GC.UP, GC.DOWN, GC.DEGRADED, GC.MAINTENANCE] as string[]).indexOf(evalResp.status) === -1) {
evalResp = {
status: GC.DOWN,
latency: latency,
type: GC.ERROR,
};
errorMessage += ` | Eval returned invalid 'status' value "${evalResp.status}". Must be one of: ${GC.UP}, ${GC.DOWN}, ${GC.DEGRADED}, ${GC.MAINTENANCE}`;
} else {
evalResp.type = GC.REALTIME;
// Ensure latency is a valid number
// Ensure latency is a valid number; fall back to measured latency
if (typeof evalResp.latency !== "number" || isNaN(evalResp.latency)) {
errorMessage += ` | Eval 'latency' must be a number, got ${JSON.stringify(evalResp.latency)}. Using measured latency instead`;
evalResp.latency = latency;
}
}
+14 -1
View File
@@ -55,7 +55,7 @@ class TcpCall {
error_message: `Error in tcpEval: ${message}`,
};
}
if (!!!evalResp) {
if (!evalResp) {
const message = "tcpEval did not return a valid response.";
console.log(`Error in tcpEval for ${tag}:`, message);
return {
@@ -65,6 +65,19 @@ class TcpCall {
error_message: `Error in tcpEval: ${message}`,
};
}
//evalResp to be an object with status and latency
if (!("status" in evalResp) || !("latency" in evalResp)) {
const message = "tcpEval did not return status or latency.";
console.log(`Error in tcpEval for ${tag}:`, message);
return {
status: GC.DOWN,
latency: 0,
type: GC.ERROR,
error_message: `Error in tcpEval: ${message}`,
};
}
//reduce to get the status
return {
status: evalResp?.status || GC.DOWN,
+52 -7
View File
@@ -1,5 +1,6 @@
// Server-only database types (based on migrations schema)
import type { Knex } from "knex";
import type { PageMonitorLayoutStyle } from "$lib/types/api";
// ============ monitoring_data table ============
export interface MonitoringData {
@@ -236,7 +237,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 +246,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 +259,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 +267,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 +469,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;
@@ -628,7 +654,7 @@ export type YesNoType = "YES" | "NO";
export interface MonitorAlertConfigRecord {
id: number;
monitor_tag: string;
monitor_tag: string | null;
alert_for: AlertForType;
alert_value: string;
failure_threshold: number;
@@ -642,7 +668,7 @@ export interface MonitorAlertConfigRecord {
}
export interface MonitorAlertConfigInsert {
monitor_tag: string;
monitor_tag?: string | null;
alert_for: AlertForType;
alert_value: string;
failure_threshold: number;
@@ -684,13 +710,27 @@ export interface MonitorAlertConfigTriggerInsert {
trigger_id: number;
}
// ============ monitor_alerts_config_monitors table ============
export interface MonitorAlertConfigMonitorRecord {
monitor_alerts_id: number;
monitor_tag: string;
created_at: Date;
updated_at: Date;
}
export interface MonitorAlertConfigMonitorInsert {
monitor_alerts_id: number;
monitor_tag: string;
}
// ============ Composite types for monitor_alerts_config ============
export interface MonitorAlertConfigWithTriggers extends MonitorAlertConfigRecord {
triggers: TriggerRecord[];
monitor_tags: string[];
}
export interface MonitorAlertConfigCreateInput {
monitor_tag: string;
monitor_tags: string[];
alert_for: AlertForType;
alert_value: string;
failure_threshold: number;
@@ -704,6 +744,7 @@ export interface MonitorAlertConfigCreateInput {
export interface MonitorAlertConfigUpdateInput {
id: number;
monitor_tags?: string[];
alert_for?: AlertForType;
alert_value?: string;
failure_threshold?: number;
@@ -721,6 +762,7 @@ export type MonitorAlertStatusType = "TRIGGERED" | "RESOLVED";
export interface MonitorAlertV2Record {
id: number;
config_id: number;
monitor_tag: string | null;
incident_id: number | null;
alert_status: MonitorAlertStatusType;
created_at: Date;
@@ -729,11 +771,13 @@ export interface MonitorAlertV2Record {
export interface MonitorAlertV2Insert {
config_id: number;
monitor_tag?: string | null;
incident_id?: number | null;
alert_status: MonitorAlertStatusType;
}
export interface MonitorAlertV2Update {
monitor_tag?: string | null;
incident_id?: number | null;
alert_status?: MonitorAlertStatusType;
}
@@ -741,6 +785,7 @@ export interface MonitorAlertV2Update {
export interface MonitorAlertV2Filter {
id?: number;
config_id?: number;
monitor_tag?: string;
incident_id?: number;
alert_status?: MonitorAlertStatusType;
}
@@ -28,7 +28,12 @@
}
gtag("js", new Date());
gtag("config", "{{id}}");
var gtagConfig = {};
var transportUrl = "{{transport_url}}";
if (!!transportUrl) {
gtagConfig.transport_url = transportUrl;
}
gtag("config", "{{id}}", gtagConfig);
window.addEventListener("analyticsEvent", function (e) {
// Extract event name and data from the custom event
@@ -43,6 +48,7 @@
//on dom ready
document.addEventListener("DOMContentLoaded", function () {
loadJS("https://www.googletagmanager.com/gtag/js?id={{id}}", initJS, document.getElementsByTagName("head")[0]);
var scriptHost = "{{script_host}}" || "https://www.googletagmanager.com/gtag/js?id={{id}}";
loadJS(scriptHost, initJS, document.getElementsByTagName("head")[0]);
});
})();
+52 -2
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;
@@ -198,6 +199,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 +301,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 +354,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 {
@@ -386,6 +398,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 +450,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 +492,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 +522,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 +536,7 @@ export interface UpdatePageRequest {
page_header?: string;
page_subheader?: string | null;
page_logo?: string | null;
page_settings?: Partial<PageSettings>;
page_settings?: PageSettingsPatch;
monitors?: string[];
}
+10
View File
@@ -148,3 +148,13 @@ export interface SitemapXMLConfig {
loc: string;
}[];
}
export interface GlobalMaintenanceNotificationSettings {
event_types: {
created: boolean;
reminder: boolean;
started: boolean;
ended: boolean;
};
reminder_buffer_hours: number;
}
+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`),
};
}
@@ -264,7 +267,7 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
updatedMaintenance.start_date_time,
updatedMaintenance.rrule,
updatedMaintenance.duration_seconds,
7,
1,
);
}
} else if (!isOneTime && scheduleChanged) {
@@ -287,7 +290,7 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
updatedMaintenance.start_date_time,
updatedMaintenance.rrule,
updatedMaintenance.duration_seconds,
7,
1,
);
}
}
@@ -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,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) {
@@ -20,7 +22,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 +30,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 +70,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
}
const response: GetMaintenanceEventResponse = {
event: buildEventResponse(event),
event: await buildEventResponse(event),
};
return json(response);
@@ -182,7 +185,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}`),
});
}
@@ -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);
-8
View File
@@ -16,14 +16,6 @@
<ModeWatcher />
<Toaster />
<svelte:head></svelte:head>
<main>
{@render children()}
</main>
<style>
/* Apply the global font family using the CSS variable */
* {
font-family: var(--font-family);
}
</style>
+12
View File
@@ -221,6 +221,10 @@
{
"title": "Internationalization",
"content": "v4/internationalization"
},
{
"title": "Analytics",
"content": "v4/analytics"
}
]
}
@@ -299,6 +303,14 @@
"group": "v4.x",
"collapsible": false,
"pages": [
{
"title": "v4.0.23",
"content": "v4/changelogs/v4.0.23"
},
{
"title": "v4.0.22",
"content": "v4/changelogs/v4.0.22"
},
{
"title": "v4.0.20",
"content": "v4/changelogs/v4.0.20"
@@ -23,8 +23,6 @@
</script>
<svelte:head>
<title>{data.config.name}</title>
<meta name="description" content="Documentation for {data.config.name}" />
<!-- favicon -->
<link rel="icon" href={data.config.favicon} />
</svelte:head>
@@ -149,14 +149,18 @@
<svelte:head>
<title>{data.title} - Documentation</title>
<meta name="description" content={data.description || `Documentation for ${data.title}`} />
<meta property="article:author" content="https://github.com/rajnandan1" />
<link rel="canonical" href={`https://kener.ing/docs/${data.slug}`} />
<meta property="og:title" content="{data.title} - Documentation" />
<meta property="og:description" content={data.description || `Documentation for ${data.title}`} />
<meta property="og:type" content="article" />
<meta property="article:author" content="https://github.com/rajnandan1" />
<link rel="canonical" href={`https://kener.ing/docs/${data.slug}`} />
<meta property="og:url" content={`https://kener.ing/docs/${data.slug}`} />
<meta property="og:logo" content="https://kener.ing/logo96.png" />
<meta property="og:image" content="https://kener.ing/og.jpg" />
<meta name="twitter:title" content="{data.title} - Documentation" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:description" content={data.description || `Documentation for ${data.title}`} />
<meta name="twitter:image" content="https://kener.ing/og.jpg" />
{@html `<script type="application/ld+json">${JSON.stringify({
"@context": "https://schema.org",
"@type": "BreadcrumbList",
+2 -60
View File
@@ -9,66 +9,8 @@
</script>
<svelte:head>
<title>Kener Documentation</title>
<!-- social preview og.jpg -->
<meta property="og:image" content="https://kener.ing/og.jpg" />
<meta property="og:title" content="Kener Documentation" />
<meta
property="og:description"
content="Comprehensive documentation for Kener, the open-source status page system. Learn how to set up, customize, and manage your own status page with monitoring, incident management, and notifications."
/>
<meta property="og:site_name" content="Kener" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Kener Documentation" />
<meta
name="twitter:description"
content="Comprehensive documentation for Kener, the open-source status page system. Learn how to set up, customize, and manage your own status page with monitoring, incident management, and notifications."
/>
<meta name="twitter:image" content="https://kener.ing/og.jpg" />
<meta name="author" content="Raj Nandan Sharma" />
<link rel="author" href="https://github.com/rajnandan1" />
{@html `<script type="application/ld+json">${JSON.stringify({
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
name: "Kener",
url: "https://kener.ing",
logo: "https://kener.ing/logo96.png",
sameAs: ["https://github.com/rajnandan1/kener"]
},
{
"@type": "SoftwareApplication",
name: "Kener",
applicationCategory: "DeveloperApplication",
operatingSystem: "Linux, macOS, Windows",
url: "https://kener.ing",
description:
"Open-source status page system built with SvelteKit. Features real-time monitoring (API, Ping, TCP, DNS, SSL, SQL, gRPC), incident management, maintenance scheduling, notifications (email, Slack, Discord, webhooks), embeddable widgets, and a REST API.",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD"
},
author: {
"@type": "Person",
name: "Raj Nandan Sharma",
url: "https://github.com/rajnandan1"
},
license: "https://opensource.org/licenses/MIT",
softwareVersion: "4.x",
downloadUrl: "https://github.com/rajnandan1/kener",
screenshot: "https://kener.ing/og.jpg"
},
{
"@type": "WebSite",
name: "Kener",
url: "https://kener.ing"
}
]
})}</script>`}
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Q3MLRXCBFT"></script>
<script async src="https://saki-production.up.railway.app/googletagmanager/gtag/js?id=G-Q3MLRXCBFT"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
@@ -76,7 +18,7 @@
}
gtag("js", new Date());
gtag("config", "G-Q3MLRXCBFT");
gtag("config", "G-Q3MLRXCBFT", { transport_url: "https://saki-production.up.railway.app/google-analytics" });
</script>
</svelte:head>
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>
@@ -0,0 +1,142 @@
---
title: Analytics
description: Connect analytics providers to track traffic on your public status page
---
Kener supports injecting analytics scripts into the public status page. Go to **Manage → Analytics Providers** to configure any of the supported providers.
## How it works {#how-it-works}
When a provider is enabled, Kener injects its tracking script into every public status page response via `/capture.js`. Multiple providers can be active simultaneously.
## Supported providers {#supported-providers}
| Provider | Key |
| ----------------- | ---------------------------- |
| Google Analytics | `analytics.googleTagManager` |
| Plausible | `analytics.plausible` |
| Mixpanel | `analytics.mixpanel` |
| Amplitude | `analytics.amplitude` |
| Microsoft Clarity | `analytics.clarity` |
| Umami | `analytics.umami` |
| PostHog | `analytics.posthog` |
---
## Google Analytics {#google-analytics}
Uses Google Tag Manager / gtag.js.
| Field | Required | Example |
| -------------- | -------- | ----------------------------------------------------------- |
| Measurement ID | Yes | `G-S05E5E5E5E5` |
| Transport URL | No | `https://www.google-analytics.com` |
| Script Host | No | `https://www.googletagmanager.com/gtag/js?id=G-S05E5E5E5E5` |
**Measurement ID** is found in your Google Analytics property under **Admin → Data Streams → Web → Measurement ID**.
**Script Host** is the full URL of the gtag.js script to load, including the `?id=` query parameter. Leave it empty to use Google's default CDN (`https://www.googletagmanager.com/gtag/js?id=<your-id>`). Set a custom URL if you proxy the script through your own domain, e.g. `https://your-proxy.example.com/gtag/js?id=G-XXXXXXXXXX`.
Leave **Transport URL** empty to send hits directly to Google Analytics.
> [!TIP]
> Ad blockers commonly block requests to `www.googletagmanager.com` and `www.google-analytics.com`. Use [Saki](https://saki.rajnandan.com/) — a self-hostable Nginx proxy — to route through your own domain:
>
> - **Script Host**: `https://saki.rajnandan.com/tg/script.js?id=G-XXXXXXXXXX`
> - **Transport URL**: `https://saki.rajnandan.com/an/`
---
## Plausible {#plausible}
Privacy-friendly analytics with no cookies.
| Field | Required | Example |
| ------------- | -------- | ---------------------------------------------------------------- |
| Domain | Yes | `kener.ing` |
| API | Yes | `https://plausible.io/api/event` |
| Script Source | Yes | `https://plausible.io/js/script.pageview-props.tagged-events.js` |
**Domain** must match exactly what you registered at plausible.io. For self-hosted Plausible, replace the **API** and **Script Source** URLs with your instance's host.
---
## Mixpanel {#mixpanel}
| Field | Required | Example |
| ------------- | -------- | -------------------------- |
| Project Token | Yes | `abc123def456` |
| API Host | No | `https://api.mixpanel.com` |
Find your **Project Token** in Mixpanel under **Settings → Project Settings**. Leave **API Host** empty to use the default Mixpanel endpoint.
> [!TIP]
> To bypass ad blockers, use [Saki](https://saki.rajnandan.com/). Set **API Host** to `https://saki.rajnandan.com/mxa/`.
---
## Amplitude {#amplitude}
| Field | Required | Example |
| ----------------- | -------- | -------------------------------------- |
| Amplitude API Key | Yes | `a1b2c3d4e5f6...` |
| Server URL | No | `https://api2.amplitude.com/2/httpapi` |
Find your **Amplitude API Key** under **Settings → Projects → [Your Project] → General**. Leave **Server URL** empty to use Amplitude's default. Use `https://api.eu.amplitude.com/2/httpapi` for EU data residency.
> [!TIP]
> To bypass ad blockers, use [Saki](https://saki.rajnandan.com/). Set **Server URL** to `https://saki.rajnandan.com/aapi/2/httpapi`.
---
## Microsoft Clarity {#microsoft-clarity}
| Field | Required | Example |
| ---------- | -------- | ------------ |
| Project ID | Yes | `abc1234xyz` |
Find your **Project ID** in Microsoft Clarity under **Settings → Overview → Tracking Code** (the value after `clarity.ms/tag/`).
---
## Umami {#umami}
Self-hostable, cookie-free analytics.
| Field | Required | Example |
| ---------- | -------- | -------------------------------------- |
| Website ID | Yes | `5e1c3b29-3f7d-4f2a-8e6b-d3f1a2b3c4d5` |
| Script URL | Yes | `https://cloud.umami.is/script.js` |
Find your **Website ID** in Umami under **Settings → Websites**. For self-hosted Umami, replace the **Script URL** with your instance URL, e.g. `https://umami.example.com/script.js`.
---
## PostHog {#posthog}
Product analytics with session recordings.
| Field | Required | Example |
| -------- | -------- | -------------------------- |
| API Key | Yes | `phc_xxxxxxxxxxxxxxxxxxxx` |
| API Host | Yes | `https://us.i.posthog.com` |
Find your **API Key** in PostHog under **Settings → Project → Project API key**. Set **API Host** to `https://eu.i.posthog.com` for the EU cloud, or your self-hosted PostHog URL.
> [!TIP]
> To bypass ad blockers, use [Saki](https://saki.rajnandan.com/). Set **API Host** to `https://saki.rajnandan.com/pha/`.
---
## Enable a provider {#enable-a-provider}
1. Go to **Manage → Analytics Providers**.
2. Select the provider from the left panel.
3. Fill in the required fields.
4. Set **Status** to **Enable**.
5. Click **Save Changes**.
A green dot next to the provider name indicates it is active on the public site.
> [!NOTE]
> Changes take effect immediately — no restart required.
@@ -0,0 +1,76 @@
---
title: v4.0.22 Changelog
description: See what's new in Kener v4.0.22, including new features, improvements, and bug fixes
---
## New features {#new-features}
> [!NOTE]
> The migration for sqlite3 was broken in the release 4.0.21. So it has been fixed in this release.
### Multi-monitor alert configurations {#multi-monitor-alerts}
Alert configurations can now be applied to **multiple monitors** at once. Previously each alert config was tied to a single monitor — creating the same alert for several monitors required duplicating it.
- A searchable multi-select UI replaces the single-monitor dropdown when creating or editing alerts.
- Alert logs show which specific monitor triggered each alert.
- Existing single-monitor configs are automatically migrated to the new schema.
New database migration adds a `monitor_alerts_config_monitors` junction table and a `monitor_tag` column on `monitor_alerts_v2` to track per-monitor trigger history.
### Upcoming maintenances on status pages {#upcoming-maintenances}
Upcoming scheduled maintenance events are now displayed on the home page, custom page routes, and individual monitor detail pages. This gives visitors advance notice of planned work.
Visibility is controlled by the existing **Event Display Settings → Maintenances → Upcoming** toggle in **Manage → Site Configurations**.
### Maintenance subscriber notifications {#maintenance-notifications}
Maintenance events can now trigger subscriber notifications at each lifecycle stage: **created**, **reminder**, **started**, and **ended**. Configure which events send notifications in **Manage → Site Configurations → Maintenance Notification Settings**.
| Event type | When sent | Default |
| :--------- | :--------------------------- | :------ |
| Created | New event is generated | Off |
| Reminder | Event enters READY state | On |
| Started | Event enters ONGOING state | On |
| Ended | Event enters COMPLETED state | On |
The **reminder buffer** (default 1 hour) controls how far before the start time the READY transition and reminder notification happen.
### Analytics documentation {#analytics-docs}
A new [Analytics](/docs/v4/analytics) documentation page covers all seven supported providers — Google Analytics, Plausible, Mixpanel, Amplitude, Microsoft Clarity, Umami, and PostHog — with configuration tables, setup instructions, and ad-blocker proxy tips.
## Improvements {#improvements}
### Google Tag Manager transport URL and script host {#gtm-transport-url}
The Google Tag Manager integration now supports two new optional fields:
- **Transport URL** — custom endpoint for analytics hits (useful for bypassing ad blockers with a proxy like [Saki](https://github.com/nicholasgasior/saki)).
- **Script Host** — custom URL for the gtag.js script instead of the default `googletagmanager.com`.
Configure both in **Manage → Analytics Providers → Google Analytics**.
### Improved TCP and API monitor error handling {#tcp-api-error-handling}
- **TCP monitors** now collect all host connection failure messages and report them as a combined error.
- **API monitors** now create an HTTPS agent with keep-alive and socket pooling, distinguish timeout errors more accurately (`ECONNABORTED` vs message-based), and truncate eval errors to 200 characters.
### "Included Monitors" shows count {#included-monitors-count}
The group monitor popover and related components now display the number of included monitors — e.g. "Included Monitors (5)" — making it easier to see group size at a glance. All locale files updated with the `%count` placeholder.
### Czech and Slovak translation updates {#czech-slovak-translations}
Updated Czech (`cs.json`) and Slovak (`sk.json`) translations with new and corrected strings.
## Bug fixes {#bug-fixes}
- Fixed custom CSS and custom fonts documentation guide links pointing to incorrect URLs.
- Removed unused `<svelte:head>` elements and stale metadata from documentation layouts.
- Removed `knip.json` — the project now uses default knip configuration.
## Security {#security}
Patched critical and high-severity vulnerabilities by adding `overrides` for `fast-xml-parser`, `rollup`, `undici`, `minimatch`, `devalue`, `dompurify`, `cookie`, and `mailparser`. Updated `@sveltejs/kit` to ^2.53.3 and `svelte` to ^5.53.5.
@@ -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.
@@ -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
@@ -15,6 +15,7 @@ Use **Manage → Site Configurations** to control identity, navigation, monitor
6. Configure **Social Preview & SEO**.
7. Configure **Data Retention Policy**.
8. Configure **Event Display Settings**.
9. Configure **Maintenance Notification Settings**.
## Runtime impact map {#runtime-impact-map}
@@ -25,6 +26,7 @@ Use **Manage → Site Configurations** to control identity, navigation, monitor
| Monitor sub menu options | `subMenuOptions` | Gates monitor share actions (badges/embed) on public monitor pages |
| Global page visibility | `globalPageVisibilitySettings` | Controls page switcher visibility and page-scoped navigation/events |
| Data retention policy | `dataRetentionPolicy` | Controls daily cleanup of old `monitoring_data` |
| Maintenance notifications | `globalMaintenanceNotificationSettings` | Controls which maintenance lifecycle events notify subscribers and reminder buffer timing |
| Social preview & SEO | `metaSiteTitle`, `metaSiteDescription`, `socialPreviewImage` | Default `<title>`, `og:title`, `<meta description>`, `og:description`, `og:image` for all pages |
## Monitor sub menu options {#monitor-sub-menu-options}
@@ -82,6 +84,25 @@ This affects:
- event sections on status pages
- notifications payload API used by the UI
## Maintenance notification settings {#maintenance-notification-settings}
`globalMaintenanceNotificationSettings` controls which maintenance event lifecycle transitions send subscriber notifications.
### Event types {#event-types}
| Event type | Trigger | Default |
| ---------- | ------------------------------- | ------- |
| `created` | New maintenance event generated | Off |
| `reminder` | Event enters READY state | On |
| `started` | Event enters ONGOING state | On |
| `ended` | Event enters COMPLETED state | On |
### Reminder buffer {#reminder-buffer}
`reminder_buffer_hours` (default `1`, minimum `1`) sets how many hours before the event start time the status transitions from SCHEDULED to READY.
See [Maintenance Events → Subscriber Notifications](/docs/v4/maintenances/events#subscriber-notifications) for the full lifecycle flow.
## Social preview and SEO {#social-preview-and-seo}
The **Social Preview & SEO** card sets site-wide defaults for meta tags used in search engines and link previews.
@@ -107,4 +128,5 @@ These values are used as defaults for every page. Individual pages can override
- notifications calendar opens page-scoped events for the current month.
- Change event display settings and verify incident/maintenance visibility.
- Set retention policy and confirm scheduler logs in server output.
- Toggle maintenance notification event types and verify subscribers receive (or don't receive) emails at each lifecycle stage.
- Set meta title/description and social preview image, then check `<meta>` tags in page source.
@@ -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}
+17 -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>
@@ -189,6 +189,15 @@
{/each}
</div>
{/if}
{#if data.upcomingMaintenances && data.upcomingMaintenances.length > 0}
<div class="flex flex-col gap-3">
{#each data.upcomingMaintenances as maintenance, i (maintenance.id ?? i)}
<div class="rounded-3xl border p-3 sm:p-4">
<MaintenanceItem {maintenance} />
</div>
{/each}
</div>
{/if}
<div class="overflow-hidden rounded-3xl border">
<div class={`grid grid-cols-1 ${getGridContainerClass(viewType)}`}>
{#each data.monitorTags as tag, i (tag)}
+17 -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>
@@ -189,6 +189,15 @@
{/each}
</div>
{/if}
{#if data.upcomingMaintenances && data.upcomingMaintenances.length > 0}
<div class="flex flex-col gap-3">
{#each data.upcomingMaintenances as maintenance, i (maintenance.id ?? i)}
<div class="rounded-3xl border p-3 sm:p-4">
<MaintenanceItem {maintenance} />
</div>
{/each}
</div>
{/if}
<div class="overflow-hidden rounded-3xl border">
<div class={`grid grid-cols-1 ${getGridContainerClass(viewType)}`}>
{#each data.monitorTags as tag, i (tag)}
@@ -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>
+9 -1
View File
@@ -32,7 +32,15 @@ export const GET: RequestHandler = async ({ params, url }) => {
if (!!analyticsData["analytics.googleTagManager"]) {
let id = analyticsData["analytics.googleTagManager"].requirements["Measurement ID"];
captureScript = captureScript + ";\n" + gtm.replaceAll("{{id}}", id);
let transportUrl = analyticsData["analytics.googleTagManager"].requirements["Transport URL"] || "";
let scriptHost = analyticsData["analytics.googleTagManager"].requirements["Script Host"] || "";
captureScript =
captureScript +
";\n" +
gtm
.replaceAll("{{id}}", id)
.replaceAll("{{transport_url}}", transportUrl)
.replaceAll("{{script_host}}", scriptHost);
}
if (!!analyticsData["analytics.mixpanel"]) {
@@ -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>
@@ -35,9 +35,17 @@ export const load: PageServerLoad = async ({ params, parent }) => {
const monitorTags = [monitor_tag];
const [ongoingIncidents, ongoingMaintenances] = await Promise.all([
const eventSettings = parentData.eventDisplaySettings;
const [ongoingIncidents, ongoingMaintenances, upcomingMaintenances] = await Promise.all([
GetOngoingIncidentsForMonitorList(monitorTags),
GetOngoingMaintenanceEventsForMonitorList(monitorTags),
eventSettings.maintenances.enabled && eventSettings.maintenances.upcoming.show
? GetUpcomingMaintenanceEventsForMonitorList(
monitorTags,
eventSettings.maintenances.upcoming.maxCount,
eventSettings.maintenances.upcoming.daysInFuture,
)
: Promise.resolve([]),
]);
//last known status
@@ -75,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 {
...{
@@ -94,6 +104,7 @@ export const load: PageServerLoad = async ({ params, parent }) => {
monitorLastLatency: ParseLatency(item.avgLatency),
ongoingIncidents,
ongoingMaintenances,
upcomingMaintenances,
externalUrl: monitor.external_url,
extendedTags,
monitorGroupMembersByTag,
@@ -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">
@@ -143,6 +143,15 @@
{/each}
</div>
{/if}
{#if data.upcomingMaintenances && data.upcomingMaintenances.length > 0}
<div class="flex flex-col gap-3">
{#each data.upcomingMaintenances as maintenance, i (maintenance.id ?? i)}
<div class="rounded-3xl border p-3 sm:p-4">
<MaintenanceItem {maintenance} />
</div>
{/each}
</div>
{/if}
<!-- Calendar View (self-contained component with its own API call) -->
<MonitorOverview
+22 -3
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 GC from "$lib/global-constants";
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,16 +31,31 @@ 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,
canSendEmail: IsEmailSetup(),
seedSiteData,
};
};
+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 />
+69 -83
View File
@@ -111,6 +111,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 +134,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 +147,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 +170,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 +258,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 +275,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) {
@@ -325,6 +322,7 @@ export async function POST({ request, cookies }) {
const testAlertData: MonitorAlertV2Record = {
id: 1,
config_id: 1,
monitor_tag: null,
incident_id: 4,
alert_status: Math.random() > 0.5 ? "TRIGGERED" : "RESOLVED",
created_at: new Date(),
@@ -369,7 +367,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];
@@ -385,10 +382,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();
@@ -401,26 +396,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 };
}
@@ -433,15 +422,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") {
@@ -452,38 +438,30 @@ 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 == "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);
@@ -493,11 +471,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;
@@ -509,7 +485,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) {
@@ -517,7 +492,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);
}
@@ -564,14 +538,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");
@@ -593,7 +565,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");
@@ -613,7 +584,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");
@@ -623,7 +593,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");
@@ -633,7 +602,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");
@@ -643,7 +611,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 = {
@@ -668,8 +635,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);
@@ -164,7 +164,7 @@
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Monitor</Table.Head>
<Table.Head>Monitors</Table.Head>
<Table.Head>Alert Type</Table.Head>
<Table.Head>Severity</Table.Head>
<Table.Head>Description</Table.Head>
@@ -198,12 +198,23 @@
{#each configs as config (config.id)}
<Table.Row class={config.is_active === GC.NO ? "opacity-60" : ""}>
<Table.Cell>
<a
href={clientResolver(resolve, `/manage/app/monitors/${config.monitor_tag}`)}
class="text-primary font-medium hover:underline"
>
{config.monitor_tag}
</a>
{#if config.monitor_tags && config.monitor_tags.length > 0}
<div class="flex flex-wrap gap-1">
{#each config.monitor_tags as tag}
<a
href={clientResolver(resolve, `/manage/app/monitors/${tag}`)}
class="text-primary text-sm font-medium hover:underline"
>
{monitors.find((m) => m.tag === tag)?.name || tag}
</a>
{#if config.monitor_tags.indexOf(tag) < config.monitor_tags.length - 1}
<span class="text-muted-foreground">,</span>
{/if}
{/each}
</div>
{:else}
<span class="text-muted-foreground text-sm">-</span>
{/if}
</Table.Cell>
<Table.Cell>
<Badge variant="outline">{config.alert_for}</Badge>

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