mirror of
https://github.com/rajnandan1/kener.git
synced 2026-08-07 07:14:56 +00:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74b6311e81 | |||
| c4c16d65a6 | |||
| 758cf5e4d5 | |||
| 7c17db12dc | |||
| d9954ea085 | |||
| 305c0a05fe | |||
| f83447f806 | |||
| 42ca67b172 | |||
| 806df0d73c | |||
| 5f66cada43 | |||
| 85788c76f7 | |||
| 604210568b | |||
| 41f5296227 | |||
| 8c1a97d844 | |||
| ed1a70d75b | |||
| e63a2f6311 | |||
| 951ab06f7e | |||
| af4684a90f | |||
| da6eaee3ab | |||
| f264115ab8 | |||
| 15b78dab66 | |||
| 54277ece9a | |||
| 54f056ad34 | |||
| 8d2808c291 | |||
| bd638ccf24 | |||
| c2945485e2 | |||
| a57c92fc0e | |||
| 8e7bc47b14 | |||
| cd26c46493 | |||
| 508b08f8f3 | |||
| 638393efac | |||
| 5a54d69d87 | |||
| 7e5ea5fda1 | |||
| 175cf605c6 | |||
| 6a9bfffbd4 | |||
| 7b120911b4 | |||
| 35817bc20a | |||
| a8fbac1b69 | |||
| cfc99e2f14 | |||
| 31ba10f434 | |||
| 1750e2a341 | |||
| a12df92b94 | |||
| 5d86084138 | |||
| 7050f780a3 | |||
| cb93089dcc | |||
| fcd05e1d68 | |||
| db9d7807e0 | |||
| b7e0756c54 | |||
| b920d2f9bc | |||
| 560c87219b | |||
| 94e24eec04 | |||
| 15680a58aa | |||
| 59f0eaef27 | |||
| 8362a73058 | |||
| 52f8c50f50 | |||
| 60868d55ca | |||
| f7e657ee95 | |||
| 63e5ec2886 | |||
| 2aef97c1ed | |||
| 51b2da97e0 | |||
| 50bddcd9a3 | |||
| bd36533b05 | |||
| 17500a0b43 | |||
| 0050cd810b |
@@ -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`.
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
# Kener
|
||||
|
||||
An open-source status page application providing real-time monitoring, uptime tracking, incident management, and customizable dashboards.
|
||||
|
||||
## Language
|
||||
|
||||
### Monitoring
|
||||
|
||||
**Monitor**:
|
||||
A single check against a service, with a unique tag, a type (API, Ping, TCP, DNS, SSL, SQL, Heartbeat, GameDig, Group, gRPC, None), and a status (ACTIVE or otherwise).
|
||||
_Avoid_: Check, probe, service
|
||||
|
||||
**Inactive Monitor**:
|
||||
A monitor that is not checked at all: the scheduler drops its job and no monitoring data is collected until it is made ACTIVE again. Independent of visibility (see Hidden Monitor).
|
||||
_Avoid_: Disabled monitor, paused monitor
|
||||
|
||||
**Hidden Monitor**:
|
||||
A monitor excluded from all status pages while remaining fully checked and alerted. Independent of ACTIVE/INACTIVE.
|
||||
_Avoid_: Invisible monitor, private monitor
|
||||
|
||||
**Group Monitor**:
|
||||
A monitor whose status is derived from other monitors via a weighted score (UP=1, DEGRADED=0.5, DOWN=0; maintenance counts as UP). A group cannot contain another group.
|
||||
_Avoid_: Monitor group, composite monitor
|
||||
|
||||
**Member**:
|
||||
A monitor belonging to a Group Monitor, carrying a weight and a position in the execution order. Membership is an explicit stored list, never a dynamic rule (e.g. tag wildcards).
|
||||
_Avoid_: Child monitor, sub-monitor
|
||||
|
||||
**Weight**:
|
||||
A member's share of the group score, between 0 and 1. Weights across a group's members must sum to 1. Any membership change (add or remove) redistributes all weights equally; manual tuning happens after membership is settled.
|
||||
|
||||
**Execution Order**:
|
||||
The stored order in which a Group Monitor's members are checked before aggregation. Manually arranged, not derived.
|
||||
|
||||
**Eligible Monitor**:
|
||||
A monitor that may become a Member: ACTIVE, not a Group Monitor, and not the group being edited itself.
|
||||
|
||||
**Monitoring Sample**:
|
||||
One recorded data point for a monitor at a timestamp: a status, a latency, and a sample type describing how it was produced. Every sample is either Observed or Synthetic.
|
||||
_Avoid_: Data point, record, check result
|
||||
|
||||
**Observed Sample**:
|
||||
A Monitoring Sample produced by a check that actually ran against the target, whatever the outcome: a clean evaluation (`REALTIME`), a timed-out check (`TIMEOUT`), or a check that errored (`ERROR`). A check failing to reach the target is itself an observation — for most monitor types that is exactly what "down" looks like.
|
||||
|
||||
**Synthetic Sample**:
|
||||
A Monitoring Sample written by the system or an admin rather than by a check: a raw heartbeat receipt (`SIGNAL`), a status pushed through the data API (`MANUAL`), a default-status fill (`DEFAULT_STATUS`), a last-known-status fill (`CARRIED`), or an incident/maintenance overlay (`INCIDENT`, `MAINTENANCE`).
|
||||
|
||||
**Default Status**:
|
||||
A monitor's answer to what a minute without a Monitoring Sample means. Exactly one choice from a closed set: nothing (`NONE` — the minute shows no data), a fixed status (`UP`, `DOWN`, `DEGRADED`) written as default-status fill, or Last Known Status. `MAINTENANCE` is not a Default Status (a maintenance overlay is an event, not a fill).
|
||||
_Avoid_: Fallback status, fill status
|
||||
|
||||
**Last Known Status**:
|
||||
A Default Status choice where a minute without a sample repeats the most recent Alert-Visible Sample — status and latency alike — written as a Carried Sample (`CARRIED`). Carried Samples are themselves alert-visible, so the chain continues from the last live statement: overlays and heartbeat receipts never become sticky, backdated corrections do not change the present, and alerts trigger and resolve on carried minutes like any other. Carry never expires and never backfills: it starts at the tick after the choice is made, and Carried Samples persist as history if the choice is later changed. A monitor with no Alert-Visible Sample yet has nothing to carry — its minutes show no data. Only None-type monitors may choose it; changing the monitor's type away from None resets the Default Status to UP.
|
||||
_Avoid_: Sticky status, carry-forward mode
|
||||
|
||||
**Stale Member**:
|
||||
A Member whose monitor is no longer an Eligible Monitor (paused or deleted after being added). It remains a Member until explicitly removed, but is excluded from the group score.
|
||||
|
||||
### Alerting
|
||||
|
||||
**Alert Configuration**:
|
||||
A per-monitor alerting rule: a condition (status, latency, or uptime against a value), a Failure Threshold, a Success Threshold, whether triggering creates an incident, and the Triggers to notify.
|
||||
_Avoid_: Alert rule, alarm
|
||||
|
||||
**Alert**:
|
||||
A fired instance of an Alert Configuration for a monitor. TRIGGERED when the condition holds, RESOLVED when the resolve condition later holds. May own the incident it created.
|
||||
_Avoid_: Alarm, notification (that's what Triggers send)
|
||||
|
||||
**Trigger**:
|
||||
A notification channel (email, webhook, Discord, Slack) that Alert Configurations notify on trigger and on resolve.
|
||||
_Avoid_: Notifier, channel
|
||||
|
||||
**Alert-Visible Sample**:
|
||||
A Monitoring Sample that alert evaluation can see: every Observed Sample, plus data-API pushes (`MANUAL`), default-status fill (`DEFAULT_STATUS`), and last-known-status fill (`CARRIED`). Raw heartbeat receipts (`SIGNAL`) and incident/maintenance overlays are never alert-visible — while an overlay is active the alert window freezes (alerts neither trigger nor resolve). All alert conditions (status and latency alike) evaluate the same alert-visible timeline.
|
||||
|
||||
**Failure Threshold**:
|
||||
The number of consecutive Alert-Visible Samples matching the condition required to trigger an Alert.
|
||||
|
||||
**Success Threshold**:
|
||||
The number of consecutive Alert-Visible Samples meeting the resolve condition required to resolve an Alert.
|
||||
|
||||
### Pages
|
||||
|
||||
**Page**:
|
||||
A public status page with its own path, title, monitors, and display settings. Served at `/<page_path>`.
|
||||
|
||||
**Home Page**:
|
||||
The Page served at the site root. Its stored path is empty, it always exists (it can not be deleted), and its path can not be changed. Addressed in the API by the `~home` token.
|
||||
_Avoid_: Default page, base page, root page
|
||||
|
||||
**Status History Window**:
|
||||
The number of days of per-day status shown for a monitor, per device class (desktop/mobile). Configurable at two levels with the same defaults and bounds: per Page (applies to all its monitors) and per Monitor (overrides the page level).
|
||||
_Avoid_: History days, bar count
|
||||
|
||||
**Page Settings**:
|
||||
A Page's display configuration: status-history window per device class, monitor layout style, per-page meta/social overrides, and event display preferences. The admin UI and the API expose the same settings, though each surface may name fields differently; a writer must never drop fields it does not understand.
|
||||
_Avoid_: Display settings (ambiguous with site-wide event display settings)
|
||||
|
||||
### Maintenance
|
||||
|
||||
**Maintenance**:
|
||||
A recurring maintenance definition: title, description, an RRULE schedule, a duration, and affected monitors. Identified by its own id.
|
||||
_Avoid_: Maintenance window, maintenance event (that's an occurrence, see below)
|
||||
|
||||
**Maintenance Event**:
|
||||
A single occurrence of a Maintenance, generated from its RRULE: a concrete start/end time with a lifecycle status (SCHEDULED → READY → ONGOING → COMPLETED). Has its own id, independent of the Maintenance id. The public maintenance page is keyed by Maintenance Event id.
|
||||
_Avoid_: Occurrence, maintenance instance
|
||||
@@ -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.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Home page is addressed as `~home` in the v4 API
|
||||
|
||||
The Home Page is stored with an empty `page_path`, which can not appear as a URL segment, so `/api/v4/pages/{page_path}` could not address it at all (#737). The v4 API now accepts the special segment `~home` for it: the request middleware maps `~home` to a lookup of the empty path before handlers run.
|
||||
|
||||
We considered the reporter's suggestion of a `default` keyword, and `home`, but both are valid page paths under the sanitizer (`[a-z0-9_-]`), so a real page could shadow the keyword or force reservation rules and migration edge cases. We also considered addressing pages by id, which either breaks the existing path-based contract or is ambiguous with numeric page paths. `~home` can never collide because the sanitizer strips `~`, and tilde is an RFC 3986 unreserved character, so clients never need to encode it (percent-encoded `%7Ehome` works too, since the middleware decodes segments).
|
||||
|
||||
Semantics follow the server-side invariants the manage UI relies on: `PATCH` via `~home` accepts every field except `page_path`, which is fixed for the home page (the UI disables the field), and `DELETE` is rejected — `DeletePage` in `pagesController` throws "Cannot delete the home page" and the rest of the app assumes the home page exists.
|
||||
|
||||
API responses also render the home page's `page_path` as `~home` (list, single, and write responses), so what a consumer reads is exactly what it can address — list → pick → `PATCH` round-trips cleanly, including read-modify-write bodies that send `~home` back (treated as "no path change"). The stored path remains empty and the public URL remains the site root; consumers must not build public URLs by concatenating `page_path`.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Alerts evaluate alert-visible samples, not just REALTIME ones
|
||||
|
||||
The consecutive-sample checks behind alert evaluation (`consecutivelyStatusFor`, `consecutivelyLatencyGreaterThan`, `consecutivelyLatencyLessThan` in `src/lib/server/db/repositories/monitoring.ts`) consider samples whose type is `REALTIME`, `ERROR`, `TIMEOUT`, `MANUAL`, or `DEFAULT_STATUS` — the "alert-visible" set — instead of `REALTIME` only. Both data-API PATCH endpoints (single timestamp and range) enqueue one alert evaluation after writing `MANUAL` rows. `SIGNAL` rows and `INCIDENT`/`MAINTENANCE` overlay rows remain invisible to alerting. Amended by ADR 0006: last-known-status fill (`CARRIED`) later joined the alert-visible set under the same invariant.
|
||||
|
||||
Two issues drove this. In #633, a GameDig monitor showed DOWN on the status page but never alerted: a down game server makes `GameDig.query` throw, so every down-sample is recorded as `ERROR`, which the old `type = REALTIME` filter excluded — the "N consecutive DOWN" condition could never become true. The same failure mode silently broke gRPC, SQL, and SSL monitors (hard-down records `ERROR`) and API monitors whose outage manifests as timeouts (`TIMEOUT`). In #720, a NONE monitor driven by the data API never alerted for two stacked reasons: PATCH writes `MANUAL` rows the filter excluded, and the endpoint never enqueued evaluation at all. The status page and UPTIME alerts have no type filter, which is why users saw DOWN while alerts stayed silent.
|
||||
|
||||
The whitelist is exactly the set of types written by flows that trigger alert evaluation — scheduler checks (`REALTIME`/`ERROR`/`TIMEOUT`), default-status fill (`DEFAULT_STATUS`), and data-API pushes (`MANUAL`). That invariant ("every enqueuer of evaluation contributes a row the evaluator can see") is what keeps the un-time-bounded last-N query self-healing: without `DEFAULT_STATUS` in the set, a NONE monitor with a default status evaluates every minute against rows it cannot see, so stale backfilled `MANUAL` rows would rank as "the last N" and fire alerts weeks after the fact. The rejected alternatives: fixing only `gamedigCall` to emit `REALTIME` on query failure (fixes one monitor type out of five, loses the stored down-vs-errored diagnostic, and does nothing for existing data), and time-bounding the query (the cutoff must scale with each monitor's cron, which means parsing cron expressions in the alert path for marginal benefit).
|
||||
|
||||
The trade-offs, accepted deliberately for one uniform rule across status and latency alerts: a service that degrades from slow to hard-down resolves an active latency alert (error samples carry latency 0, satisfying "consecutively below threshold") — the status alert is the one that covers outages; a NONE monitor with a default status auto-resolves MANUAL-pushed alerts once default fill resumes, because a default status is an explicit statement that absence of pushes means that status; and the fix is retroactive, so monitors that were already down at upgrade time alert shortly after — which is the bug report, inverted.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Last Known Status is a Default Status choice that repeats the latest alert-visible sample
|
||||
|
||||
Issue #721: push-driven NONE monitors lost their status between pushes in v4 — one red minute, then gray forever — because the per-minute sample model only fills gaps with a static `default_status`. We added a fifth Default Status choice, `LAST_KNOWN`, where each tick with no observed sample writes a `CARRIED` row repeating the most recent alert-visible sample — status and latency alike. It is modeled as a dropdown value rather than a separate "sticky" checkbox so that "what does a minute without a sample mean" stays a single dimension with no conflicting combinations; the same cleanup removed the `MAINTENANCE` option, which the UI offered but the fill engine had always silently ignored (stored `MAINTENANCE`/unknown values migrate to `NONE`, preserving behavior).
|
||||
|
||||
The carry source is the most recent **Alert-Visible Sample** by timestamp, and `CARRIED` itself joins the alert-visible set. That one rule does three jobs: incident/maintenance overlays and raw heartbeat `SIGNAL` receipts can never become sticky; backdated data-API corrections cannot rewrite the present (newer carried rows outrank them); and ADR 0005's invariant — every flow that enqueues alert evaluation contributes a row the evaluator can see — keeps holding. The consequences were accepted deliberately: a single DOWN push triggers status alerts once carried minutes meet the failure threshold, alerts never auto-resolve (recovery must be pushed — unlike a fixed default, nothing "resumes"), and enabling the option on a monitor whose last push was DOWN fires the alert shortly after — the bug report, inverted, same as ADR 0005.
|
||||
|
||||
Rejected alternatives: a staleness cap ("carry for at most X, then gray") re-introduces "absence means unknown" — the opposite of what the admin just selected — and dead-integration detection is what Heartbeat monitors are for; reusing the `DEFAULT` sample type for carried rows loses the stored distinction between "admin declared absence means X" and "system repeated the last live statement"; backfilling the gap on enable would mutate historical uptime, so carry is tick-forward only and `CARRIED` rows persist as history if the setting is later changed. `LAST_KNOWN` is selectable only on NONE-type monitors — for polled types an observed sample wins the merge every tick, so offering it would be a dormant knob; changing a monitor's type away from NONE auto-resets the Default Status to UP so the invalid combination never persists.
|
||||
@@ -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…_
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Triage Labels
|
||||
|
||||
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
|
||||
|
||||
| Label in mattpocock/skills | Label in our tracker | Meaning |
|
||||
| -------------------------- | -------------------- | ---------------------------------------- |
|
||||
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
|
||||
| `needs-info` | `needs-info` | Waiting on reporter for more information |
|
||||
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
|
||||
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
||||
| `wontfix` | `wontfix` | Will not be actioned |
|
||||
|
||||
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
|
||||
|
||||
Edit the right-hand column to match whatever vocabulary you actually use.
|
||||
@@ -0,0 +1,794 @@
|
||||
# Last Known Status (fix #721) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a `LAST_KNOWN` Default Status choice for NONE-type (Manual) monitors: each scheduler tick without new data writes a `CARRIED` sample repeating the most recent alert-visible sample (status + latency), so push-driven monitors keep their status between pushes.
|
||||
|
||||
**Architecture:** The carry fill slots into the existing `defaultData` branch of the monitor-execute worker (`monitorExecuteQueue.ts`), sourcing from a new repository query for the latest alert-visible sample. A single normalization helper in `monitorsController.ts` enforces the closed `default_status` value set (`NONE|UP|DOWN|DEGRADED|LAST_KNOWN`) and the "LAST_KNOWN only on NONE-type, auto-reset to UP otherwise" rule across all three monitor write paths (manage UI action, v4 POST, v4 PATCH). A migration normalizes legacy values (`MAINTENANCE`/unknown/NULL → `NONE`).
|
||||
|
||||
**Tech Stack:** SvelteKit 2 (Svelte 5 runes), Knex migrations, BullMQ workers, shadcn-svelte UI.
|
||||
|
||||
**Design authority:** `docs/adr/0006-last-known-status-fill.md` and the `Default Status` / `Last Known Status` / `Alert-Visible Sample` entries in `CONTEXT.md`. If a step seems to contradict those, the docs win.
|
||||
|
||||
**Verification approach:** This repo has NO test infrastructure (no test script, no tests/ dir). Backend logic is verified with throwaway `vite-node` scripts driving repository classes against in-memory better-sqlite3 (delete the scripts before committing), plus a live end-to-end pass against the dev server. `npm run check` gates every commit.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Constants + alert-visible whitelist
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/lib/global-constants.ts:43`
|
||||
- Modify: `src/lib/server/db/repositories/monitoring.ts:13-20`
|
||||
|
||||
- [ ] **Step 1: Add the two constants**
|
||||
|
||||
In `src/lib/global-constants.ts`, the default export object currently has (line 43):
|
||||
|
||||
```typescript
|
||||
DEFAULT_STATUS: "DEFAULT",
|
||||
```
|
||||
|
||||
Add two lines directly after it:
|
||||
|
||||
```typescript
|
||||
DEFAULT_STATUS: "DEFAULT",
|
||||
CARRIED: "CARRIED",
|
||||
LAST_KNOWN: "LAST_KNOWN",
|
||||
```
|
||||
|
||||
(`CARRIED` is a **sample type** written by last-known-status fill; `LAST_KNOWN` is a **default_status value** stored on the monitor. They are different namespaces that happen to live in the same constants object — keep both names exactly as above.)
|
||||
|
||||
- [ ] **Step 2: Add CARRIED to the alert-visible whitelist**
|
||||
|
||||
In `src/lib/server/db/repositories/monitoring.ts`, replace lines 13-20:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Sample types alert evaluation can see (see docs/adr/0005-alerts-evaluate-alert-visible-samples.md).
|
||||
* Exactly the types written by flows that enqueue alert evaluation: scheduler checks
|
||||
* (REALTIME/ERROR/TIMEOUT), default-status fill (DEFAULT_STATUS), and data-API pushes (MANUAL).
|
||||
* SIGNAL rows (raw heartbeat receipts) and INCIDENT/MAINTENANCE overlays stay invisible, so the
|
||||
* alert window freezes during manual overlays instead of triggering or resolving on them.
|
||||
*/
|
||||
const ALERT_VISIBLE_TYPES = [GC.REALTIME, GC.ERROR, GC.TIMEOUT, GC.MANUAL, GC.DEFAULT_STATUS]
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Sample types alert evaluation can see (see docs/adr/0005-alerts-evaluate-alert-visible-samples.md
|
||||
* and docs/adr/0006-last-known-status-fill.md).
|
||||
* Exactly the types written by flows that enqueue alert evaluation: scheduler checks
|
||||
* (REALTIME/ERROR/TIMEOUT), default-status fill (DEFAULT_STATUS), last-known-status fill (CARRIED),
|
||||
* and data-API pushes (MANUAL).
|
||||
* SIGNAL rows (raw heartbeat receipts) and INCIDENT/MAINTENANCE overlays stay invisible, so the
|
||||
* alert window freezes during manual overlays instead of triggering or resolving on them.
|
||||
*/
|
||||
const ALERT_VISIBLE_TYPES = [GC.REALTIME, GC.ERROR, GC.TIMEOUT, GC.MANUAL, GC.DEFAULT_STATUS, GC.CARRIED]
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Type-check**
|
||||
|
||||
Run: `npm run check`
|
||||
Expected: 0 errors (same error/warning count as before the change — run it on a clean tree first if unsure).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/global-constants.ts src/lib/server/db/repositories/monitoring.ts
|
||||
git commit -m "feat(constants): add CARRIED sample type and LAST_KNOWN default status"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Repository — latest alert-visible sample query
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/lib/server/db/repositories/monitoring.ts` (after `getLatestMonitoringData`, line 79)
|
||||
- Modify: `src/lib/server/db/dbimpl.ts:52-53` (declaration) and `:412` (binding)
|
||||
|
||||
- [ ] **Step 1: Add the repository method**
|
||||
|
||||
In `src/lib/server/db/repositories/monitoring.ts`, directly after the `getLatestMonitoringData` method (ends line 79), add:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Latest sample the alert evaluator (and last-known-status fill) can see.
|
||||
* Carry source for Default Status = LAST_KNOWN (docs/adr/0006): overlays
|
||||
* (INCIDENT/MAINTENANCE) and raw heartbeat receipts (SIGNAL) are excluded,
|
||||
* so they can never become sticky.
|
||||
*/
|
||||
async getLatestAlertVisibleData(monitor_tag: string): Promise<MonitoringData | undefined> {
|
||||
return await this.knex("monitoring_data")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
.whereIn("type", ALERT_VISIBLE_TYPES)
|
||||
.orderBy("timestamp", "desc")
|
||||
.limit(1)
|
||||
.first();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Expose it on the db singleton**
|
||||
|
||||
In `src/lib/server/db/dbimpl.ts`, after line 52 (`getLatestMonitoringData!: ...`), add the declaration:
|
||||
|
||||
```typescript
|
||||
getLatestAlertVisibleData!: MonitoringRepository["getLatestAlertVisibleData"];
|
||||
```
|
||||
|
||||
and after line 412 (`this.getLatestMonitoringData = ...bind(this.monitoring);`), add the binding:
|
||||
|
||||
```typescript
|
||||
this.getLatestAlertVisibleData = this.monitoring.getLatestAlertVisibleData.bind(this.monitoring)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write the throwaway verification script**
|
||||
|
||||
Create `scripts/tmp-verify-carry-source.ts` (will be deleted, never committed):
|
||||
|
||||
```typescript
|
||||
import Knex from "knex"
|
||||
import { MonitoringRepository } from "../src/lib/server/db/repositories/monitoring"
|
||||
|
||||
const knex = Knex({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true })
|
||||
|
||||
await knex.schema.createTable("monitoring_data", (t) => {
|
||||
t.string("monitor_tag")
|
||||
t.integer("timestamp")
|
||||
t.string("status")
|
||||
t.float("latency")
|
||||
t.string("type")
|
||||
t.text("error_message")
|
||||
t.primary(["monitor_tag", "timestamp"])
|
||||
})
|
||||
|
||||
const repo = new MonitoringRepository(knex)
|
||||
|
||||
// Timeline: MANUAL DOWN, then a CARRIED copy, then an INCIDENT overlay, then a SIGNAL receipt.
|
||||
await knex("monitoring_data").insert([
|
||||
{ monitor_tag: "t", timestamp: 100, status: "DOWN", latency: 42, type: "MANUAL" },
|
||||
{ monitor_tag: "t", timestamp: 160, status: "DOWN", latency: 42, type: "CARRIED" },
|
||||
{ monitor_tag: "t", timestamp: 220, status: "UP", latency: 0, type: "INCIDENT" },
|
||||
{ monitor_tag: "t", timestamp: 280, status: "UP", latency: 0, type: "SIGNAL" }
|
||||
])
|
||||
|
||||
const latest = await repo.getLatestAlertVisibleData("t")
|
||||
console.log("latest:", latest)
|
||||
if (!latest || latest.timestamp !== 160 || latest.type !== "CARRIED" || latest.status !== "DOWN") {
|
||||
throw new Error("FAIL: expected the CARRIED row at ts=160 (INCIDENT/SIGNAL must be skipped)")
|
||||
}
|
||||
|
||||
const none = await repo.getLatestAlertVisibleData("missing")
|
||||
if (none !== undefined) throw new Error("FAIL: expected undefined for unknown tag")
|
||||
|
||||
console.log("PASS")
|
||||
await knex.destroy()
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run it**
|
||||
|
||||
Run: `npx vite-node scripts/tmp-verify-carry-source.ts`
|
||||
Expected: prints the ts=160 CARRIED row, then `PASS`.
|
||||
|
||||
- [ ] **Step 5: Delete the script, type-check, commit**
|
||||
|
||||
```bash
|
||||
rm scripts/tmp-verify-carry-source.ts
|
||||
npm run check
|
||||
git add src/lib/server/db/repositories/monitoring.ts src/lib/server/db/dbimpl.ts
|
||||
git commit -m "feat(db): add getLatestAlertVisibleData query for last-known-status carry source"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Engine — carry fill in the execute worker
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/lib/server/queues/monitorExecuteQueue.ts:125-156`
|
||||
|
||||
- [ ] **Step 1: Extend the defaultData branch**
|
||||
|
||||
In `src/lib/server/queues/monitorExecuteQueue.ts`, replace lines 125-139:
|
||||
|
||||
```typescript
|
||||
let defaultData: MonitoringResultTS = {}
|
||||
let mergedData: MonitoringResultTS = {}
|
||||
|
||||
if (monitor.default_status !== undefined && monitor.default_status !== null) {
|
||||
if (([GC.UP, GC.DOWN, GC.DEGRADED] as string[]).indexOf(monitor.default_status) !== -1) {
|
||||
defaultData[ts] = {
|
||||
status: monitor.default_status,
|
||||
latency: 0,
|
||||
type: GC.DEFAULT_STATUS
|
||||
}
|
||||
if (monitor.default_status !== GC.UP) {
|
||||
defaultData[ts].error_message = "Default status applied"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
let defaultData: MonitoringResultTS = {}
|
||||
let mergedData: MonitoringResultTS = {}
|
||||
|
||||
if (monitor.default_status !== undefined && monitor.default_status !== null) {
|
||||
if (([GC.UP, GC.DOWN, GC.DEGRADED] as string[]).indexOf(monitor.default_status) !== -1) {
|
||||
defaultData[ts] = {
|
||||
status: monitor.default_status,
|
||||
latency: 0,
|
||||
type: GC.DEFAULT_STATUS
|
||||
}
|
||||
if (monitor.default_status !== GC.UP) {
|
||||
defaultData[ts].error_message = "Default status applied"
|
||||
}
|
||||
} else if (monitor.default_status === GC.LAST_KNOWN) {
|
||||
// Last Known Status fill (docs/adr/0006): repeat the most recent alert-visible
|
||||
// sample — status and latency alike. No sample yet → nothing to carry → no fill.
|
||||
const lastKnown = await db.getLatestAlertVisibleData(monitor.tag)
|
||||
if (lastKnown && lastKnown.status) {
|
||||
defaultData[ts] = {
|
||||
status: lastKnown.status,
|
||||
latency: lastKnown.latency ?? 0,
|
||||
type: GC.CARRIED
|
||||
}
|
||||
if (lastKnown.status !== GC.UP) {
|
||||
defaultData[ts].error_message = "Last known status applied"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Fix the NO_DATA-preference block to preserve the fill's type**
|
||||
|
||||
Still in the same file, the block at (previously) lines 141-156 hardcodes `type: GC.DEFAULT_STATUS` when realtime returns NO_DATA but a fill exists. A heartbeat monitor with `LAST_KNOWN` would mislabel its carried rows. Replace:
|
||||
|
||||
```typescript
|
||||
const defaultStatus = defaultData[ts]?.status
|
||||
const realtimeStatus = realtimeData[ts]?.status
|
||||
let realtimeDataForMerge = realtimeData
|
||||
if (defaultStatus && realtimeStatus === GC.NO_DATA) {
|
||||
// Apply the preference *before* merging so incident/maintenance can still override later.
|
||||
// Also avoid carrying over realtime NO_DATA error_message.
|
||||
realtimeDataForMerge = { ...realtimeData }
|
||||
realtimeDataForMerge[ts] = {
|
||||
...realtimeDataForMerge[ts],
|
||||
status: defaultStatus,
|
||||
type: GC.DEFAULT_STATUS
|
||||
}
|
||||
delete realtimeDataForMerge[ts].error_message
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
const defaultStatus = defaultData[ts]?.status
|
||||
const realtimeStatus = realtimeData[ts]?.status
|
||||
let realtimeDataForMerge = realtimeData
|
||||
if (defaultStatus && realtimeStatus === GC.NO_DATA) {
|
||||
// Apply the preference *before* merging so incident/maintenance can still override later.
|
||||
// Also avoid carrying over realtime NO_DATA error_message.
|
||||
// Keep the fill's own type: DEFAULT for fixed fill, CARRIED for last-known fill.
|
||||
realtimeDataForMerge = { ...realtimeData }
|
||||
realtimeDataForMerge[ts] = {
|
||||
...realtimeDataForMerge[ts],
|
||||
status: defaultStatus,
|
||||
type: defaultData[ts].type
|
||||
}
|
||||
delete realtimeDataForMerge[ts].error_message
|
||||
}
|
||||
```
|
||||
|
||||
Note: `latency` in this branch intentionally stays whatever realtime reported — unchanged from today for fixed fill; for LAST_KNOWN-on-heartbeat the carried latency was already placed in `defaultData[ts]` and `mergedData` spread order (`{ ...defaultData, ...realtimeDataForMerge, ... }`) means the realtime object wins the spread; this matches existing fixed-fill behavior, do not "improve" it here.
|
||||
|
||||
- [ ] **Step 3: Type-check**
|
||||
|
||||
Run: `npm run check`
|
||||
Expected: 0 new errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/server/queues/monitorExecuteQueue.ts
|
||||
git commit -m "feat(scheduler): write CARRIED samples for LAST_KNOWN default status fixes #721"
|
||||
```
|
||||
|
||||
(Live behavior is verified end-to-end in Task 8 — the worker needs Redis + the cron loop, so there is no isolated script for this task.)
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Normalization chokepoint for all monitor writes
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/lib/server/controllers/monitorsController.ts` (near `CreateUpdateMonitor`, line 193)
|
||||
- Modify: `src/routes/(api)/api/v4/monitors/+server.ts:104-121`
|
||||
- Modify: `src/routes/(api)/api/v4/monitors/[monitor_tag]/+server.ts:72-78`
|
||||
|
||||
- [ ] **Step 1: Add the helper to monitorsController.ts**
|
||||
|
||||
Directly above `CreateUpdateMonitor` (line 193), add:
|
||||
|
||||
```typescript
|
||||
const VALID_DEFAULT_STATUSES = ["NONE", GC.UP, GC.DOWN, GC.DEGRADED, GC.LAST_KNOWN] as const
|
||||
|
||||
/**
|
||||
* Enforce the closed default_status value set and the LAST_KNOWN scope rule
|
||||
* (docs/adr/0006): LAST_KNOWN is only meaningful on NONE-type (Manual) monitors;
|
||||
* on any other type it silently resets to UP so the invalid combination never persists.
|
||||
* Throws on values outside the closed set.
|
||||
*/
|
||||
export const NormalizeDefaultStatus = (monitorType: string | null | undefined, defaultStatus: string | null | undefined): string => {
|
||||
const value = defaultStatus ?? "NONE"
|
||||
if (!(VALID_DEFAULT_STATUSES as readonly string[]).includes(value)) {
|
||||
throw new Error(`default_status must be one of: ${VALID_DEFAULT_STATUSES.join(", ")}`)
|
||||
}
|
||||
if (value === GC.LAST_KNOWN && monitorType !== "NONE") {
|
||||
return GC.UP
|
||||
}
|
||||
return value
|
||||
}
|
||||
```
|
||||
|
||||
(`monitorsController.ts` already imports `GC` at line 25 — no import change needed.)
|
||||
|
||||
- [ ] **Step 2: Apply it in the manage-UI path**
|
||||
|
||||
Replace `CreateUpdateMonitor` (lines 193-201):
|
||||
|
||||
```typescript
|
||||
export const CreateUpdateMonitor = async (monitor: MonitorInput): Promise<number | number[]> => {
|
||||
let monitorData = { ...monitor }
|
||||
if (monitorData.id) {
|
||||
return await db.updateMonitor(monitorData as MonitorRecord)
|
||||
} else {
|
||||
validateMonitorTag(monitorData.tag)
|
||||
return await db.insertMonitor(monitorData)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
export const CreateUpdateMonitor = async (monitor: MonitorInput): Promise<number | number[]> => {
|
||||
let monitorData = { ...monitor }
|
||||
monitorData.default_status = NormalizeDefaultStatus(monitorData.monitor_type, monitorData.default_status)
|
||||
if (monitorData.id) {
|
||||
return await db.updateMonitor(monitorData as MonitorRecord)
|
||||
} else {
|
||||
validateMonitorTag(monitorData.tag)
|
||||
return await db.insertMonitor(monitorData)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(The manage API route at `src/routes/(manage)/manage/api/+server.ts` wraps the action switch in try/catch (line 660) and surfaces thrown `Error.message` — no route change needed.)
|
||||
|
||||
- [ ] **Step 3: Apply it in v4 POST**
|
||||
|
||||
In `src/routes/(api)/api/v4/monitors/+server.ts`, add to the imports from the monitors controller (`GetMonitorsParsed` is already imported — extend that import):
|
||||
|
||||
```typescript
|
||||
import { GetMonitorsParsed, NormalizeDefaultStatus } from "$lib/server/controllers/monitorsController"
|
||||
```
|
||||
|
||||
(match the existing import line's exact shape — if `GetMonitorsParsed` is imported from a different specifier, add `NormalizeDefaultStatus` to that same line).
|
||||
|
||||
Then replace line 111:
|
||||
|
||||
```typescript
|
||||
default_status: body.default_status ?? "UP",
|
||||
```
|
||||
|
||||
with a pre-validated variable. Above the `const monitorData = {` block (line 104), insert:
|
||||
|
||||
```typescript
|
||||
let defaultStatus: string
|
||||
try {
|
||||
defaultStatus = NormalizeDefaultStatus(body.monitor_type ?? "API", body.default_status ?? "UP")
|
||||
} catch (e) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: {
|
||||
code: "BAD_REQUEST",
|
||||
message: e instanceof Error ? e.message : "Invalid default_status"
|
||||
}
|
||||
}
|
||||
return json(errorResponse, { status: 400 })
|
||||
}
|
||||
```
|
||||
|
||||
and in `monitorData` use:
|
||||
|
||||
```typescript
|
||||
default_status: defaultStatus,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Apply it in v4 PATCH**
|
||||
|
||||
In `src/routes/(api)/api/v4/monitors/[monitor_tag]/+server.ts`, the handler resolves `updateData.monitor_type` at line 78 _after_ `updateData.default_status` at line 75 — the normalization must run after BOTH are resolved. Replace line 75:
|
||||
|
||||
```typescript
|
||||
updateData.default_status = body.default_status !== undefined ? body.default_status : existingMonitor.default_status
|
||||
```
|
||||
|
||||
with (keep it in place so field ordering stays readable, but move the value through the helper after line 78):
|
||||
|
||||
```typescript
|
||||
updateData.default_status = body.default_status !== undefined ? body.default_status : existingMonitor.default_status
|
||||
```
|
||||
|
||||
…and after line 78 (`updateData.monitor_type = ...`), insert:
|
||||
|
||||
```typescript
|
||||
// Closed-set validation + LAST_KNOWN scope rule (docs/adr/0006). Runs after monitor_type
|
||||
// is resolved so a type change away from NONE auto-resets LAST_KNOWN to UP.
|
||||
try {
|
||||
updateData.default_status = NormalizeDefaultStatus(updateData.monitor_type as string, updateData.default_status as string | null)
|
||||
} catch (e) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: {
|
||||
code: "BAD_REQUEST",
|
||||
message: e instanceof Error ? e.message : "Invalid default_status"
|
||||
}
|
||||
}
|
||||
return json(errorResponse, { status: 400 })
|
||||
}
|
||||
```
|
||||
|
||||
Add `NormalizeDefaultStatus` to this file's monitors-controller import the same way as in Step 3.
|
||||
|
||||
- [ ] **Step 5: Verify with a throwaway script**
|
||||
|
||||
Create `scripts/tmp-verify-normalize.ts`:
|
||||
|
||||
```typescript
|
||||
import { NormalizeDefaultStatus } from "../src/lib/server/controllers/monitorsController"
|
||||
|
||||
const cases: Array<[string, string | null, string]> = [
|
||||
["NONE", "LAST_KNOWN", "LAST_KNOWN"], // allowed on Manual monitors
|
||||
["API", "LAST_KNOWN", "UP"], // auto-reset on any other type
|
||||
["NONE", null, "NONE"], // null → NONE
|
||||
["API", "DOWN", "DOWN"] // fixed values pass through
|
||||
]
|
||||
for (const [type, input, expected] of cases) {
|
||||
const got = NormalizeDefaultStatus(type, input)
|
||||
if (got !== expected) throw new Error(`FAIL: (${type}, ${input}) → ${got}, expected ${expected}`)
|
||||
}
|
||||
let threw = false
|
||||
try {
|
||||
NormalizeDefaultStatus("API", "MAINTENANCE")
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
if (!threw) throw new Error("FAIL: MAINTENANCE must be rejected")
|
||||
console.log("PASS")
|
||||
```
|
||||
|
||||
Run: `npx vite-node scripts/tmp-verify-normalize.ts`
|
||||
Expected: `PASS`. (Importing monitorsController transitively pulls in the db singleton; vite-node loads the repo `.env` automatically, so the configured `DATABASE_URL`/`REDIS_URL` satisfy it. If module side-effects still fail outside the dev process, inline the `NormalizeDefaultStatus` cases into a temporary copy instead — the function is pure.)
|
||||
|
||||
- [ ] **Step 6: Delete script, type-check, commit**
|
||||
|
||||
```bash
|
||||
rm scripts/tmp-verify-normalize.ts
|
||||
npm run check
|
||||
git add src/lib/server/controllers/monitorsController.ts "src/routes/(api)/api/v4/monitors/+server.ts" "src/routes/(api)/api/v4/monitors/[monitor_tag]/+server.ts"
|
||||
git commit -m "feat(api): enforce closed default_status set with LAST_KNOWN scope rule"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Migration — normalize legacy default_status values
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `migrations/20260607150000_normalize_default_status.ts`
|
||||
|
||||
- [ ] **Step 1: Write the migration**
|
||||
|
||||
```typescript
|
||||
import type { Knex } from "knex"
|
||||
|
||||
// Closed default_status set as of docs/adr/0006. MAINTENANCE was offered by the old
|
||||
// UI but never honored by the fill engine — it behaved exactly like "no fill", so it
|
||||
// (and any other unknown value, and NULL) normalizes to NONE, preserving behavior.
|
||||
const VALID = ["NONE", "UP", "DOWN", "DEGRADED", "LAST_KNOWN"]
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex("monitors").whereNull("default_status").update({ default_status: "NONE" })
|
||||
await knex("monitors").whereNotIn("default_status", VALID).update({ default_status: "NONE" })
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// Irreversible by design: the values rewritten to NONE were dead (never honored
|
||||
// by the fill engine), so there is nothing meaningful to restore.
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it against the dev database**
|
||||
|
||||
Run: `npm run migrate`
|
||||
Expected: `Batch N run: 1 migrations` with no errors.
|
||||
|
||||
- [ ] **Step 3: Spot-check via the dev API**
|
||||
|
||||
With the dev server running (`npm run dev` if not already):
|
||||
|
||||
```bash
|
||||
curl -s 'http://localhost:3000/api/v4/monitors' -H 'Authorization: Bearer <API_KEY>' | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const r=JSON.parse(s);const bad=(r.monitors||r.data||[]).filter(m=>!['NONE','UP','DOWN','DEGRADED','LAST_KNOWN'].includes(m.default_status));console.log('invalid default_status rows:',bad.length)})"
|
||||
```
|
||||
|
||||
Expected: `invalid default_status rows: 0`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add migrations/20260607150000_normalize_default_status.ts
|
||||
git commit -m "feat(db): migrate default_status to closed value set"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Manage UI — dropdown options + callout + auto-reset
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/routes/(manage)/manage/app/monitors/[tag]/components/GeneralSettingsCard.svelte:230-247`
|
||||
|
||||
**REQUIRED SUB-SKILL for this task: `svelte-code-writer` (per CLAUDE.md, mandatory for all .svelte edits).**
|
||||
|
||||
- [ ] **Step 1: Add imports and labels**
|
||||
|
||||
In the `<script lang="ts">` block (GC is already imported at line 20), add to the imports:
|
||||
|
||||
```typescript
|
||||
import * as Alert from "$lib/components/ui/alert/index.js"
|
||||
import TriangleAlertIcon from "@lucide/svelte/icons/triangle-alert"
|
||||
```
|
||||
|
||||
and below the props destructuring add:
|
||||
|
||||
```typescript
|
||||
const defaultStatusLabels: Record<string, string> = {
|
||||
NONE: "None (show gaps as no data)",
|
||||
UP: "UP",
|
||||
DOWN: "DOWN",
|
||||
DEGRADED: "DEGRADED",
|
||||
LAST_KNOWN: "Last known status"
|
||||
}
|
||||
|
||||
// LAST_KNOWN is only valid on Manual (NONE-type) monitors; the server enforces the
|
||||
// same rule (NormalizeDefaultStatus), this effect just keeps the UI honest live.
|
||||
$effect(() => {
|
||||
if (monitor.monitor_type !== "NONE" && monitor.default_status === GC.LAST_KNOWN) {
|
||||
monitor.default_status = GC.UP
|
||||
toast.info("Default status was reset to UP — Last known status is only available for Manual monitors.")
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
(`toast` is already imported from `svelte-sonner` at line 16.)
|
||||
|
||||
- [ ] **Step 2: Replace the Default Status select**
|
||||
|
||||
Replace lines 230-247:
|
||||
|
||||
```svelte
|
||||
<Label for="monitor-default-status">Default Status</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={monitor.default_status}
|
||||
onValueChange={(v) => {
|
||||
if (v) monitor.default_status = v
|
||||
}}
|
||||
>
|
||||
<Select.Trigger id="monitor-default-status" class="w-full">
|
||||
{monitor.default_status}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="UP">UP</Select.Item>
|
||||
<Select.Item value="DOWN">DOWN</Select.Item>
|
||||
<Select.Item value="DEGRADED">DEGRADED</Select.Item>
|
||||
<Select.Item value="MAINTENANCE">MAINTENANCE</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```svelte
|
||||
<Label for="monitor-default-status">Default Status</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={monitor.default_status ?? "NONE"}
|
||||
onValueChange={(v) => {
|
||||
if (v) monitor.default_status = v
|
||||
}}
|
||||
>
|
||||
<Select.Trigger id="monitor-default-status" class="w-full">
|
||||
{defaultStatusLabels[monitor.default_status ?? "NONE"] ?? monitor.default_status}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="NONE">None (show gaps as no data)</Select.Item>
|
||||
<Select.Item value="UP">UP</Select.Item>
|
||||
<Select.Item value="DOWN">DOWN</Select.Item>
|
||||
<Select.Item value="DEGRADED">DEGRADED</Select.Item>
|
||||
{#if monitor.monitor_type === "NONE"}
|
||||
<Select.Item value="LAST_KNOWN">Last known status</Select.Item>
|
||||
{/if}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{#if monitor.default_status === GC.LAST_KNOWN}
|
||||
<Alert.Root>
|
||||
<TriangleAlertIcon />
|
||||
<Alert.Title>Last known status</Alert.Title>
|
||||
<Alert.Description>
|
||||
<p>Kener will repeat the most recent status and latency every minute until your integration sends new data.</p>
|
||||
<ul class="list-disc pl-4">
|
||||
<li>
|
||||
If your integration stops sending, the page keeps showing the last status indefinitely — Kener cannot tell "still up" from "stopped reporting". Use a Heartbeat
|
||||
monitor to catch a silent integration.
|
||||
</li>
|
||||
<li>
|
||||
Carried minutes count toward alert thresholds: a single DOWN push will trigger alerts after your failure threshold, and they stay triggered until you push a
|
||||
recovery.
|
||||
</li>
|
||||
</ul>
|
||||
</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the svelte autofixer / check**
|
||||
|
||||
Run: `npm run check`
|
||||
Expected: 0 new errors or warnings for `GeneralSettingsCard.svelte`. Also run the svelte MCP autofixer on the component if the svelte-code-writer skill instructs it.
|
||||
|
||||
- [ ] **Step 4: Visual verification**
|
||||
|
||||
With `npm run dev` running, screenshot the editor for the seeded NONE-type monitor (`earth`):
|
||||
|
||||
```bash
|
||||
npx playwright screenshot --channel chrome --color-scheme light --viewport-size "1440,900" --full-page --wait-for-timeout 3000 http://localhost:3000/manage/app/monitors/earth /tmp/lk-ui.png
|
||||
```
|
||||
|
||||
(Authed page — if it renders the login screen, follow the storage-state cookie recipe in the project memory `kener-ui-verification-recipe`, or verify manually in the browser.)
|
||||
Expected: dropdown shows the five options ("Last known status" present because earth is Manual/NONE type, "MAINTENANCE" gone); selecting "Last known status" reveals the callout.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add "src/routes/(manage)/manage/app/monitors/[tag]/components/GeneralSettingsCard.svelte"
|
||||
git commit -m "feat(manage): Last known status option with callout in Default Status dropdown"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Docs — ADR cross-link + user documentation
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/adr/0005-alerts-evaluate-alert-visible-samples.md` (append one sentence)
|
||||
- Modify: `src/routes/(docs)/docs/content/v4/monitors/overview.md` (Default Status section)
|
||||
|
||||
**REQUIRED SUB-SKILL for the `src/routes/(docs)/docs/content/` edit: `documentation-writer` (per CLAUDE.md, mandatory for docs content).**
|
||||
|
||||
- [ ] **Step 1: Amend ADR 0005**
|
||||
|
||||
Append this sentence to the end of the first paragraph of `docs/adr/0005-alerts-evaluate-alert-visible-samples.md` (after "...remain invisible to alerting."):
|
||||
|
||||
```
|
||||
Amended by ADR 0006: last-known-status fill (`CARRIED`) later joined the alert-visible set under the same invariant.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Document Last Known Status in the monitor docs**
|
||||
|
||||
`src/routes/(docs)/docs/content/v4/monitors/overview.md` does not mention `default_status` today (verified) — add a new "Default Status" section to it. Following the documentation-writer skill's conventions, document:
|
||||
|
||||
- The five values: `NONE`, `UP`, `DOWN`, `DEGRADED`, `LAST_KNOWN`.
|
||||
- `LAST_KNOWN` is only accepted for Manual (`NONE`-type) monitors; on any other type the API resets it to `UP`.
|
||||
- Behavior: every minute without new data, Kener writes a `CARRIED` sample repeating the most recent alert-visible sample (status and latency). Carry never expires and starts at the next tick after the setting is saved (no backfill).
|
||||
- The two warnings from the UI callout (stale-forever if the integration goes silent → use a Heartbeat monitor; carried minutes count toward alert thresholds and alerts only resolve on a pushed recovery).
|
||||
- A curl example mirroring #721's flow:
|
||||
|
||||
```bash
|
||||
curl -X PATCH 'https://status.example.com/api/v4/monitors/my-service/data/{current_unix_minute}' \
|
||||
-H 'Authorization: Bearer <api-key>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"status": "DOWN", "latency": 100}'
|
||||
# With Default Status = Last known status, the monitor stays DOWN until you push UP.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/adr/0005-alerts-evaluate-alert-visible-samples.md "src/routes/(docs)/docs/content/v4/monitors/overview.md"
|
||||
git commit -m "docs: document Last known status default and amend ADR 0005"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: End-to-end verification against the dev server
|
||||
|
||||
**Files:** none (verification only; uses the running `npm run dev` with Redis + Postgres up)
|
||||
|
||||
- [ ] **Step 1: Create a throwaway NONE monitor with LAST_KNOWN**
|
||||
|
||||
```bash
|
||||
curl -s -X POST 'http://localhost:3000/api/v4/monitors' \
|
||||
-H 'Authorization: Bearer <API_KEY>' -H 'Content-Type: application/json' \
|
||||
--data '{"tag":"lk-e2e","name":"LK E2E","monitor_type":"NONE","default_status":"LAST_KNOWN","cron":"* * * * *"}'
|
||||
```
|
||||
|
||||
Expected: 201, monitor JSON with `"default_status":"LAST_KNOWN"`.
|
||||
|
||||
- [ ] **Step 2: Confirm no fill before any push**
|
||||
|
||||
Wait ~70 seconds (one scheduler tick), then:
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +%s); curl -s "http://localhost:3000/api/v4/monitors/lk-e2e/data?start_ts=$((NOW-300))&end_ts=$NOW" -H 'Authorization: Bearer <API_KEY>'
|
||||
```
|
||||
|
||||
Expected: `{"data":[]}` — nothing to carry yet (never-pushed monitors stay no-data).
|
||||
|
||||
- [ ] **Step 3: Push DOWN once, watch CARRIED rows appear**
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +%s)
|
||||
curl -s -X PATCH "http://localhost:3000/api/v4/monitors/lk-e2e/data/$NOW" \
|
||||
-H 'Authorization: Bearer <API_KEY>' -H 'Content-Type: application/json' \
|
||||
--data '{"status":"DOWN","latency":2201}'
|
||||
```
|
||||
|
||||
Wait ~130 seconds (two ticks), then re-run the Step 2 range query.
|
||||
Expected: one `"type":"MANUAL"` DOWN row at the pushed minute, followed by `"type":"CARRIED"` rows with `"status":"DOWN","latency":2201` for each subsequent minute.
|
||||
|
||||
- [ ] **Step 4: Push recovery, confirm carry follows**
|
||||
|
||||
Repeat Step 3's PATCH with `{"status":"UP","latency":5}`. Wait ~70s.
|
||||
Expected: subsequent CARRIED rows are `UP` with latency 5. The status page (`http://localhost:3000`) shows the monitor UP with the red DOWN window in today's bar.
|
||||
|
||||
- [ ] **Step 5: Verify the type-change auto-reset**
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH 'http://localhost:3000/api/v4/monitors/lk-e2e' \
|
||||
-H 'Authorization: Bearer <API_KEY>' -H 'Content-Type: application/json' \
|
||||
--data '{"monitor_type":"API","type_data":{"url":"https://example.com","timeout":5000}}'
|
||||
```
|
||||
|
||||
Expected: 200 with `"default_status":"UP"` in the response (LAST_KNOWN auto-reset because the type left NONE). Then verify rejection:
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH 'http://localhost:3000/api/v4/monitors/lk-e2e' \
|
||||
-H 'Authorization: Bearer <API_KEY>' -H 'Content-Type: application/json' \
|
||||
--data '{"default_status":"MAINTENANCE"}'
|
||||
```
|
||||
|
||||
Expected: 400 with `default_status must be one of: NONE, UP, DOWN, DEGRADED, LAST_KNOWN`.
|
||||
|
||||
- [ ] **Step 6: Clean up the test monitor**
|
||||
|
||||
The v4 monitor route has no DELETE handler (only GET/PATCH), so delete via the manage API action the dashboard uses, or from the UI at `http://localhost:3000/manage/app/monitors/lk-e2e` (Danger Zone → Delete). Verify it is gone from `http://localhost:3000/`.
|
||||
|
||||
- [ ] **Step 7: Final gate**
|
||||
|
||||
Run: `npm run check && npm run prettify`
|
||||
Expected: clean check; prettify produces no diff beyond the files already touched (re-commit formatting if it does).
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- **Spec coverage:** Q1 dropdown (Task 6), Q2 MAINTENANCE migration + closed set (Tasks 4, 5, 6), Q3 carry source (Task 2), Q4 CARRIED type + whitelist (Task 1), Q5 status+latency payload (Task 3), Q6/Q7 NONE-only + auto-reset (Tasks 4, 6), Q8 no expiry (no code — absence is the feature; documented in Task 7), Q9 tick-forward/no-backfill (no code — the engine only writes at `ts`; verified in Task 8 Step 2-3), Q10 alert consequences (Task 1 whitelist + existing evaluator, no further code), Q11 callout copy (Task 6).
|
||||
- **Known non-goals:** no staleness cap, no backfill, no purge on disable, no change to `CloneMonitor` (it copies `monitor_type` + `default_status` together from an already-normalized source, so the pair stays valid).
|
||||
- **Pre-existing race left untouched (deliberate):** a same-minute tick can overwrite a just-pushed MANUAL row's type via the response-queue upsert; this exists today for DEFAULT fill and is orthogonal to this change.
|
||||
+47
-3
@@ -7,13 +7,45 @@ const databaseURLParts = databaseURL.split("://");
|
||||
const databaseType = databaseURLParts[0];
|
||||
const databasePath = databaseURLParts[1];
|
||||
|
||||
const intFromEnv = (name: string, fallback: number): number => {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined) return fallback;
|
||||
const parsed = parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
};
|
||||
|
||||
// TCP keepalive on pooled connections, on by default. Cloud networks (Railway,
|
||||
// Docker Swarm overlays, k8s) silently drop idle TCP connections; without
|
||||
// keepalive the pool keeps handing out dead sockets after an idle period or a
|
||||
// database restart. See docs/adr/0003-fail-fast-self-healing-db-pool.md.
|
||||
const keepAliveEnabled = process.env.DATABASE_KEEPALIVE !== "false";
|
||||
|
||||
// Pool defaults deviate from knex's on purpose:
|
||||
// - min 0: knex's min 2 connections are never reaped, so they are exactly the
|
||||
// ones that go stale and wedge the app until a manual restart
|
||||
// - 15s acquire/create timeouts: fail fast instead of hanging requests for
|
||||
// knex's default 60s during a database outage
|
||||
// Tarn requires max >= 1 and min <= max; clamp so a bad env value can not
|
||||
// produce a pool that fails every acquire
|
||||
const poolMax = Math.max(1, intFromEnv("DATABASE_POOL_MAX", 10));
|
||||
const poolMin = Math.min(intFromEnv("DATABASE_POOL_MIN", 0), poolMax);
|
||||
const pool = {
|
||||
min: poolMin,
|
||||
max: poolMax,
|
||||
idleTimeoutMillis: intFromEnv("DATABASE_IDLE_TIMEOUT_MS", 30000),
|
||||
createTimeoutMillis: intFromEnv("DATABASE_CREATE_TIMEOUT_MS", 15000),
|
||||
};
|
||||
const acquireConnectionTimeout = intFromEnv("DATABASE_ACQUIRE_TIMEOUT_MS", 15000);
|
||||
|
||||
interface KnexConfig {
|
||||
migrations: { directory: string };
|
||||
seeds: { directory: string };
|
||||
databaseType: string;
|
||||
client?: string;
|
||||
connection?: string | { filename: string };
|
||||
connection?: string | { filename: string } | Record<string, unknown>;
|
||||
useNullAsDefault?: boolean;
|
||||
pool?: typeof pool;
|
||||
acquireConnectionTimeout?: number;
|
||||
}
|
||||
|
||||
const knexOb: KnexConfig = {
|
||||
@@ -25,6 +57,7 @@ const knexOb: KnexConfig = {
|
||||
},
|
||||
databaseType,
|
||||
};
|
||||
console.log(`Configuring database with type ${databaseType}`);
|
||||
if (databaseType === "sqlite") {
|
||||
knexOb.client = "better-sqlite3";
|
||||
knexOb.connection = {
|
||||
@@ -33,10 +66,21 @@ if (databaseType === "sqlite") {
|
||||
knexOb.useNullAsDefault = true;
|
||||
} else if (databaseType === "postgresql") {
|
||||
knexOb.client = "pg";
|
||||
knexOb.connection = databaseURL;
|
||||
knexOb.connection = {
|
||||
connectionString: databaseURL,
|
||||
keepAlive: keepAliveEnabled,
|
||||
};
|
||||
knexOb.pool = pool;
|
||||
knexOb.acquireConnectionTimeout = acquireConnectionTimeout;
|
||||
} else if (databaseType === "mysql") {
|
||||
knexOb.client = "mysql2";
|
||||
knexOb.connection = databaseURL;
|
||||
knexOb.connection = {
|
||||
uri: databaseURL,
|
||||
enableKeepAlive: keepAliveEnabled,
|
||||
keepAliveInitialDelay: 10000,
|
||||
};
|
||||
knexOb.pool = pool;
|
||||
knexOb.acquireConnectionTimeout = acquireConnectionTimeout;
|
||||
} else {
|
||||
console.error("Invalid database type");
|
||||
process.exit(1);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// 1. Roles table
|
||||
if (!(await knex.schema.hasTable("roles"))) {
|
||||
await knex.schema.createTable("roles", (table) => {
|
||||
table.string("id", 100).primary();
|
||||
table.text("role_name").notNullable();
|
||||
table.integer("readonly").notNullable().defaultTo(0);
|
||||
table.string("status", 20).notNullable().defaultTo("ACTIVE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Permissions table
|
||||
if (!(await knex.schema.hasTable("permissions"))) {
|
||||
await knex.schema.createTable("permissions", (table) => {
|
||||
table.string("id", 100).primary();
|
||||
table.text("permission_name").notNullable();
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Roles ↔ Permissions junction table
|
||||
if (!(await knex.schema.hasTable("roles_permissions"))) {
|
||||
await knex.schema.createTable("roles_permissions", (table) => {
|
||||
table.string("roles_id", 100).notNullable().references("id").inTable("roles").onDelete("CASCADE");
|
||||
table.string("permissions_id", 100).notNullable().references("id").inTable("permissions").onDelete("CASCADE");
|
||||
table.string("status", 20).notNullable().defaultTo("ACTIVE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
table.primary(["roles_id", "permissions_id"]);
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Users ↔ Roles junction table
|
||||
if (!(await knex.schema.hasTable("users_roles"))) {
|
||||
await knex.schema.createTable("users_roles", (table) => {
|
||||
table.string("roles_id", 100).notNullable().references("id").inTable("roles").onDelete("CASCADE");
|
||||
table.integer("users_id").unsigned().notNullable().references("id").inTable("users").onDelete("CASCADE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
table.primary(["roles_id", "users_id"]);
|
||||
table.index("users_id", "idx_users_roles_users_id");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists("users_roles");
|
||||
await knex.schema.dropTableIfExists("roles_permissions");
|
||||
await knex.schema.dropTableIfExists("permissions");
|
||||
await knex.schema.dropTableIfExists("roles");
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
// Maps the legacy users.role string to the new roles.id value.
|
||||
// The old default was "user"; everything unmapped falls back to "member".
|
||||
const ROLE_MAP: Record<string, string> = {
|
||||
admin: "admin",
|
||||
editor: "editor",
|
||||
member: "member",
|
||||
user: "member",
|
||||
};
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn("users", "role");
|
||||
if (!hasColumn) return;
|
||||
|
||||
// 1. Ensure the three target roles exist so FK inserts succeed.
|
||||
// Seeds will reconcile permissions later; we only need the rows.
|
||||
const rolesToEnsure = [
|
||||
{ id: "admin", role_name: "Administrator" },
|
||||
{ id: "editor", role_name: "Editor" },
|
||||
{ id: "member", role_name: "Member" },
|
||||
];
|
||||
for (const role of rolesToEnsure) {
|
||||
const exists = await knex("roles").where("id", role.id).first();
|
||||
if (!exists) {
|
||||
await knex("roles").insert({
|
||||
id: role.id,
|
||||
role_name: role.role_name,
|
||||
readonly: 1,
|
||||
status: "ACTIVE",
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Read users.role into memory BEFORE dropping the column.
|
||||
// On SQLite, dropColumn recreates the table (create → copy → drop → rename),
|
||||
// which can discard DML inserts to tables with FKs pointing at users.
|
||||
const users: Array<{ id: number; role: string }> = await knex("users").select("id", "role");
|
||||
|
||||
// 3. Drop the column first.
|
||||
await knex.schema.alterTable("users", (table) => {
|
||||
table.dropColumn("role");
|
||||
});
|
||||
|
||||
// 4. Now populate users_roles from the in-memory snapshot.
|
||||
for (const user of users) {
|
||||
const newRoleId = ROLE_MAP[user.role] ?? "member";
|
||||
|
||||
const alreadyAssigned = await knex("users_roles").where({ roles_id: newRoleId, users_id: user.id }).first();
|
||||
|
||||
if (!alreadyAssigned) {
|
||||
await knex("users_roles").insert({
|
||||
roles_id: newRoleId,
|
||||
users_id: user.id,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse map: pick the highest-precedence role when backfilling.
|
||||
const REVERSE_ROLE_PRECEDENCE: string[] = ["admin", "editor", "member"];
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn("users", "role");
|
||||
if (!hasColumn) {
|
||||
await knex.schema.alterTable("users", (table) => {
|
||||
table.string("role").defaultTo("member");
|
||||
});
|
||||
}
|
||||
|
||||
// Backfill users.role from users_roles using deterministic precedence
|
||||
const assignments: Array<{ users_id: number; roles_id: string }> = await knex("users_roles").select(
|
||||
"users_id",
|
||||
"roles_id",
|
||||
);
|
||||
|
||||
// Group roles by user
|
||||
const userRolesMap = new Map<number, string[]>();
|
||||
for (const row of assignments) {
|
||||
const list = userRolesMap.get(row.users_id) || [];
|
||||
list.push(row.roles_id);
|
||||
userRolesMap.set(row.users_id, list);
|
||||
}
|
||||
|
||||
// Pick highest-precedence role for each user
|
||||
for (const [userId, roleIds] of userRolesMap) {
|
||||
const bestRole = REVERSE_ROLE_PRECEDENCE.find((r) => roleIds.includes(r)) || roleIds[0] || "member";
|
||||
await knex("users").where("id", userId).update({ role: bestRole });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
// Closed default_status set as of docs/adr/0006. MAINTENANCE was offered by the old
|
||||
// UI but never honored by the fill engine — it behaved exactly like "no fill", so it
|
||||
// (and any other unknown value, and NULL) normalizes to NONE, preserving behavior.
|
||||
const VALID = ["NONE", "UP", "DOWN", "DEGRADED", "LAST_KNOWN"];
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex("monitors").whereNull("default_status").update({ default_status: "NONE" });
|
||||
await knex("monitors").whereNotIn("default_status", VALID).update({ default_status: "NONE" });
|
||||
}
|
||||
|
||||
export async function down(_knex: Knex): Promise<void> {
|
||||
// Irreversible by design: the values rewritten to NONE were dead (never honored
|
||||
// by the fill engine), so there is nothing meaningful to restore.
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "kener",
|
||||
"version": "4.0.21",
|
||||
"version": "4.0.23",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kener",
|
||||
"version": "4.0.21",
|
||||
"version": "4.0.23",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kener",
|
||||
"version": "4.0.21",
|
||||
"version": "4.0.23",
|
||||
"type": "module",
|
||||
"private": false,
|
||||
"license": "MIT",
|
||||
|
||||
+110
-70
@@ -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();
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -41,6 +41,8 @@ export default {
|
||||
MANUAL: "MANUAL",
|
||||
WEBHOOK: "WEBHOOK",
|
||||
DEFAULT_STATUS: "DEFAULT",
|
||||
CARRIED: "CARRIED",
|
||||
LAST_KNOWN: "LAST_KNOWN",
|
||||
SIGNAL: "SIGNAL",
|
||||
INVITE_VERIFY_EMAIL: "invite_verify_email",
|
||||
ERROR_NO_SETUP: "Set up not done yet. Create a user first.",
|
||||
@@ -69,6 +71,18 @@ export default {
|
||||
STATUS: "STATUS",
|
||||
LATENCY: "LATENCY",
|
||||
UPTIME: "UPTIME",
|
||||
// Special path segment addressing the home page in the v4 API; its stored
|
||||
// page_path is an empty string. See docs/adr/0004-home-page-api-token.md.
|
||||
HOME_PAGE_TOKEN: "~home",
|
||||
// Status history window (days of per-day status shown), shared by pages and
|
||||
// monitors, the manage UI, the public pages, and the v4 API
|
||||
DEFAULT_STATUS_HISTORY_DAYS_DESKTOP: 90,
|
||||
DEFAULT_STATUS_HISTORY_DAYS_MOBILE: 30,
|
||||
STATUS_HISTORY_DAYS_MIN: 1,
|
||||
STATUS_HISTORY_DAYS_MAX: 365,
|
||||
// Monitor layout styles available on status pages
|
||||
MONITOR_LAYOUT_STYLES: ["default-list", "default-grid", "compact-list", "compact-grid"],
|
||||
DEFAULT_MONITOR_LAYOUT_STYLE: "default-list",
|
||||
DOCS_URL: "https://kener.ing/docs",
|
||||
MAX_UPLOAD_BYTES: 2 * 1024 * 1024, // 2MB
|
||||
MAX_IMAGE_DIMENSION: 4096,
|
||||
|
||||
+22
-22
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"name": "Українська",
|
||||
"code": "uk",
|
||||
"mappings": {
|
||||
"%latency %metric latency": "%latency %metric latency",
|
||||
"Affected Monitors (%count)": "Затронуті монітори (%count)",
|
||||
"All Systems Operational": "Усі системи працюють",
|
||||
"Average Latency": "Середня затримка",
|
||||
"Avg Latency": "Сер. затримка",
|
||||
"Back": "Назад",
|
||||
"Badges": "Бейджі",
|
||||
"CANCELLED": "СКАСОВАНО",
|
||||
"COMPLETED": "ЗАВЕРШЕНО",
|
||||
"Continue": "Продовжити",
|
||||
"Copied": "Скопійовано",
|
||||
"Current": "Поточні",
|
||||
"Dark": "Темна",
|
||||
"Day": "День",
|
||||
"Day Uptime": "Час роботи за день",
|
||||
"Days": "Дні",
|
||||
"Degraded": "Погіршення",
|
||||
"DEGRADED": "ПОГІРШЕННЯ",
|
||||
"Degraded Performance": "Зниження продуктивності",
|
||||
"Didn't receive the code? Resend": "Не отримали код? Надіслати повторно",
|
||||
"Down": "Недоступний",
|
||||
"DOWN": "НЕ ПРАЦЮЄ",
|
||||
"Duration": "Тривалість",
|
||||
"Email address": "Адреса електронної пошти",
|
||||
"Embed Monitor": "Вбудувати монітор",
|
||||
"Embed this monitor in your website or app": "Вбудуйте цей монітор у свій сайт або застосунок",
|
||||
"End Time": "Час завершення",
|
||||
"Enter the verification code sent to your email.": "Введіть код підтвердження, надісланий на вашу пошту.",
|
||||
"Events": "Події",
|
||||
"Failed to load data": "Не вдалося завантажити дані",
|
||||
"Failed to load latency data": "Не вдалося завантажити дані затримки",
|
||||
"Failed to load status data for this day": "Не вдалося завантажити дані статусу за цей день",
|
||||
"Failed to send verification code": "Не вдалося надіслати код підтвердження",
|
||||
"Failed to update preference": "Не вдалося оновити налаштування",
|
||||
"Get badges for this monitor": "Отримати бейджі для цього монітора",
|
||||
"Get notified about incidents and scheduled maintenance.": "Отримуйте сповіщення про інциденти та планове обслуговування",
|
||||
"Get notified about incidents updates": "Отримуйте сповіщення про оновлення інцидентів",
|
||||
"Get notified about scheduled maintenance": "Отримуйте сповіщення про планове обслуговування",
|
||||
"IDENTIFIED": "ВИЗНАЧЕНО",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Вплив",
|
||||
"incident": "інцидент",
|
||||
"Incident Updates": "Оновлення інцидентів",
|
||||
"Incidents": "Інциденти",
|
||||
"Included Monitors (%count)": "Включені монітори (%count)",
|
||||
"INVESTIGATING": "ДОСЛІДЖЕННЯ",
|
||||
"Last Updated": "Останнє оновлення",
|
||||
"Latency": "Затримка",
|
||||
"Latency Embed": "Вбудована затримка",
|
||||
"Latency Over Time": "Затримка з часом",
|
||||
"Latency Trend": "Тренд затримки",
|
||||
"Latest Latency": "Остання затримка",
|
||||
"Latest Status": "Останній статус",
|
||||
"Light": "Світла",
|
||||
"Live Status": "Статус у реальному часі",
|
||||
"Loading your preferences...": "Завантаження налаштувань...",
|
||||
"maintenance": "обслуговування",
|
||||
"MAINTENANCE": "ОБСЛУГОВУВАННЯ",
|
||||
"Maintenance Updates": "Оновлення обслуговування",
|
||||
"Maintenances": "Обслуговування",
|
||||
"Major System Outage": "Критичний збій системи",
|
||||
"Manage Site": "Керування сайтом",
|
||||
"Manage your notification preferences.": "Керуйте налаштуваннями сповіщень",
|
||||
"Max Latency": "Макс. затримка",
|
||||
"Maximum Latency": "Максимальна затримка",
|
||||
"Min Latency": "Мін. затримка",
|
||||
"Minimum Latency": "Мінімальна затримка",
|
||||
"Minute-by-minute status data for this day": "Похвилинні дані статусу за цей день",
|
||||
"MONITORING": "МОНІТОРИНГ",
|
||||
"Network error. Please try again.": "Помилка мережі. Спробуйте ще раз",
|
||||
"No Events in %currentMonth": "Немає подій у %currentMonth",
|
||||
"No events to show": "Немає подій для відображення",
|
||||
"No incidents for this day": "Немає інцидентів за цей день",
|
||||
"No latency data available for this day": "Немає даних про затримку за цей день",
|
||||
"No maintenances for this day": "Немає обслуговування за цей день",
|
||||
"No monitors affected": "Жоден монітор не зачеплений",
|
||||
"No monitors available.": "Немає доступних моніторів",
|
||||
"No ongoing maintenances": "Немає поточного обслуговування",
|
||||
"No past maintenances": "Немає минулого обслуговування",
|
||||
"No Status Available": "Статус недоступний",
|
||||
"No upcoming maintenances": "Немає запланованого обслуговування",
|
||||
"No Updates": "Немає оновлень",
|
||||
"No updates yet": "Оновлень поки немає",
|
||||
"Notifications": "Сповіщення",
|
||||
"One-time": "Одноразове",
|
||||
"Ongoing": "Поточні",
|
||||
"Operational": "Працює",
|
||||
"Partial Degraded Performance": "Часткове зниження продуктивності",
|
||||
"Partial System Outage": "Частковий збій системи",
|
||||
"Past": "Минулі",
|
||||
"Per-Minute Status": "Похвилинний статус",
|
||||
"Pinging": "Перевірка доступності",
|
||||
"Please enter a valid email address": "Будь ласка, введіть дійсну електронну адресу",
|
||||
"Please enter the 6-digit verification code": "Будь ласка, введіть 6-значний код підтвердження",
|
||||
"Read less": "Згорнути",
|
||||
"Read more": "Читати більше",
|
||||
"READY": "ГОТОВО",
|
||||
"Recurring": "Повторюване",
|
||||
"RESOLVED": "ВИРІШЕНО",
|
||||
"SCHEDULED": "ЗАПЛАНОВАНО",
|
||||
"Scheduled Events (%count)": "Заплановані події (%count)",
|
||||
"Script": "Скрипт",
|
||||
"Select Language": "Оберіть мову",
|
||||
"Select latency metric to display": "Оберіть метрику затримки для відображення",
|
||||
"Select Range": "Оберіть діапазон",
|
||||
"Sending...": "Надсилання...",
|
||||
"Standard": "Стандартний",
|
||||
"Start Time": "Час початку",
|
||||
"Status": "Статус",
|
||||
"Status Badge": "Бейдж статусу",
|
||||
"Status Embed": "Вбудований статус",
|
||||
"Status history and latency trend": "Історія статусів та тренд затримки",
|
||||
"Subscribe": "Підписатися",
|
||||
"Subscribe to Updates": "Підписатися на оновлення",
|
||||
"There are no incidents or maintenances scheduled for this month.": "На цей місяць не заплановано інцидентів або обслуговування",
|
||||
"There are no ongoing incidents or maintenance events.": "Наразі немає активних інцидентів або обслуговування",
|
||||
"Total Incidents": "Загальна кількість інцидентів",
|
||||
"Total Maintenances": "Загальна кількість обслуговувань",
|
||||
"Under Maintenance": "На обслуговуванні",
|
||||
"Unknown impact": "Невідомий вплив",
|
||||
"UP": "ПРАЦЮЄ",
|
||||
"Upcoming": "Майбутні",
|
||||
"Update Incident": "Оновити інцидент",
|
||||
"Update Maintenance": "Оновити обслуговування",
|
||||
"Updates": "Оновлення",
|
||||
"Updates (%count)": "Оновлення (%count)",
|
||||
"Uptime": "Час роботи",
|
||||
"Uptime Badge": "Бейдж часу роботи",
|
||||
"Verification failed": "Перевірка не вдалася",
|
||||
"Verify": "Підтвердити",
|
||||
"Verifying": "Перевірка",
|
||||
"We sent a 6-digit code to": "Ми надіслали 6-значний код на"
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,10 @@ import type { LayoutServerData } from "./layoutController.js";
|
||||
// Default page settings
|
||||
const defaultPageSettings: PageSettingsType = {
|
||||
monitor_status_history_days: {
|
||||
desktop: 90,
|
||||
mobile: 30,
|
||||
desktop: GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP,
|
||||
mobile: GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE,
|
||||
},
|
||||
monitor_layout_style: "default-list",
|
||||
monitor_layout_style: GC.DEFAULT_MONITOR_LAYOUT_STYLE,
|
||||
};
|
||||
|
||||
export interface NotificationEvent {
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
GetLoggedInSession,
|
||||
GetLocaleFromCookie,
|
||||
GetUsersCount,
|
||||
HasRequiredEnv,
|
||||
IsEmailSetup,
|
||||
IsSetupComplete,
|
||||
} from "./controller.js";
|
||||
import type { EventDisplaySettings, GlobalPageVisibilitySettings, SiteDateTimeFormat } from "$lib/types/site.js";
|
||||
|
||||
@@ -86,7 +86,9 @@ export async function GetLayoutServerData(cookies: Cookies, request: Request): P
|
||||
GetUsersCount(),
|
||||
]);
|
||||
|
||||
const isSetupComplete = await IsSetupComplete();
|
||||
// Same check as IsSetupComplete, but reuses the site data fetched above
|
||||
// instead of querying it a second time on every request
|
||||
const isSetupComplete = HasRequiredEnv() && Object.keys(siteData).length > 0;
|
||||
|
||||
const selectedLang = GetLocaleFromCookie(siteData, cookies);
|
||||
const siteStatusColors = siteData.colors;
|
||||
|
||||
@@ -190,8 +190,31 @@ export const GetMonitorsParsed = async (query: MonitorFilter): Promise<Array<Mon
|
||||
return parsedMonitors;
|
||||
};
|
||||
|
||||
const VALID_DEFAULT_STATUSES = ["NONE", GC.UP, GC.DOWN, GC.DEGRADED, GC.LAST_KNOWN] as const;
|
||||
|
||||
/**
|
||||
* Enforce the closed default_status value set and the LAST_KNOWN scope rule
|
||||
* (docs/adr/0006): LAST_KNOWN is only meaningful on NONE-type (Manual) monitors;
|
||||
* on any other type it silently resets to UP so the invalid combination never persists.
|
||||
* Throws on values outside the closed set.
|
||||
*/
|
||||
export const NormalizeDefaultStatus = (
|
||||
monitorType: string | null | undefined,
|
||||
defaultStatus: string | null | undefined,
|
||||
): string => {
|
||||
const value = defaultStatus ?? "NONE";
|
||||
if (!(VALID_DEFAULT_STATUSES as readonly string[]).includes(value)) {
|
||||
throw new Error(`default_status must be one of: ${VALID_DEFAULT_STATUSES.join(", ")}`);
|
||||
}
|
||||
if (value === GC.LAST_KNOWN && monitorType !== "NONE") {
|
||||
return GC.UP;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const CreateUpdateMonitor = async (monitor: MonitorInput): Promise<number | number[]> => {
|
||||
let monitorData = { ...monitor };
|
||||
monitorData.default_status = NormalizeDefaultStatus(monitorData.monitor_type, monitorData.default_status);
|
||||
if (monitorData.id) {
|
||||
return await db.updateMonitor(monitorData as MonitorRecord);
|
||||
} else {
|
||||
|
||||
@@ -108,6 +108,22 @@ export const GetLocaleFromCookie = (site: SiteDataTransformed, cookies: Cookies)
|
||||
return selectedLang;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the site URL used for building absolute public URLs, without a trailing slash.
|
||||
* Prefers the configured siteURL and falls back to the ORIGIN env var; only absolute
|
||||
* http(s) values are returned. Returns an empty string when neither is usable, in which
|
||||
* case callers degrade to a relative path.
|
||||
*/
|
||||
export const GetSiteURL = async (): Promise<string> => {
|
||||
const siteURL = await GetSiteDataByKey("siteURL");
|
||||
for (const candidate of [siteURL, process.env.ORIGIN]) {
|
||||
if (typeof candidate === "string" && /^https?:\/\//i.test(candidate)) {
|
||||
return candidate.replace(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
export const GetSiteLogoURL = async (siteURL: string, logo: string, base: string): Promise<string> => {
|
||||
if (logo.startsWith("http")) {
|
||||
return logo;
|
||||
@@ -138,14 +154,17 @@ export const GetSiteDataByKey = async (key: string): Promise<unknown> => {
|
||||
return data.value;
|
||||
};
|
||||
|
||||
/** Checks the env vars required for setup, without touching the database. */
|
||||
export const HasRequiredEnv = (): boolean => {
|
||||
return (
|
||||
process.env.KENER_SECRET_KEY !== undefined &&
|
||||
process.env.ORIGIN !== undefined &&
|
||||
process.env.REDIS_URL !== undefined
|
||||
);
|
||||
};
|
||||
|
||||
export const IsSetupComplete = async (): Promise<boolean> => {
|
||||
if (process.env.KENER_SECRET_KEY === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (process.env.ORIGIN === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (process.env.REDIS_URL === undefined) {
|
||||
if (!HasRequiredEnv()) {
|
||||
return false;
|
||||
}
|
||||
let data = await db.getAllSiteData();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -50,6 +50,7 @@ class DbImpl {
|
||||
getMonitoringData!: MonitoringRepository["getMonitoringData"];
|
||||
getMonitoringDataAll!: MonitoringRepository["getMonitoringDataAll"];
|
||||
getLatestMonitoringData!: MonitoringRepository["getLatestMonitoringData"];
|
||||
getLatestAlertVisibleData!: MonitoringRepository["getLatestAlertVisibleData"];
|
||||
getLatestMonitoringDataN!: MonitoringRepository["getLatestMonitoringDataN"];
|
||||
getMonitoringDataPaginated!: MonitoringRepository["getMonitoringDataPaginated"];
|
||||
getMonitoringDataCount!: MonitoringRepository["getMonitoringDataCount"];
|
||||
@@ -115,11 +116,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"];
|
||||
@@ -392,6 +411,7 @@ class DbImpl {
|
||||
this.getMonitoringData = this.monitoring.getMonitoringData.bind(this.monitoring);
|
||||
this.getMonitoringDataAll = this.monitoring.getMonitoringDataAll.bind(this.monitoring);
|
||||
this.getLatestMonitoringData = this.monitoring.getLatestMonitoringData.bind(this.monitoring);
|
||||
this.getLatestAlertVisibleData = this.monitoring.getLatestAlertVisibleData.bind(this.monitoring);
|
||||
this.getLatestMonitoringDataN = this.monitoring.getLatestMonitoringDataN.bind(this.monitoring);
|
||||
this.getMonitoringDataPaginated = this.monitoring.getMonitoringDataPaginated.bind(this.monitoring);
|
||||
this.getMonitoringDataCount = this.monitoring.getMonitoringDataCount.bind(this.monitoring);
|
||||
@@ -460,7 +480,7 @@ class DbImpl {
|
||||
this.getUsersPaginated = this.users.getUsersPaginated.bind(this.users);
|
||||
this.getTotalUsers = this.users.getTotalUsers.bind(this.users);
|
||||
this.updateUserName = this.users.updateUserName.bind(this.users);
|
||||
this.updateUserRole = this.users.updateUserRole.bind(this.users);
|
||||
this.updateUserRoles = this.users.updateUserRoles.bind(this.users);
|
||||
this.updateUserIsActive = this.users.updateUserIsActive.bind(this.users);
|
||||
this.updateUserPasswordById = this.users.updateUserPasswordById.bind(this.users);
|
||||
this.updateIsVerified = this.users.updateIsVerified.bind(this.users);
|
||||
@@ -469,6 +489,24 @@ class DbImpl {
|
||||
this.deleteApiKey = this.users.deleteApiKey.bind(this.users);
|
||||
this.getApiKeyByHashedKey = this.users.getApiKeyByHashedKey.bind(this.users);
|
||||
this.getAllApiKeys = this.users.getAllApiKeys.bind(this.users);
|
||||
|
||||
// Roles
|
||||
this.getRoleById = this.users.getRoleById.bind(this.users);
|
||||
this.getAllRoles = this.users.getAllRoles.bind(this.users);
|
||||
this.insertRole = this.users.insertRole.bind(this.users);
|
||||
this.updateRole = this.users.updateRole.bind(this.users);
|
||||
this.deleteRole = this.users.deleteRole.bind(this.users);
|
||||
this.getUsersCountByRoleId = this.users.getUsersCountByRoleId.bind(this.users);
|
||||
this.migrateUsersRole = this.users.migrateUsersRole.bind(this.users);
|
||||
this.getRolePermissions = this.users.getRolePermissions.bind(this.users);
|
||||
this.getAllPermissions = this.users.getAllPermissions.bind(this.users);
|
||||
this.addRolePermission = this.users.addRolePermission.bind(this.users);
|
||||
this.removeRolePermission = this.users.removeRolePermission.bind(this.users);
|
||||
this.getUsersByRoleId = this.users.getUsersByRoleId.bind(this.users);
|
||||
this.addUserToRole = this.users.addUserToRole.bind(this.users);
|
||||
this.removeUserFromRole = this.users.removeUserFromRole.bind(this.users);
|
||||
this.getUserPermissionIds = this.users.getUserPermissionIds.bind(this.users);
|
||||
this.getUserRoleIds = this.users.getUserRoleIds.bind(this.users);
|
||||
}
|
||||
|
||||
private bindSiteDataMethods(): void {
|
||||
@@ -798,6 +836,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();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,17 @@ import type {
|
||||
TimestampStatusCountByMonitor,
|
||||
} from "../../types/db.js";
|
||||
|
||||
/**
|
||||
* Sample types alert evaluation can see (see docs/adr/0005-alerts-evaluate-alert-visible-samples.md
|
||||
* and docs/adr/0006-last-known-status-fill.md).
|
||||
* Exactly the types written by flows that enqueue alert evaluation: scheduler checks
|
||||
* (REALTIME/ERROR/TIMEOUT), default-status fill (DEFAULT_STATUS), last-known-status fill (CARRIED),
|
||||
* and data-API pushes (MANUAL).
|
||||
* SIGNAL rows (raw heartbeat receipts) and INCIDENT/MAINTENANCE overlays stay invisible, so the
|
||||
* alert window freezes during manual overlays instead of triggering or resolving on them.
|
||||
*/
|
||||
const ALERT_VISIBLE_TYPES = [GC.REALTIME, GC.ERROR, GC.TIMEOUT, GC.MANUAL, GC.DEFAULT_STATUS, GC.CARRIED];
|
||||
|
||||
/**
|
||||
* Repository for monitoring data operations
|
||||
*/
|
||||
@@ -61,6 +72,10 @@ export class MonitoringRepository extends BaseRepository {
|
||||
.orderBy("timestamp", "asc");
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest sample of ANY type (including SIGNAL receipts and overlays).
|
||||
* For the last-known-status carry source, use getLatestAlertVisibleData.
|
||||
*/
|
||||
async getLatestMonitoringData(monitor_tag: string): Promise<MonitoringData | undefined> {
|
||||
return await this.knex("monitoring_data")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
@@ -69,6 +84,23 @@ export class MonitoringRepository extends BaseRepository {
|
||||
.first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest sample the alert evaluator (and last-known-status fill) can see.
|
||||
* Carry source for Default Status = LAST_KNOWN (docs/adr/0006): overlays
|
||||
* (INCIDENT/MAINTENANCE) and raw heartbeat receipts (SIGNAL) are excluded,
|
||||
* so they can never become sticky. CARRIED rows are included — each tick's
|
||||
* carry output becomes the next tick's carry source, which is the intended
|
||||
* sticky behavior.
|
||||
*/
|
||||
async getLatestAlertVisibleData(monitor_tag: string): Promise<MonitoringData | undefined> {
|
||||
return await this.knex("monitoring_data")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
.whereIn("type", ALERT_VISIBLE_TYPES)
|
||||
.orderBy("timestamp", "desc")
|
||||
.limit(1)
|
||||
.first();
|
||||
}
|
||||
|
||||
async getLatestMonitoringDataN(monitor_tag: string, limit: number): Promise<MonitoringData[]> {
|
||||
return await this.knex("monitoring_data")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
@@ -262,7 +294,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 +320,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 +342,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);
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -135,6 +135,20 @@ const addWorker = () => {
|
||||
if (monitor.default_status !== GC.UP) {
|
||||
defaultData[ts].error_message = "Default status applied";
|
||||
}
|
||||
} else if (monitor.default_status === GC.LAST_KNOWN) {
|
||||
// Last Known Status fill (docs/adr/0006): repeat the most recent alert-visible
|
||||
// sample — status and latency alike. No sample yet → nothing to carry → no fill.
|
||||
const lastKnown = await db.getLatestAlertVisibleData(monitor.tag);
|
||||
if (lastKnown && lastKnown.status) {
|
||||
defaultData[ts] = {
|
||||
status: lastKnown.status,
|
||||
latency: lastKnown.latency ?? 0,
|
||||
type: GC.CARRIED,
|
||||
};
|
||||
if (lastKnown.status !== GC.UP) {
|
||||
defaultData[ts].error_message = "Last known status applied";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,11 +160,12 @@ const addWorker = () => {
|
||||
if (defaultStatus && realtimeStatus === GC.NO_DATA) {
|
||||
// Apply the preference *before* merging so incident/maintenance can still override later.
|
||||
// Also avoid carrying over realtime NO_DATA error_message.
|
||||
// Keep the fill's own type: DEFAULT for fixed fill, CARRIED for last-known fill.
|
||||
realtimeDataForMerge = { ...realtimeData };
|
||||
realtimeDataForMerge[ts] = {
|
||||
...realtimeDataForMerge[ts],
|
||||
status: defaultStatus,
|
||||
type: GC.DEFAULT_STATUS,
|
||||
type: defaultData[ts].type,
|
||||
};
|
||||
delete realtimeDataForMerge[ts].error_message;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+52
-2
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,9 @@
|
||||
import { ModeWatcher } from "mode-watcher";
|
||||
import { resolve } from "$app/paths";
|
||||
import { Toaster } from "$lib/components/ui/sonner/index.js";
|
||||
|
||||
let base = resolve("/");
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
|
||||
let { children, data } = $props();
|
||||
|
||||
const colorUp = $derived(data.siteStatusColors.UP);
|
||||
const colorDegraded = $derived(data.siteStatusColors.DEGRADED);
|
||||
const colorDown = $derived(data.siteStatusColors.DOWN);
|
||||
const colorMaintenance = $derived(data.siteStatusColors.MAINTENANCE);
|
||||
import KenerNav from "$lib/components/KenerNav.svelte";
|
||||
</script>
|
||||
|
||||
@@ -21,7 +15,32 @@
|
||||
|
||||
<svelte:head>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
{@html `<style>:root{--up:${colorUp};--degraded:${colorDegraded};--down:${colorDown};--maintenance:${colorMaintenance};}</style>`}
|
||||
<link rel="icon" href={data.favicon} />
|
||||
{#if data.font?.cssSrc}
|
||||
<link rel="stylesheet" href={data.font.cssSrc} />
|
||||
{/if}
|
||||
{@html `
|
||||
<style id="dynamic-styles">
|
||||
body {
|
||||
--up: ${data.siteStatusColors.UP};
|
||||
--degraded: ${data.siteStatusColors.DEGRADED};
|
||||
--down: ${data.siteStatusColors.DOWN};
|
||||
--maintenance: ${data.siteStatusColors.MAINTENANCE};
|
||||
--accent: ${data.siteStatusColors.ACCENT || "#f4f4f5"};
|
||||
--accent-foreground: ${data.siteStatusColors.ACCENT_FOREGROUND || data.siteStatusColors.ACCENT || "#e96e2d"};
|
||||
${data.font?.family ? `--font-family:'${data.font.family}', sans-serif;` : ""}
|
||||
}
|
||||
:is(.dark) body {
|
||||
--up: ${data.siteStatusColorsDark.UP};
|
||||
--degraded: ${data.siteStatusColorsDark.DEGRADED};
|
||||
--down: ${data.siteStatusColorsDark.DOWN};
|
||||
--maintenance: ${data.siteStatusColorsDark.MAINTENANCE};
|
||||
--accent: ${data.siteStatusColorsDark.ACCENT || "#27272a"};
|
||||
--accent-foreground: ${data.siteStatusColorsDark.ACCENT_FOREGROUND || data.siteStatusColorsDark.ACCENT || "#e96e2d"};
|
||||
}
|
||||
${data.customCSS || ""}
|
||||
</style>`}
|
||||
<script src={clientResolver(resolve, "/capture.js")}></script>
|
||||
</svelte:head>
|
||||
<main>
|
||||
<!-- Nav -->
|
||||
|
||||
@@ -59,6 +59,13 @@ export const actions: Actions = {
|
||||
});
|
||||
}
|
||||
|
||||
if (!userDB.role_ids || userDB.role_ids.length === 0) {
|
||||
return fail(403, {
|
||||
error: "Your account has no active roles assigned. Please contact an administrator.",
|
||||
values: { email },
|
||||
});
|
||||
}
|
||||
|
||||
const token = await GenerateToken(userDB);
|
||||
const cookieConfig = CookieConfig();
|
||||
cookies.set(cookieConfig.name, token, {
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
} from "$lib/types/api";
|
||||
import GC from "$lib/global-constants";
|
||||
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
|
||||
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
|
||||
import serverResolver from "$lib/server/resolver";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
if (date instanceof Date) {
|
||||
@@ -56,6 +58,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
}
|
||||
|
||||
// Build response with monitors for each incident
|
||||
const siteUrl = await GetSiteURL();
|
||||
const incidents: IncidentResponse[] = [];
|
||||
for (const incident of rawIncidents) {
|
||||
const monitors = await db.getIncidentMonitorsByIncidentID(incident.id);
|
||||
@@ -72,6 +75,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
})),
|
||||
created_at: formatDateToISO(incident.created_at),
|
||||
updated_at: formatDateToISO(incident.updated_at),
|
||||
url: siteUrl + serverResolver(`/incidents/${incident.id}`),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -207,6 +211,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
})),
|
||||
created_at: formatDateToISO(createdIncident.created_at),
|
||||
updated_at: formatDateToISO(createdIncident.updated_at),
|
||||
url: (await GetSiteURL()) + serverResolver(`/incidents/${createdIncident.id}`),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
BadRequestResponse,
|
||||
} from "$lib/types/api";
|
||||
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
|
||||
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
|
||||
import serverResolver from "$lib/server/resolver";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
if (date instanceof Date) {
|
||||
@@ -43,6 +45,7 @@ async function buildIncidentResponse(incidentId: number): Promise<IncidentDetail
|
||||
})),
|
||||
created_at: formatDateToISO(incident.created_at),
|
||||
updated_at: formatDateToISO(incident.updated_at),
|
||||
url: (await GetSiteURL()) + serverResolver(`/incidents/${incident.id}`),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
GenerateMaintenanceEvents,
|
||||
isOneTimeRrule,
|
||||
} from "$lib/server/controllers/maintenanceController";
|
||||
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
|
||||
import serverResolver from "$lib/server/resolver";
|
||||
import { rrulestr } from "rrule";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
@@ -67,6 +69,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
}
|
||||
|
||||
// Build response with monitors for each maintenance
|
||||
const siteUrl = await GetSiteURL();
|
||||
const maintenances: MaintenanceResponse[] = [];
|
||||
for (const maintenance of rawMaintenances) {
|
||||
const monitors = await db.getMaintenanceMonitors(maintenance.id);
|
||||
@@ -84,6 +87,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
})),
|
||||
created_at: formatDateToISO(maintenance.created_at),
|
||||
updated_at: formatDateToISO(maintenance.updated_at),
|
||||
url: siteUrl + serverResolver(`/maintenances/${maintenance.id}?type=maintenance`),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -267,6 +271,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
})),
|
||||
created_at: formatDateToISO(maintenance.created_at),
|
||||
updated_at: formatDateToISO(maintenance.updated_at),
|
||||
url: (await GetSiteURL()) + serverResolver(`/maintenances/${maintenance.id}?type=maintenance`),
|
||||
};
|
||||
|
||||
const response: CreateMaintenanceResponse = {
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
} from "$lib/types/api";
|
||||
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
|
||||
import { GenerateMaintenanceEvents, isOneTimeRrule } from "$lib/server/controllers/maintenanceController";
|
||||
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
|
||||
import serverResolver from "$lib/server/resolver";
|
||||
import { rrulestr } from "rrule";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
@@ -55,6 +57,7 @@ async function buildMaintenanceResponse(maintenanceId: number): Promise<Maintena
|
||||
})),
|
||||
created_at: formatDateToISO(maintenance.created_at),
|
||||
updated_at: formatDateToISO(maintenance.updated_at),
|
||||
url: (await GetSiteURL()) + serverResolver(`/maintenances/${maintenance.id}?type=maintenance`),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { json, type RequestHandler } from "@sveltejs/kit";
|
||||
import db from "$lib/server/db/db";
|
||||
import type { GetMaintenanceEventsListResponse, MaintenanceEventResponse } from "$lib/types/api";
|
||||
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
|
||||
import serverResolver from "$lib/server/resolver";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
if (date instanceof Date) {
|
||||
@@ -31,14 +33,16 @@ export const GET: RequestHandler = async ({ locals, url }) => {
|
||||
const paginatedEvents = allEvents.slice(offset, offset + limit);
|
||||
|
||||
// Build response
|
||||
const siteUrl = await GetSiteURL();
|
||||
const events: MaintenanceEventResponse[] = paginatedEvents.map((event) => ({
|
||||
id: event.id,
|
||||
maintenance_id: event.maintenance_id,
|
||||
start_date_time: event.start_date_time,
|
||||
end_date_time: event.end_date_time,
|
||||
status: event.status as "SCHEDULED" | "ONGOING" | "COMPLETED" | "CANCELLED",
|
||||
status: event.status as MaintenanceEventResponse["status"],
|
||||
created_at: formatDateToISO(event.created_at),
|
||||
updated_at: formatDateToISO(event.updated_at),
|
||||
url: siteUrl + serverResolver(`/maintenances/${event.id}`),
|
||||
}));
|
||||
|
||||
const response: GetMaintenanceEventsListResponse = {
|
||||
|
||||
@@ -10,6 +10,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}`),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
BadRequestResponse,
|
||||
} from "$lib/types/api";
|
||||
import type { MonitorRecord } from "$lib/server/types/db";
|
||||
import { GetMonitorsParsed } from "$lib/server/controllers/monitorsController";
|
||||
import { GetMonitorsParsed, NormalizeDefaultStatus } from "$lib/server/controllers/monitorsController";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
if (date instanceof Date) {
|
||||
@@ -101,6 +101,19 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
|
||||
let defaultStatus: string;
|
||||
try {
|
||||
defaultStatus = NormalizeDefaultStatus(body.monitor_type ?? "API", body.default_status ?? "UP");
|
||||
} catch (e) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: {
|
||||
code: "BAD_REQUEST",
|
||||
message: e instanceof Error ? e.message : "Invalid default_status",
|
||||
},
|
||||
};
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
|
||||
// Prepare monitor data for insertion
|
||||
const monitorData = {
|
||||
tag: body.tag.trim(),
|
||||
@@ -108,7 +121,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
description: body.description ?? null,
|
||||
image: body.image ?? null,
|
||||
cron: body.cron ?? null,
|
||||
default_status: body.default_status ?? "UP",
|
||||
default_status: defaultStatus,
|
||||
status: body.status ?? "ACTIVE",
|
||||
category_name: body.category_name ?? null,
|
||||
monitor_type: body.monitor_type ?? "API",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json, type RequestHandler } from "@sveltejs/kit";
|
||||
import db from "$lib/server/db/db";
|
||||
import { GetMonitorsParsed } from "$lib/server/controllers/monitorsController";
|
||||
import { GetMonitorsParsed, NormalizeDefaultStatus } from "$lib/server/controllers/monitorsController";
|
||||
import type {
|
||||
GetMonitorResponse,
|
||||
MonitorResponse,
|
||||
@@ -77,6 +77,23 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
updateData.category_name = body.category_name !== undefined ? body.category_name : existingMonitor.category_name;
|
||||
updateData.monitor_type = body.monitor_type !== undefined ? body.monitor_type : existingMonitor.monitor_type;
|
||||
|
||||
// Closed-set validation + LAST_KNOWN scope rule (docs/adr/0006). Runs after monitor_type
|
||||
// is resolved so a type change away from NONE auto-resets LAST_KNOWN to UP.
|
||||
try {
|
||||
updateData.default_status = NormalizeDefaultStatus(
|
||||
updateData.monitor_type as string,
|
||||
updateData.default_status as string | null,
|
||||
);
|
||||
} catch (e) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: {
|
||||
code: "BAD_REQUEST",
|
||||
message: e instanceof Error ? e.message : "Invalid default_status",
|
||||
},
|
||||
};
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
|
||||
updateData.is_hidden = body.is_hidden !== undefined ? body.is_hidden : existingMonitor.is_hidden;
|
||||
|
||||
// Handle JSON fields - merge with existing data instead of replacing
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -19,10 +19,3 @@
|
||||
<main>
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
/* Apply the global font family using the CSS variable */
|
||||
* {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -303,6 +303,10 @@
|
||||
"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"
|
||||
|
||||
+518
-719
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
|
||||
@@ -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 · 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,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.
|
||||
@@ -29,6 +29,43 @@ So realtime checks do not override an active maintenance or incident state.
|
||||
|
||||
Monitors run from cron expressions (for example `* * * * *` for every minute). Use tighter schedules for critical services and relaxed schedules for low-risk dependencies.
|
||||
|
||||
## Default Status {#default-status}
|
||||
|
||||
Default Status is the monitor's answer to the question: **what does a minute with no monitoring sample mean?**
|
||||
|
||||
| Value | Behavior |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NONE` | Gap minutes show as no data (gray) |
|
||||
| `UP` | A `DEFAULT` sample is written each minute marking the service UP |
|
||||
| `DOWN` | A `DEFAULT` sample is written each minute marking the service DOWN |
|
||||
| `DEGRADED` | A `DEFAULT` sample is written each minute marking the service DEGRADED |
|
||||
| `LAST_KNOWN` | Each minute without a new sample, Kener writes a `CARRIED` row repeating the most recent alert-visible status and latency |
|
||||
|
||||
### Last known status {#last-known-status}
|
||||
|
||||
`LAST_KNOWN` is only available on **Manual (`NONE`-type) monitors**. If you select it on any other monitor type, the API resets it to `UP`. Changing a monitor's type away from Manual also resets it to `UP`.
|
||||
|
||||
How it works:
|
||||
|
||||
- Every scheduler tick with no new data, Kener writes a `CARRIED` sample copying the status and latency of the most recent alert-visible sample.
|
||||
- Carry is tick-forward only — it starts at the next scheduler tick after you save the setting, with no backfill of past gaps.
|
||||
- Carried rows persist in history even if you later change the setting.
|
||||
|
||||
Example push flow:
|
||||
|
||||
```bash
|
||||
curl -X PATCH 'https://status.example.com/api/v4/monitors/my-service/data/{current_unix_minute}' \
|
||||
-H 'Authorization: Bearer <api-key>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"status": "DOWN", "latency": 100}'
|
||||
# With Default Status = Last known status, the monitor stays DOWN until you push UP.
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> - If your integration stops sending, the page keeps showing the last status indefinitely — Kener cannot tell "still up" from "stopped reporting". Use a [Heartbeat monitor](/docs/v4/monitors/heartbeat) to catch a silent integration.
|
||||
> - Carried minutes count toward alert thresholds: a single DOWN push will trigger alerts after your failure threshold, and they stay triggered until you push a recovery.
|
||||
|
||||
## Uptime calculation {#uptime-calculation}
|
||||
|
||||
Default uptime formula:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -7,29 +7,29 @@
|
||||
import IncidentItem from "$lib/components/IncidentItem.svelte";
|
||||
import MaintenanceItem from "$lib/components/MaintenanceItem.svelte";
|
||||
import mdToHTML from "$lib/marked.js";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import clientResolver, { absoluteResolve } from "$lib/client/resolver.js";
|
||||
import { resolve } from "$app/paths";
|
||||
import { selectedTimezone } from "$lib/stores/timezone";
|
||||
import { getEndOfDayAtTz } from "$lib/client/datetime";
|
||||
import { requestMonitorBar } from "$lib/client/monitor-bar-client";
|
||||
import type { MonitorBarResponse } from "$lib/server/api-server/monitor-bar/get";
|
||||
import { SveltePurify } from "@humanspeak/svelte-purify";
|
||||
import type { PageMonitorLayoutStyle } from "$lib/types/api";
|
||||
import GC from "$lib/global-constants.js";
|
||||
|
||||
let { data } = $props();
|
||||
let pageSettings = $derived(data.pageDetails.page_settings);
|
||||
let barCount = $derived.by(() =>
|
||||
data.isMobile
|
||||
? pageSettings?.monitor_status_history_days.mobile || 30
|
||||
: pageSettings?.monitor_status_history_days.desktop || 90
|
||||
? pageSettings?.monitor_status_history_days.mobile || GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE
|
||||
: pageSettings?.monitor_status_history_days.desktop || GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP
|
||||
);
|
||||
let endOfDayTodayAtTz = $derived(getEndOfDayAtTz($selectedTimezone));
|
||||
|
||||
let monitorBarDataByTag = $state<Record<string, MonitorBarResponse>>({});
|
||||
let monitorBarErrorByTag = $state<Record<string, string>>({});
|
||||
let requestVersion = 0;
|
||||
let viewType = $derived<"compact-list" | "default-list" | "default-grid" | "compact-grid" | undefined>(
|
||||
pageSettings?.monitor_layout_style
|
||||
);
|
||||
let viewType = $derived<PageMonitorLayoutStyle | undefined>(pageSettings?.monitor_layout_style);
|
||||
let isCompact = $derived(viewType === "compact-list" || viewType === "compact-grid");
|
||||
|
||||
function getGridItemSpanClass(index: number, total: number, type: typeof viewType): string {
|
||||
@@ -136,8 +136,8 @@
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{#if data.socialPagePreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
|
||||
<meta property="og:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPagePreviewImage)} />
|
||||
<meta name="twitter:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPagePreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import { formatDate } from "$lib/stores/datetime";
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import clientResolver, { absoluteResolve } from "$lib/client/resolver.js";
|
||||
import { format, parse, addMonths, subMonths, getUnixTime, startOfDay, formatDistanceStrict } from "date-fns";
|
||||
import { page } from "$app/state";
|
||||
import type { IncidentForMonitorListWithComments, MaintenanceEventsMonitorList } from "$lib/server/types/db";
|
||||
@@ -165,8 +165,8 @@
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta property="og:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import { formatDate } from "$lib/stores/datetime";
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import clientResolver, { absoluteResolve } from "$lib/client/resolver.js";
|
||||
import { format, parse, addMonths, subMonths, getUnixTime, startOfDay, formatDistanceStrict } from "date-fns";
|
||||
import { page } from "$app/state";
|
||||
import type { IncidentForMonitorListWithComments, MaintenanceEventsMonitorList } from "$lib/server/types/db";
|
||||
@@ -165,8 +165,8 @@
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta property="og:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { SveltePurify } from "@humanspeak/svelte-purify";
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import { formatDate, formatDuration } from "$lib/stores/datetime";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import clientResolver, { absoluteResolve } from "$lib/client/resolver.js";
|
||||
import { page } from "$app/state";
|
||||
|
||||
let { data } = $props();
|
||||
@@ -28,8 +28,8 @@
|
||||
<meta property="og:description" content={data.comments[0].comment} />
|
||||
{/if}
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta property="og:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import STATUS_ICON from "$lib/icons";
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import { formatDate, formatDuration } from "$lib/stores/datetime";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import clientResolver, { absoluteResolve } from "$lib/client/resolver.js";
|
||||
import { SveltePurify } from "@humanspeak/svelte-purify";
|
||||
import { page } from "$app/state";
|
||||
|
||||
@@ -73,8 +73,8 @@
|
||||
<meta property="og:description" content={data.maintenance.description} />
|
||||
{/if}
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta property="og:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
|
||||
@@ -83,11 +83,13 @@ export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
}
|
||||
}
|
||||
|
||||
let maxDays = parentData.isMobile ? 30 : 90;
|
||||
let maxDays: number = parentData.isMobile
|
||||
? GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE
|
||||
: GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP;
|
||||
if (monitor.monitor_settings_json?.monitor_status_history_days) {
|
||||
maxDays = parentData.isMobile
|
||||
? monitor.monitor_settings_json.monitor_status_history_days.mobile || 30
|
||||
: monitor.monitor_settings_json.monitor_status_history_days.desktop || 90;
|
||||
? monitor.monitor_settings_json.monitor_status_history_days.mobile || GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE
|
||||
: monitor.monitor_settings_json.monitor_status_history_days.desktop || GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP;
|
||||
}
|
||||
return {
|
||||
...{
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import ThemePlus from "$lib/components/ThemePlus.svelte";
|
||||
import MonitorOverview from "$lib/components/MonitorOverview.svelte";
|
||||
import ArrowUpRight from "@lucide/svelte/icons/arrow-up-right";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import clientResolver, { absoluteResolve } from "$lib/client/resolver.js";
|
||||
import { resolve } from "$app/paths";
|
||||
import trackEvent from "$lib/beacon";
|
||||
import IncidentItem from "$lib/components/IncidentItem.svelte";
|
||||
@@ -37,8 +37,8 @@
|
||||
<meta property="og:description" content={data.monitorDescription} />
|
||||
{/if}
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta property="og:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
<div class="flex flex-col gap-3">
|
||||
|
||||
@@ -2,8 +2,11 @@ import { redirect } from "@sveltejs/kit";
|
||||
import MobileDetect from "mobile-detect";
|
||||
import type { LayoutServerLoad } from "./$types";
|
||||
import { IsEmailSetup } from "$lib/server/controllers/controller.js";
|
||||
import { RequirePermission } from "$lib/server/controllers/userController.js";
|
||||
import seedSiteData from "$lib/server/db/seedSiteData.js";
|
||||
import serverResolve from "$lib/server/resolver.js";
|
||||
import { ROUTE_PERMISSION_MAP } from "$lib/allPerms.js";
|
||||
import { error } from "@sveltejs/kit";
|
||||
|
||||
import { resolve } from "$app/paths";
|
||||
import {
|
||||
@@ -12,8 +15,9 @@ import {
|
||||
GetLoggedInSession,
|
||||
GetLocaleFromCookie,
|
||||
} from "$lib/server/controllers/controller.js";
|
||||
import { GetUserPermissions } from "$lib/server/controllers/userController.js";
|
||||
|
||||
export const load: LayoutServerLoad = async ({ cookies }) => {
|
||||
export const load: LayoutServerLoad = async ({ cookies, route }) => {
|
||||
let isSetupComplete = await IsSetupComplete();
|
||||
if (!isSetupComplete) {
|
||||
throw redirect(302, serverResolve(`/account/signin`));
|
||||
@@ -27,13 +31,27 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
|
||||
}
|
||||
|
||||
const siteData = await GetAllSiteData();
|
||||
const userPermissions = await GetUserPermissions(loggedInUser.id);
|
||||
const routeId = route.id || "";
|
||||
|
||||
const requiredPermission = ROUTE_PERMISSION_MAP[routeId];
|
||||
if (requiredPermission === undefined) {
|
||||
throw error(403, "Forbidden");
|
||||
}
|
||||
if (requiredPermission !== null) {
|
||||
try {
|
||||
RequirePermission(userPermissions, requiredPermission);
|
||||
} catch {
|
||||
throw error(403, "Forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
const siteStatusColors = siteData.colors;
|
||||
const siteStatusColorsDark = siteData.colorsDark || siteStatusColors;
|
||||
const font = siteData.font || { cssSrc: "", family: "" };
|
||||
// const emailSubscriptionTrigger = await GetSubscriptionTriggerByEmail();
|
||||
return {
|
||||
userDb: loggedInUser,
|
||||
userPermissions: [...userPermissions],
|
||||
siteStatusColors,
|
||||
siteStatusColorsDark,
|
||||
font,
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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) {
|
||||
@@ -370,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];
|
||||
@@ -386,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();
|
||||
@@ -402,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 };
|
||||
}
|
||||
@@ -434,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") {
|
||||
@@ -453,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);
|
||||
@@ -494,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;
|
||||
@@ -510,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) {
|
||||
@@ -518,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);
|
||||
}
|
||||
|
||||
@@ -565,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");
|
||||
@@ -594,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");
|
||||
@@ -614,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");
|
||||
@@ -624,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");
|
||||
@@ -634,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");
|
||||
@@ -644,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 = {
|
||||
@@ -669,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);
|
||||
|
||||
@@ -273,7 +273,7 @@
|
||||
<Table.Cell class="pr-4 text-right">
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={page.data.userDb.role !== "admin"}
|
||||
disabled={!page.data.userPermissions?.includes("api_keys.delete")}
|
||||
size="sm"
|
||||
onclick={() => openDeleteDialog(apiKey)}
|
||||
>
|
||||
|
||||
@@ -14,15 +14,20 @@
|
||||
import XIcon from "@lucide/svelte/icons/x";
|
||||
import { Spinner } from "$lib/components/ui/spinner/index.js";
|
||||
import * as Item from "$lib/components/ui/item/index.js";
|
||||
import { Switch } from "$lib/components/ui/switch/index.js";
|
||||
import type { MonitorRecord } from "$lib/server/types/db.js";
|
||||
import { resolve } from "$app/paths";
|
||||
import { page } from "$app/state";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import { GetInitials } from "$lib/clientTools.js";
|
||||
import { onMount } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
|
||||
let monitors = $state<MonitorRecord[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let toggling = $state<Record<string, boolean>>({});
|
||||
const canWrite = $derived(page.data.userPermissions?.includes("monitors.write") ?? false);
|
||||
let showFilters = $state(false);
|
||||
let statusFilter = $state("ALL");
|
||||
let searchQuery = $state("");
|
||||
@@ -93,6 +98,39 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMonitorField(monitor: MonitorRecord, field: "status" | "is_hidden", checked: boolean) {
|
||||
const key = `${monitor.id}:${field}`;
|
||||
if (toggling[key]) return;
|
||||
|
||||
// For is_hidden the switch shows visibility: on = visible = not hidden
|
||||
const previous = field === "status" ? monitor.status || "INACTIVE" : monitor.is_hidden || "NO";
|
||||
const next = field === "status" ? (checked ? "ACTIVE" : "INACTIVE") : checked ? "NO" : "YES";
|
||||
if (previous === next) return;
|
||||
|
||||
// Optimistic flip; rolled back on failure
|
||||
monitor[field] = next;
|
||||
toggling[key] = true;
|
||||
try {
|
||||
const response = await fetch(clientResolver(resolve, "/manage/api"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "storeMonitorData", data: { ...monitor, [field]: next } })
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
const stateLabel =
|
||||
field === "status" ? (next === "ACTIVE" ? "active" : "inactive") : next === "YES" ? "hidden" : "visible";
|
||||
toast.success(`${monitor.name} is now ${stateLabel}`);
|
||||
} catch (e) {
|
||||
monitor[field] = previous;
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update monitor");
|
||||
} finally {
|
||||
delete toggling[key];
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchMonitors();
|
||||
});
|
||||
@@ -184,7 +222,7 @@
|
||||
<Table.Head class="w-[180px]">Tag</Table.Head>
|
||||
<Table.Head class="w-[130px]">Type</Table.Head>
|
||||
<Table.Head class="w-[120px]">Status</Table.Head>
|
||||
<Table.Head class="w-[120px]">Hidden</Table.Head>
|
||||
<Table.Head class="w-[120px]">Visible</Table.Head>
|
||||
<Table.Head class="w-[180px]">Cron</Table.Head>
|
||||
<Table.Head class="w-[120px] text-right"></Table.Head>
|
||||
</Table.Row>
|
||||
@@ -212,14 +250,30 @@
|
||||
<Badge variant="secondary">{data.monitor_type}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant={(data.status || "INACTIVE") === "ACTIVE" ? "default" : "destructive"}>
|
||||
{data.status || "INACTIVE"}
|
||||
</Badge>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={(data.status || "INACTIVE") === "ACTIVE"}
|
||||
disabled={!canWrite || !!toggling[`${data.id}:status`]}
|
||||
aria-label="Toggle status for {data.name}"
|
||||
onCheckedChange={(checked) => toggleMonitorField(data, "status", checked)}
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{(data.status || "INACTIVE") === "ACTIVE" ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant={data.is_hidden === "YES" ? "destructive" : "outline"}>
|
||||
{data.is_hidden === "YES" ? "YES" : "NO"}
|
||||
</Badge>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={data.is_hidden !== "YES"}
|
||||
disabled={!canWrite || !!toggling[`${data.id}:is_hidden`]}
|
||||
aria-label="Toggle status page visibility for {data.name}"
|
||||
onCheckedChange={(checked) => toggleMonitorField(data, "is_hidden", checked)}
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{data.is_hidden === "YES" ? "Hidden" : "Visible"}
|
||||
</span>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span class="text-muted-foreground text-xs">{data.cron || "-"}</span>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
import MonitorRecentLogs from "./components/MonitorRecentLogs.svelte";
|
||||
import StatusHistoryDaysCard from "./components/StatusHistoryDaysCard.svelte";
|
||||
import MonitorSharingOptionsCard from "./components/MonitorSharingOptionsCard.svelte";
|
||||
import GC from "$lib/global-constants.js";
|
||||
|
||||
let { params }: PageProps = $props();
|
||||
const isNew = $derived(params.tag === "new");
|
||||
@@ -48,8 +49,8 @@
|
||||
|
||||
// Status history days state
|
||||
let statusHistoryDays = $state({
|
||||
desktop: 90,
|
||||
mobile: 30
|
||||
desktop: GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP as number,
|
||||
mobile: GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE as number
|
||||
});
|
||||
|
||||
// Pages state
|
||||
@@ -137,8 +138,8 @@
|
||||
};
|
||||
if (settings.monitor_status_history_days) {
|
||||
statusHistoryDays = {
|
||||
desktop: settings.monitor_status_history_days.desktop ?? 90,
|
||||
mobile: settings.monitor_status_history_days.mobile ?? 30
|
||||
desktop: settings.monitor_status_history_days.desktop ?? GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP,
|
||||
mobile: settings.monitor_status_history_days.mobile ?? GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
+47
-3
@@ -11,6 +11,8 @@
|
||||
import UploadIcon from "@lucide/svelte/icons/upload";
|
||||
import XIcon from "@lucide/svelte/icons/x";
|
||||
import ImageIcon from "@lucide/svelte/icons/image";
|
||||
import * as Alert from "$lib/components/ui/alert/index.js";
|
||||
import TriangleAlertIcon from "@lucide/svelte/icons/triangle-alert";
|
||||
import type { MonitorRecord } from "$lib/server/types/db.js";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -26,6 +28,23 @@
|
||||
|
||||
let { monitor = $bindable(), typeData, isNew }: Props = $props();
|
||||
|
||||
const defaultStatusLabels: Record<string, string> = {
|
||||
NONE: "None (show gaps as no data)",
|
||||
UP: "UP",
|
||||
DOWN: "DOWN",
|
||||
DEGRADED: "DEGRADED",
|
||||
LAST_KNOWN: "Last known status"
|
||||
};
|
||||
|
||||
// LAST_KNOWN is only valid on Manual (NONE-type) monitors; the server enforces the
|
||||
// same rule (NormalizeDefaultStatus), this effect just keeps the UI honest live.
|
||||
$effect(() => {
|
||||
if (monitor.monitor_type !== "NONE" && monitor.default_status === GC.LAST_KNOWN) {
|
||||
monitor.default_status = GC.UP;
|
||||
toast.info("Default status was reset to UP — Last known status is only available for Manual monitors.");
|
||||
}
|
||||
});
|
||||
|
||||
let savingGeneral = $state(false);
|
||||
let uploadingImage = $state(false);
|
||||
|
||||
@@ -230,21 +249,46 @@
|
||||
<Label for="monitor-default-status">Default Status</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={monitor.default_status}
|
||||
value={monitor.default_status ?? "NONE"}
|
||||
onValueChange={(v) => {
|
||||
if (v) monitor.default_status = v;
|
||||
}}
|
||||
>
|
||||
<Select.Trigger id="monitor-default-status" class="w-full">
|
||||
{monitor.default_status}
|
||||
{defaultStatusLabels[monitor.default_status ?? "NONE"] ?? monitor.default_status}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="NONE">None (show gaps as no data)</Select.Item>
|
||||
<Select.Item value="UP">UP</Select.Item>
|
||||
<Select.Item value="DOWN">DOWN</Select.Item>
|
||||
<Select.Item value="DEGRADED">DEGRADED</Select.Item>
|
||||
<Select.Item value="MAINTENANCE">MAINTENANCE</Select.Item>
|
||||
{#if monitor.monitor_type === "NONE"}
|
||||
<Select.Item value="LAST_KNOWN">Last known status</Select.Item>
|
||||
{/if}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{#if monitor.default_status === GC.LAST_KNOWN}
|
||||
<Alert.Root>
|
||||
<TriangleAlertIcon />
|
||||
<Alert.Title>Last known status</Alert.Title>
|
||||
<Alert.Description>
|
||||
<p>
|
||||
Kener will repeat the most recent status and latency every minute until your integration sends new data.
|
||||
</p>
|
||||
<ul class="list-disc pl-4">
|
||||
<li>
|
||||
If your integration stops sending, the page keeps showing the last status indefinitely — Kener
|
||||
cannot tell "still up" from "stopped reporting". Use a Heartbeat monitor to catch
|
||||
a silent integration.
|
||||
</li>
|
||||
<li>
|
||||
Carried minutes count toward alert thresholds: a single DOWN push will trigger alerts after your
|
||||
failure threshold, and they stay triggered until you push a recovery.
|
||||
</li>
|
||||
</ul>
|
||||
</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="hidden-switch">Hidden in Status Page</Label>
|
||||
|
||||
+24
-13
@@ -7,6 +7,7 @@
|
||||
import Loader from "@lucide/svelte/icons/loader";
|
||||
import type { MonitorRecord } from "$lib/server/types/db.js";
|
||||
import { toast } from "svelte-sonner";
|
||||
import GC from "$lib/global-constants.js";
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
|
||||
@@ -23,16 +24,23 @@
|
||||
|
||||
let saving = $state(false);
|
||||
|
||||
const isValid = $derived(
|
||||
statusHistoryDays.desktop >= 1 &&
|
||||
statusHistoryDays.desktop <= 365 &&
|
||||
statusHistoryDays.mobile >= 1 &&
|
||||
statusHistoryDays.mobile <= 365
|
||||
const isDesktopValid = $derived(
|
||||
Number.isInteger(statusHistoryDays.desktop) &&
|
||||
statusHistoryDays.desktop >= GC.STATUS_HISTORY_DAYS_MIN &&
|
||||
statusHistoryDays.desktop <= GC.STATUS_HISTORY_DAYS_MAX
|
||||
);
|
||||
const isMobileValid = $derived(
|
||||
Number.isInteger(statusHistoryDays.mobile) &&
|
||||
statusHistoryDays.mobile >= GC.STATUS_HISTORY_DAYS_MIN &&
|
||||
statusHistoryDays.mobile <= GC.STATUS_HISTORY_DAYS_MAX
|
||||
);
|
||||
const isValid = $derived(isDesktopValid && isMobileValid);
|
||||
|
||||
async function save() {
|
||||
if (!isValid) {
|
||||
toast.error("Days must be between 1 and 365");
|
||||
toast.error(
|
||||
`Days must be a whole number between ${GC.STATUS_HISTORY_DAYS_MIN} and ${GC.STATUS_HISTORY_DAYS_MAX}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -99,10 +107,11 @@
|
||||
<Input
|
||||
id="monitor-history-desktop"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
step="1"
|
||||
min={GC.STATUS_HISTORY_DAYS_MIN}
|
||||
max={GC.STATUS_HISTORY_DAYS_MAX}
|
||||
bind:value={statusHistoryDays.desktop}
|
||||
class={statusHistoryDays.desktop < 1 || statusHistoryDays.desktop > 365 ? "border-destructive" : ""}
|
||||
class={isDesktopValid ? "" : "border-destructive"}
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">Number of days shown on desktop screens</p>
|
||||
</div>
|
||||
@@ -111,16 +120,18 @@
|
||||
<Input
|
||||
id="monitor-history-mobile"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
step="1"
|
||||
min={GC.STATUS_HISTORY_DAYS_MIN}
|
||||
max={GC.STATUS_HISTORY_DAYS_MAX}
|
||||
bind:value={statusHistoryDays.mobile}
|
||||
class={statusHistoryDays.mobile < 1 || statusHistoryDays.mobile > 365 ? "border-destructive" : ""}
|
||||
class={isMobileValid ? "" : "border-destructive"}
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">Number of days shown on mobile screens</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
This overrides the page-level default for this monitor. Values must be between 1 and 365.
|
||||
This overrides the page-level default for this monitor. Values must be whole numbers between {GC.STATUS_HISTORY_DAYS_MIN}
|
||||
and {GC.STATUS_HISTORY_DAYS_MAX}.
|
||||
</p>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-end">
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
import { Input } from "$lib/components/ui/input/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import * as Select from "$lib/components/ui/select/index.js";
|
||||
import { Switch } from "$lib/components/ui/switch/index.js";
|
||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||
import MonitorPicker from "$lib/components/MonitorPicker.svelte";
|
||||
import type { MonitorRecord } from "$lib/server/types/db.js";
|
||||
import type { GroupMonitorMember } from "$lib/server/types/monitor.js";
|
||||
import ArrowUp from "@lucide/svelte/icons/arrow-up";
|
||||
import ArrowDown from "@lucide/svelte/icons/arrow-down";
|
||||
import GripVertical from "@lucide/svelte/icons/grip-vertical";
|
||||
import X from "@lucide/svelte/icons/x";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import { resolve } from "$app/paths";
|
||||
|
||||
@@ -58,16 +60,20 @@
|
||||
formData.executionDelay = parsedExecutionDelay;
|
||||
});
|
||||
|
||||
// Filter out GROUP monitors - groups can't contain other groups
|
||||
let eligibleMonitors = $derived(availableMonitors.filter((m) => m.monitor_type !== "GROUP" && m.status === "ACTIVE"));
|
||||
// Filter out GROUP monitors - groups can't contain other groups - and the group being edited itself
|
||||
let eligibleMonitors = $derived(
|
||||
availableMonitors.filter((m) => m.monitor_type !== "GROUP" && m.status === "ACTIVE" && m.tag !== tag)
|
||||
);
|
||||
let selectedTags = $derived(formData.monitors.map((m) => m.tag));
|
||||
|
||||
/** A stale member's monitor is no longer eligible (paused or deleted after being added). */
|
||||
function isStale(monitorTag: string): boolean {
|
||||
return !eligibleMonitors.some((m) => m.tag === monitorTag);
|
||||
}
|
||||
|
||||
let totalWeight = $derived(Math.round(formData.monitors.reduce((sum, m) => sum + m.weight, 0) * 1000) / 1000);
|
||||
let weightsValid = $derived(Math.abs(totalWeight - 1) < 0.001 || formData.monitors.length === 0);
|
||||
|
||||
function findMember(monitorTag: string): GroupMonitorMember | undefined {
|
||||
return formData.monitors.find((m) => m.tag === monitorTag);
|
||||
}
|
||||
|
||||
function isSelected(monitorTag: string): boolean {
|
||||
return formData.monitors.some((m) => m.tag === monitorTag);
|
||||
}
|
||||
@@ -93,6 +99,22 @@
|
||||
distributeEqually();
|
||||
}
|
||||
|
||||
function addMonitors(tags: string[]) {
|
||||
const newTags = tags.filter((t) => !isSelected(t));
|
||||
if (newTags.length === 0) return;
|
||||
formData.monitors = [...formData.monitors, ...newTags.map((t) => ({ tag: t, weight: 0 }))];
|
||||
distributeEqually();
|
||||
}
|
||||
|
||||
function removeMonitor(monitorTag: string) {
|
||||
formData.monitors = formData.monitors.filter((m) => m.tag !== monitorTag);
|
||||
distributeEqually();
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
formData.monitors = [];
|
||||
}
|
||||
|
||||
function setWeight(monitorTag: string, weight: number) {
|
||||
formData.monitors = formData.monitors.map((m) => {
|
||||
if (m.tag !== monitorTag) return m;
|
||||
@@ -129,55 +151,8 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if eligibleMonitors.length > 0}
|
||||
<div class="grid gap-2">
|
||||
{#each eligibleMonitors.filter((m) => m.tag !== tag) as monitor (monitor.id ?? monitor.tag)}
|
||||
{@const member = findMember(monitor.tag)}
|
||||
{@const selected = !!member}
|
||||
<div class="rounded-lg border p-3 transition-colors {selected ? 'bg-primary/5' : ''}">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if monitor.image}
|
||||
<img
|
||||
src={clientResolver(resolve, monitor.image)}
|
||||
alt={monitor.name}
|
||||
class="size-8 rounded object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="bg-muted flex size-8 items-center justify-center rounded text-xs font-medium">
|
||||
{monitor.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<p class="text-sm font-medium">{monitor.name}</p>
|
||||
<p class="text-muted-foreground text-xs">{monitor.tag}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={selected} onCheckedChange={() => toggleMonitor(monitor.tag)} />
|
||||
</div>
|
||||
|
||||
{#if selected && member}
|
||||
<div class="mt-3 flex items-end gap-4 border-t pt-3">
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs">Weight</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={String(member.weight)}
|
||||
class="h-8 w-[100px] text-xs"
|
||||
onchange={(e) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
setWeight(monitor.tag, Number(target.value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if eligibleMonitors.length > 0 || formData.monitors.length > 0}
|
||||
<MonitorPicker monitors={eligibleMonitors} {selectedTags} onToggle={toggleMonitor} onAddMany={addMonitors} />
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-sm">No eligible monitors available. Create some non-group monitors first.</p>
|
||||
{/if}
|
||||
@@ -195,13 +170,24 @@
|
||||
<span class="text-xs">(must equal 1)</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
class="text-muted-foreground hover:text-foreground text-xs underline underline-offset-2"
|
||||
variant="link"
|
||||
size="sm"
|
||||
class="text-muted-foreground hover:text-foreground h-auto p-0 text-xs"
|
||||
onclick={distributeEqually}
|
||||
>
|
||||
Distribute equally
|
||||
</button>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
class="text-muted-foreground hover:text-destructive h-auto p-0 text-xs"
|
||||
onclick={clearAll}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -249,21 +235,59 @@
|
||||
</div>
|
||||
{#each formData.monitors as m, index (m.tag)}
|
||||
{@const monitorInfo = availableMonitors.find((am) => am.tag === m.tag)}
|
||||
{@const stale = isStale(m.tag)}
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-2 {index < formData.monitors.length - 1 ? 'border-b' : ''}"
|
||||
class="flex items-center justify-between gap-2 px-3 py-2 {index < formData.monitors.length - 1
|
||||
? 'border-b'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<GripVertical class="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
<span class="text-muted-foreground text-xs font-medium">{index + 1}.</span>
|
||||
<div>
|
||||
<p class="text-sm">{monitorInfo?.name || m.tag}</p>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{m.tag}
|
||||
<span class="ml-1 text-[10px]">weight {m.weight}</span>
|
||||
{#if monitorInfo?.image}
|
||||
<img
|
||||
src={clientResolver(resolve, monitorInfo.image)}
|
||||
alt={monitorInfo.name}
|
||||
class="size-8 shrink-0 rounded object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="bg-muted flex size-8 shrink-0 items-center justify-center rounded text-xs font-medium">
|
||||
{(monitorInfo?.name || m.tag).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<p class="flex items-center gap-1.5 truncate text-sm">
|
||||
{monitorInfo?.name || m.tag}
|
||||
{#if stale}
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-muted-foreground shrink-0 text-[10px]"
|
||||
title="Not currently checked; excluded from group score"
|
||||
>
|
||||
inactive
|
||||
</Badge>
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-muted-foreground truncate text-xs">{m.tag}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Label class="text-muted-foreground text-xs" for="group-member-weight-{m.tag}">Weight</Label>
|
||||
<Input
|
||||
id="group-member-weight-{m.tag}"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={String(m.weight)}
|
||||
class="h-8 w-[80px] text-xs"
|
||||
onchange={(e) => {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
setWeight(m.tag, Number(target.value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -282,6 +306,15 @@
|
||||
>
|
||||
<ArrowDown class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="hover:text-destructive h-7 w-7"
|
||||
title="Remove from group"
|
||||
onclick={() => removeMonitor(m.tag)}
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -33,10 +33,10 @@
|
||||
// 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
|
||||
};
|
||||
|
||||
interface PageWithMonitors extends PageRecord {
|
||||
@@ -86,6 +86,16 @@
|
||||
|
||||
// Page settings state
|
||||
let pageSettings = $state<PageSettingsType>(structuredClone(defaultPageSettings));
|
||||
const isHistoryDesktopValid = $derived(
|
||||
Number.isInteger(pageSettings.monitor_status_history_days.desktop) &&
|
||||
pageSettings.monitor_status_history_days.desktop >= GC.STATUS_HISTORY_DAYS_MIN &&
|
||||
pageSettings.monitor_status_history_days.desktop <= GC.STATUS_HISTORY_DAYS_MAX
|
||||
);
|
||||
const isHistoryMobileValid = $derived(
|
||||
Number.isInteger(pageSettings.monitor_status_history_days.mobile) &&
|
||||
pageSettings.monitor_status_history_days.mobile >= GC.STATUS_HISTORY_DAYS_MIN &&
|
||||
pageSettings.monitor_status_history_days.mobile <= GC.STATUS_HISTORY_DAYS_MAX
|
||||
);
|
||||
let savingDisplaySettings = $state(false);
|
||||
let savingSeoSettings = $state(false);
|
||||
|
||||
@@ -479,6 +489,13 @@
|
||||
async function savePageSettings(source: "display" | "seo") {
|
||||
if (!currentPage) return;
|
||||
|
||||
if (source === "display" && (!isHistoryDesktopValid || !isHistoryMobileValid)) {
|
||||
toast.error(
|
||||
`Days must be a whole number between ${GC.STATUS_HISTORY_DAYS_MIN} and ${GC.STATUS_HISTORY_DAYS_MAX}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (source === "display") savingDisplaySettings = true;
|
||||
else savingSeoSettings = true;
|
||||
try {
|
||||
@@ -788,9 +805,11 @@
|
||||
<Input
|
||||
id="history-desktop"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
step="1"
|
||||
min={GC.STATUS_HISTORY_DAYS_MIN}
|
||||
max={GC.STATUS_HISTORY_DAYS_MAX}
|
||||
bind:value={pageSettings.monitor_status_history_days.desktop}
|
||||
class={isHistoryDesktopValid ? "" : "border-destructive"}
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">Number of days shown on desktop screens</p>
|
||||
</div>
|
||||
@@ -799,9 +818,11 @@
|
||||
<Input
|
||||
id="history-mobile"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
step="1"
|
||||
min={GC.STATUS_HISTORY_DAYS_MIN}
|
||||
max={GC.STATUS_HISTORY_DAYS_MAX}
|
||||
bind:value={pageSettings.monitor_status_history_days.mobile}
|
||||
class={isHistoryMobileValid ? "" : "border-destructive"}
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">Number of days shown on mobile screens</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,821 @@
|
||||
<script lang="ts">
|
||||
import * as Accordion from "$lib/components/ui/accordion/index.js";
|
||||
import * as Card from "$lib/components/ui/card/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
||||
import * as Sheet from "$lib/components/ui/sheet/index.js";
|
||||
import * as Checkbox from "$lib/components/ui/checkbox/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { Input } from "$lib/components/ui/input/index.js";
|
||||
import { Label } from "$lib/components/ui/label/index.js";
|
||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||
import { Spinner } from "$lib/components/ui/spinner/index.js";
|
||||
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||
import ShieldIcon from "@lucide/svelte/icons/shield";
|
||||
import PlusIcon from "@lucide/svelte/icons/plus";
|
||||
import LockIcon from "@lucide/svelte/icons/lock";
|
||||
import KeyIcon from "@lucide/svelte/icons/key";
|
||||
import UsersIcon from "@lucide/svelte/icons/users";
|
||||
import PencilIcon from "@lucide/svelte/icons/pencil";
|
||||
import CopyIcon from "@lucide/svelte/icons/copy";
|
||||
import TrashIcon from "@lucide/svelte/icons/trash-2";
|
||||
import UserMinusIcon from "@lucide/svelte/icons/user-minus";
|
||||
import UserPlusIcon from "@lucide/svelte/icons/user-plus";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { onMount } from "svelte";
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import type { UserRecordPublic, RoleRecord } from "$lib/server/types/db.js";
|
||||
|
||||
interface Permission {
|
||||
id: string;
|
||||
permission_name: string;
|
||||
}
|
||||
|
||||
interface RoleUser extends UserRecordPublic {
|
||||
roles_id: string;
|
||||
}
|
||||
|
||||
interface PageData {
|
||||
userDb: UserRecordPublic;
|
||||
userPermissions: string[];
|
||||
}
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
let currentUser = $derived(data.userDb);
|
||||
let userPermissions = $derived(data.userPermissions);
|
||||
|
||||
function hasPermission(perm: string): boolean {
|
||||
return userPermissions.includes(perm);
|
||||
}
|
||||
|
||||
// State
|
||||
let loading = $state(true);
|
||||
let roles = $state<RoleRecord[]>([]);
|
||||
let allPermissions = $state<Permission[]>([]);
|
||||
let allUsers = $state<UserRecordPublic[]>([]);
|
||||
|
||||
// Create role dialog
|
||||
let showCreateDialog = $state(false);
|
||||
let creatingRole = $state(false);
|
||||
let createError = $state("");
|
||||
let newRole = $state({ role_id: "", name: "" });
|
||||
let createPermissionMode = $state<"pick" | "clone">("pick");
|
||||
let cloneFromRoleId = $state("");
|
||||
|
||||
// Delete role dialog
|
||||
let showDeleteDialog = $state(false);
|
||||
let deletingRole = $state(false);
|
||||
let roleToDelete = $state<RoleRecord | null>(null);
|
||||
let deleteAction = $state<"migrate" | "remove">("remove");
|
||||
let deleteTargetRoleId = $state("");
|
||||
|
||||
// Edit role dialog
|
||||
let showEditDialog = $state(false);
|
||||
let editingRole = $state(false);
|
||||
let editError = $state("");
|
||||
let roleToEdit = $state<RoleRecord | null>(null);
|
||||
let editRole = $state({ name: "", status: "ACTIVE" });
|
||||
|
||||
// Permissions sheet
|
||||
let showPermissionsSheet = $state(false);
|
||||
let permissionsRole = $state<RoleRecord | null>(null);
|
||||
let rolePermissionIds = $state<Set<string>>(new Set());
|
||||
let savingPermissions = $state(false);
|
||||
let loadingPermissions = $state(false);
|
||||
|
||||
// Users sheet
|
||||
let showUsersSheet = $state(false);
|
||||
let usersRole = $state<RoleRecord | null>(null);
|
||||
let roleUsers = $state<RoleUser[]>([]);
|
||||
let loadingUsers = $state(false);
|
||||
let addingUserId = $state<number | null>(null);
|
||||
let removingUserId = $state<number | null>(null);
|
||||
|
||||
const apiUrl = clientResolver(resolve, "/manage/api");
|
||||
|
||||
async function apiCall(action: string, data: Record<string, unknown> = {}) {
|
||||
const res = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, data })
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.error) throw new Error(result.error);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function fetchRoles() {
|
||||
loading = true;
|
||||
try {
|
||||
const result = await apiCall("getRoles");
|
||||
roles = result;
|
||||
} catch {
|
||||
toast.error("Failed to load roles");
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllPermissions() {
|
||||
try {
|
||||
allPermissions = await apiCall("getAllPermissions");
|
||||
} catch {
|
||||
toast.error("Failed to load permissions");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllUsers() {
|
||||
try {
|
||||
const result = await apiCall("getUsers", { page: 1, limit: 1000 });
|
||||
allUsers = result.users || [];
|
||||
} catch {
|
||||
toast.error("Failed to load users");
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog(prefill?: { role_id: string; name: string; cloneFromRoleId: string }) {
|
||||
if (prefill) {
|
||||
newRole = { role_id: prefill.role_id, name: prefill.name };
|
||||
createPermissionMode = "clone";
|
||||
cloneFromRoleId = prefill.cloneFromRoleId;
|
||||
} else {
|
||||
newRole = { role_id: "", name: "" };
|
||||
createPermissionMode = "pick";
|
||||
cloneFromRoleId = "";
|
||||
}
|
||||
createError = "";
|
||||
showCreateDialog = true;
|
||||
}
|
||||
|
||||
// Create role
|
||||
async function handleCreateRole() {
|
||||
createError = "";
|
||||
if (!newRole.role_id.trim()) {
|
||||
createError = "Role ID is required";
|
||||
return;
|
||||
}
|
||||
if (!newRole.name.trim()) {
|
||||
createError = "Role name is required";
|
||||
return;
|
||||
}
|
||||
if (createPermissionMode === "clone" && !cloneFromRoleId) {
|
||||
createError = "Please select a role to clone permissions from";
|
||||
return;
|
||||
}
|
||||
creatingRole = true;
|
||||
try {
|
||||
const created = await apiCall("createRole", { role_id: newRole.role_id, name: newRole.name });
|
||||
|
||||
// Clone permissions if selected
|
||||
if (createPermissionMode === "clone" && cloneFromRoleId) {
|
||||
const sourcePerms = await apiCall("getRolePermissions", { roleId: cloneFromRoleId });
|
||||
const permIds = sourcePerms.map((p: { permissions_id: string }) => p.permissions_id);
|
||||
if (permIds.length > 0) {
|
||||
await apiCall("updateRolePermissions", {
|
||||
roleId: newRole.role_id.trim().toLowerCase().replace(/\s+/g, "_"),
|
||||
permissionIds: permIds
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toast.success("Role created");
|
||||
showCreateDialog = false;
|
||||
const createdRoleId = newRole.role_id.trim().toLowerCase().replace(/\s+/g, "_");
|
||||
newRole = { role_id: "", name: "" };
|
||||
cloneFromRoleId = "";
|
||||
createPermissionMode = "pick";
|
||||
await fetchRoles();
|
||||
|
||||
// Open permissions sheet for the newly created role
|
||||
const createdRole = roles.find((r) => r.id === createdRoleId);
|
||||
if (createdRole) {
|
||||
openPermissions(createdRole);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
createError = e instanceof Error ? e.message : "Failed to create role";
|
||||
} finally {
|
||||
creatingRole = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete role
|
||||
async function handleDeleteRole() {
|
||||
if (!roleToDelete) return;
|
||||
deletingRole = true;
|
||||
try {
|
||||
const options =
|
||||
deleteAction === "migrate"
|
||||
? { action: "migrate" as const, targetRoleId: deleteTargetRoleId }
|
||||
: { action: "remove" as const };
|
||||
await apiCall("deleteRole", { roleId: roleToDelete.id, options });
|
||||
toast.success("Role deleted");
|
||||
showDeleteDialog = false;
|
||||
roleToDelete = null;
|
||||
await fetchRoles();
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to delete role");
|
||||
} finally {
|
||||
deletingRole = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Edit role
|
||||
function openEditDialog(role: RoleRecord) {
|
||||
roleToEdit = role;
|
||||
editRole = { name: role.role_name, status: role.status };
|
||||
editError = "";
|
||||
showEditDialog = true;
|
||||
}
|
||||
|
||||
async function handleEditRole() {
|
||||
if (!roleToEdit) return;
|
||||
editError = "";
|
||||
if (!editRole.name.trim()) {
|
||||
editError = "Role name is required";
|
||||
return;
|
||||
}
|
||||
editingRole = true;
|
||||
try {
|
||||
await apiCall("updateRole", {
|
||||
roleId: roleToEdit.id,
|
||||
name: editRole.name,
|
||||
status: editRole.status
|
||||
});
|
||||
toast.success("Role updated");
|
||||
showEditDialog = false;
|
||||
roleToEdit = null;
|
||||
await fetchRoles();
|
||||
} catch (e: unknown) {
|
||||
editError = e instanceof Error ? e.message : "Failed to update role";
|
||||
} finally {
|
||||
editingRole = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Open permissions sheet
|
||||
async function openPermissions(role: RoleRecord) {
|
||||
permissionsRole = role;
|
||||
rolePermissionIds = new Set();
|
||||
showPermissionsSheet = true;
|
||||
loadingPermissions = true;
|
||||
try {
|
||||
const perms = await apiCall("getRolePermissions", { roleId: role.id });
|
||||
rolePermissionIds = new Set(perms.map((p: { permissions_id: string }) => p.permissions_id));
|
||||
} catch {
|
||||
toast.error("Failed to load permissions");
|
||||
} finally {
|
||||
loadingPermissions = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Save permissions
|
||||
async function savePermissions() {
|
||||
if (!permissionsRole) return;
|
||||
savingPermissions = true;
|
||||
try {
|
||||
await apiCall("updateRolePermissions", {
|
||||
roleId: permissionsRole.id,
|
||||
permissionIds: Array.from(rolePermissionIds)
|
||||
});
|
||||
toast.success("Permissions updated");
|
||||
showPermissionsSheet = false;
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update permissions");
|
||||
} finally {
|
||||
savingPermissions = false;
|
||||
}
|
||||
}
|
||||
|
||||
function togglePermission(permId: string) {
|
||||
const next = new Set(rolePermissionIds);
|
||||
if (next.has(permId)) {
|
||||
next.delete(permId);
|
||||
} else {
|
||||
next.add(permId);
|
||||
}
|
||||
rolePermissionIds = next;
|
||||
}
|
||||
|
||||
// Open users sheet
|
||||
async function openUsers(role: RoleRecord) {
|
||||
usersRole = role;
|
||||
roleUsers = [];
|
||||
showUsersSheet = true;
|
||||
loadingUsers = true;
|
||||
try {
|
||||
const canAssign = hasPermission("roles.assign_users");
|
||||
const [users] = await Promise.all([
|
||||
apiCall("getRoleUsers", { roleId: role.id }),
|
||||
canAssign ? fetchAllUsers() : Promise.resolve()
|
||||
]);
|
||||
roleUsers = users;
|
||||
} catch {
|
||||
toast.error("Failed to load role users");
|
||||
} finally {
|
||||
loadingUsers = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Add user to role
|
||||
async function addUser(userId: number) {
|
||||
if (!usersRole) return;
|
||||
addingUserId = userId;
|
||||
try {
|
||||
await apiCall("addUserToRole", { roleId: usersRole.id, userId });
|
||||
toast.success("User added to role");
|
||||
roleUsers = await apiCall("getRoleUsers", { roleId: usersRole.id });
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to add user");
|
||||
} finally {
|
||||
addingUserId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove user from role
|
||||
async function removeUser(userId: number) {
|
||||
if (!usersRole) return;
|
||||
removingUserId = userId;
|
||||
try {
|
||||
await apiCall("removeUserFromRole", { roleId: usersRole.id, userId });
|
||||
toast.success("User removed from role");
|
||||
roleUsers = await apiCall("getRoleUsers", { roleId: usersRole.id });
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to remove user");
|
||||
} finally {
|
||||
removingUserId = null;
|
||||
}
|
||||
}
|
||||
|
||||
let groupedPermissions = $derived.by(() => {
|
||||
const groups: Array<{ group: string; label: string; permissions: Permission[] }> = [];
|
||||
const groupMap = new Map<string, Permission[]>();
|
||||
for (const perm of allPermissions) {
|
||||
const dotIndex = perm.id.indexOf(".");
|
||||
const group = dotIndex > -1 ? perm.id.substring(0, dotIndex) : perm.id;
|
||||
if (!groupMap.has(group)) groupMap.set(group, []);
|
||||
groupMap.get(group)!.push(perm);
|
||||
}
|
||||
for (const [group, perms] of groupMap) {
|
||||
const label = group.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
groups.push({ group, label, permissions: perms });
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
function groupGrantedCount(perms: Permission[]): number {
|
||||
return perms.filter((p) => rolePermissionIds.has(p.id)).length;
|
||||
}
|
||||
|
||||
let availableUsersToAdd = $derived(allUsers.filter((u) => !roleUsers.some((ru) => ru.id === u.id)));
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([fetchRoles(), fetchAllPermissions()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="kener-manage flex flex-1 flex-col gap-4 p-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<ShieldIcon class="h-5 w-5" />
|
||||
<h2 class="text-xl font-semibold">Roles</h2>
|
||||
</div>
|
||||
{#if hasPermission("roles.write")}
|
||||
<Button size="sm" onclick={() => openCreateDialog()}>
|
||||
<PlusIcon class="mr-1 h-4 w-4" />
|
||||
Create Role
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Roles Table -->
|
||||
<div class="">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<Spinner class="h-6 w-6" />
|
||||
</div>
|
||||
{:else if roles.length === 0}
|
||||
<div class="text-muted-foreground p-8 text-center text-sm">No roles found</div>
|
||||
{:else}
|
||||
<div class="ktable overflow-hidden rounded-xl border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Role ID</Table.Head>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head>Status</Table.Head>
|
||||
<Table.Head>Type</Table.Head>
|
||||
<Table.Head class="text-right"></Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each roles as role (role.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-mono text-sm">{role.id}</Table.Cell>
|
||||
<Table.Cell>{role.role_name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant={role.status === "ACTIVE" ? "default" : "secondary"}>
|
||||
{role.status}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if role.readonly === 1}
|
||||
<Badge variant="outline">
|
||||
<LockIcon class="mr-1 h-3 w-3" />
|
||||
Readonly
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline">Custom</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" onclick={() => openPermissions(role)}>
|
||||
<KeyIcon class="mr-1 h-4 w-4" />
|
||||
Permissions
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => openUsers(role)}>
|
||||
<UsersIcon class="mr-1 h-4 w-4" />
|
||||
Users
|
||||
</Button>
|
||||
{#if hasPermission("roles.write")}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Duplicate"
|
||||
onclick={() =>
|
||||
openCreateDialog({
|
||||
role_id: role.id + "-copy",
|
||||
name: role.role_name + " Copy",
|
||||
cloneFromRoleId: role.id
|
||||
})}
|
||||
>
|
||||
<CopyIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
{#if role.readonly !== 1 && hasPermission("roles.write")}
|
||||
<Button variant="ghost" size="sm" onclick={() => openEditDialog(role)}>
|
||||
<PencilIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
roleToDelete = role;
|
||||
deleteAction = "remove";
|
||||
deleteTargetRoleId = "";
|
||||
showDeleteDialog = true;
|
||||
}}
|
||||
>
|
||||
<TrashIcon class="text-destructive h-4 w-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Role Dialog -->
|
||||
<Dialog.Root bind:open={showEditDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Edit Role</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Update <span class="font-semibold">{roleToEdit?.role_name}</span> role.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="edit-role-name">Role Name</Label>
|
||||
<Input id="edit-role-name" bind:value={editRole.name} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="edit-role-status">Status</Label>
|
||||
<select
|
||||
id="edit-role-status"
|
||||
class="border-input flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm"
|
||||
bind:value={editRole.status}
|
||||
>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="INACTIVE">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if editError}
|
||||
<p class="text-destructive text-sm">{editError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (showEditDialog = false)}>Cancel</Button>
|
||||
<Button onclick={handleEditRole} disabled={editingRole}>
|
||||
{#if editingRole}
|
||||
<Spinner class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
Save
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Create Role Dialog -->
|
||||
<Dialog.Root bind:open={showCreateDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Create Role</Dialog.Title>
|
||||
<Dialog.Description>Create a new custom role with its own permissions.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="role-id">Role ID</Label>
|
||||
<Input id="role-id" placeholder="e.g. viewer" bind:value={newRole.role_id} />
|
||||
<p class="text-muted-foreground text-xs">Lowercase letters, numbers, underscores, hyphens only.</p>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="role-name">Role Name</Label>
|
||||
<Input id="role-name" placeholder="e.g. Viewer" bind:value={newRole.name} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label>Permissions</Label>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant={createPermissionMode === "pick" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
createPermissionMode = "pick";
|
||||
cloneFromRoleId = "";
|
||||
}}
|
||||
>
|
||||
Pick after creation
|
||||
</Button>
|
||||
<Button
|
||||
variant={createPermissionMode === "clone" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onclick={() => (createPermissionMode = "clone")}
|
||||
>
|
||||
Clone from existing role
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{#if createPermissionMode === "clone"}
|
||||
<div class="grid gap-2">
|
||||
<Label for="clone-role">Clone permissions from</Label>
|
||||
<select
|
||||
id="clone-role"
|
||||
class="border-input flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm"
|
||||
bind:value={cloneFromRoleId}
|
||||
>
|
||||
<option value="">Select a role...</option>
|
||||
{#each roles.filter((r) => r.status === "ACTIVE") as r (r.id)}
|
||||
<option value={r.id}>{r.role_name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
{#if createError}
|
||||
<p class="text-destructive text-sm">{createError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (showCreateDialog = false)}>Cancel</Button>
|
||||
<Button onclick={handleCreateRole} disabled={creatingRole}>
|
||||
{#if creatingRole}
|
||||
<Spinner class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
Create
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Delete Role Dialog -->
|
||||
<Dialog.Root bind:open={showDeleteDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Delete Role</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Are you sure you want to delete <span class="font-semibold">{roleToDelete?.role_name}</span>?
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid gap-2">
|
||||
<Label>What should happen to users in this role?</Label>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant={deleteAction === "remove" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onclick={() => (deleteAction = "remove")}
|
||||
>
|
||||
Remove assignments
|
||||
</Button>
|
||||
<Button
|
||||
variant={deleteAction === "migrate" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onclick={() => (deleteAction = "migrate")}
|
||||
>
|
||||
Migrate to another role
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{#if deleteAction === "migrate"}
|
||||
<div class="grid gap-2">
|
||||
<Label for="target-role">Target Role</Label>
|
||||
<select
|
||||
id="target-role"
|
||||
class="border-input flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm"
|
||||
bind:value={deleteTargetRoleId}
|
||||
>
|
||||
<option value="">Select a role...</option>
|
||||
{#each roles.filter((r) => r.id !== roleToDelete?.id && r.status === "ACTIVE") as r (r.id)}
|
||||
<option value={r.id}>{r.role_name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (showDeleteDialog = false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onclick={handleDeleteRole}
|
||||
disabled={deletingRole || (deleteAction === "migrate" && !deleteTargetRoleId)}
|
||||
>
|
||||
{#if deletingRole}
|
||||
<Spinner class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
Delete
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Permissions Sheet -->
|
||||
<Sheet.Root bind:open={showPermissionsSheet}>
|
||||
<Sheet.Content side="right" class="w-full overflow-y-auto sm:max-w-lg">
|
||||
<Sheet.Header>
|
||||
<Sheet.Title>
|
||||
Permissions — {permissionsRole?.role_name}
|
||||
</Sheet.Title>
|
||||
<Sheet.Description>
|
||||
{#if permissionsRole?.readonly === 1}
|
||||
This is a readonly role. Permissions cannot be modified.
|
||||
{:else}
|
||||
Toggle permissions for this role.
|
||||
{/if}
|
||||
</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
<div class=" p-4">
|
||||
{#if loadingPermissions}
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<Spinner class="h-6 w-6" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-xl border">
|
||||
<Accordion.Root type="multiple">
|
||||
{#each groupedPermissions as group (group.group)}
|
||||
{@const granted = groupGrantedCount(group.permissions)}
|
||||
<Accordion.Item value={group.group}>
|
||||
<Accordion.Trigger class="px-4">
|
||||
<div>
|
||||
<span class="capitalize">{group.label}</span>
|
||||
<Badge
|
||||
variant={granted === group.permissions.length ? "default" : granted > 0 ? "secondary" : "outline"}
|
||||
class="ml-2"
|
||||
>
|
||||
{granted}/{group.permissions.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content class="px-4">
|
||||
<div class="flex flex-col gap-2 pt-0">
|
||||
{#each group.permissions as perm (perm.id)}
|
||||
<Button
|
||||
variant={rolePermissionIds.has(perm.id) ? "outline" : "ghost"}
|
||||
class="h-auto justify-start gap-3 p-3 text-left {rolePermissionIds.has(perm.id)
|
||||
? 'border-primary bg-primary/5'
|
||||
: ''}"
|
||||
disabled={permissionsRole?.readonly === 1 || !hasPermission("roles.assign_permissions")}
|
||||
onclick={() => togglePermission(perm.id)}
|
||||
>
|
||||
<Checkbox.Root
|
||||
checked={rolePermissionIds.has(perm.id)}
|
||||
disabled={permissionsRole?.readonly === 1 || !hasPermission("roles.assign_permissions")}
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium">{perm.permission_name}</span>
|
||||
</div>
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
{/each}
|
||||
</Accordion.Root>
|
||||
</div>
|
||||
|
||||
{#if permissionsRole?.readonly !== 1 && hasPermission("roles.assign_permissions")}
|
||||
<div class="flex justify-end gap-2 p-4">
|
||||
<Button variant="outline" onclick={() => (showPermissionsSheet = false)}>Cancel</Button>
|
||||
<Button onclick={savePermissions} disabled={savingPermissions}>
|
||||
{#if savingPermissions}
|
||||
<Spinner class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
Save Permissions
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
|
||||
<!-- Users Sheet -->
|
||||
<Sheet.Root bind:open={showUsersSheet}>
|
||||
<Sheet.Content side="right" class="w-full overflow-y-auto sm:max-w-lg">
|
||||
<Sheet.Header>
|
||||
<Sheet.Title>
|
||||
Users — {usersRole?.role_name}
|
||||
</Sheet.Title>
|
||||
<Sheet.Description>Manage users assigned to this role.</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
|
||||
{#if loadingUsers}
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<Spinner class="h-6 w-6" />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Current users in role -->
|
||||
<div class="p-4">
|
||||
<h4 class="mb-2 text-sm font-medium">Current Users ({roleUsers.length})</h4>
|
||||
{#if roleUsers.length === 0}
|
||||
<p class="text-muted-foreground text-sm">No users assigned to this role.</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each roleUsers as user (user.id)}
|
||||
<div class="flex items-center justify-between rounded-md border p-3">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium">{user.name}</span>
|
||||
<span class="text-muted-foreground text-xs">{user.email}</span>
|
||||
</div>
|
||||
{#if hasPermission("roles.assign_users")}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={removingUserId === user.id}
|
||||
onclick={() => removeUser(user.id)}
|
||||
>
|
||||
{#if removingUserId === user.id}
|
||||
<Spinner class="h-4 w-4" />
|
||||
{:else}
|
||||
<UserMinusIcon class="text-destructive h-4 w-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if hasPermission("roles.assign_users")}
|
||||
<Separator />
|
||||
|
||||
<!-- Add users -->
|
||||
<div class="p-4">
|
||||
<h4 class="mb-2 text-sm font-medium">Add Users</h4>
|
||||
{#if availableUsersToAdd.length === 0}
|
||||
<p class="text-muted-foreground text-sm">All users are already in this role.</p>
|
||||
{:else}
|
||||
<div class="flex max-h-64 flex-col gap-2 overflow-y-auto">
|
||||
{#each availableUsersToAdd as user (user.id)}
|
||||
<div class="flex items-center justify-between rounded-md border p-3">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium">{user.name}</span>
|
||||
<span class="text-muted-foreground text-xs">{user.email}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={addingUserId === user.id}
|
||||
onclick={() => addUser(user.id)}
|
||||
>
|
||||
{#if addingUserId === user.id}
|
||||
<Spinner class="h-4 w-4" />
|
||||
{:else}
|
||||
<UserPlusIcon class="h-4 w-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
@@ -29,7 +29,6 @@
|
||||
SitemapXMLConfig,
|
||||
GlobalMaintenanceNotificationSettings
|
||||
} from "$lib/types/site.js";
|
||||
|
||||
interface NavItem {
|
||||
name: string;
|
||||
url: string;
|
||||
|
||||
@@ -396,9 +396,11 @@
|
||||
<p class="text-muted-foreground text-sm">Override the default JSON payload</p>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Use Mustache variables like <code class="bg-muted rounded px-1">{"{{variable}}"}</code>. Available: id,
|
||||
alert_name, severity, status, source, timestamp, description, metric, current_value, threshold,
|
||||
action_text, action_url
|
||||
Use Mustache variables like <code class="bg-muted rounded px-1">{"{{variable}}"}</code>. Available:
|
||||
alert_id, alert_name, alert_for, alert_value, alert_status, alert_severity, alert_message, alert_source,
|
||||
alert_timestamp, alert_cta_url, alert_cta_text, alert_incident_id, alert_incident_url,
|
||||
alert_failure_threshold, alert_success_threshold, is_resolved, is_triggered, site_url, site_name,
|
||||
site_logo_url, colors_up, colors_down, colors_degraded, colors_maintenance
|
||||
</p>
|
||||
<div class="overflow-hidden rounded-md border">
|
||||
<Textarea bind:value={trigger.trigger_meta.webhook_body} />
|
||||
@@ -414,8 +416,11 @@
|
||||
<p class="text-muted-foreground text-sm">Override the default Discord message</p>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Use Mustache variables. Available: site_name, logo_url, alert_name, status, is_triggered, is_resolved,
|
||||
description, metric, severity, id, current_value, threshold, source, action_text, action_url
|
||||
Use Mustache variables. Available: alert_id, alert_name, alert_for, alert_value, alert_status,
|
||||
alert_severity, alert_message, alert_source, alert_timestamp, alert_cta_url, alert_cta_text,
|
||||
alert_incident_id, alert_incident_url, alert_failure_threshold, alert_success_threshold, is_resolved,
|
||||
is_triggered, site_url, site_name, site_logo_url, colors_up, colors_down, colors_degraded,
|
||||
colors_maintenance
|
||||
</p>
|
||||
<div class="overflow-hidden rounded-md border">
|
||||
<Textarea bind:value={trigger.trigger_meta.discord_body} />
|
||||
@@ -431,8 +436,11 @@
|
||||
<p class="text-muted-foreground text-sm">Override the default Slack message</p>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Use Mustache variables. Available: site_name, logo_url, alert_name, status, is_triggered, is_resolved,
|
||||
description, metric, severity, id, current_value, threshold, source, action_text, action_url
|
||||
Use Mustache variables. Available: alert_id, alert_name, alert_for, alert_value, alert_status,
|
||||
alert_severity, alert_message, alert_source, alert_timestamp, alert_cta_url, alert_cta_text,
|
||||
alert_incident_id, alert_incident_url, alert_failure_threshold, alert_success_threshold, is_resolved,
|
||||
is_triggered, site_url, site_name, site_logo_url, colors_up, colors_down, colors_degraded,
|
||||
colors_maintenance
|
||||
</p>
|
||||
<div class="overflow-hidden rounded-md border">
|
||||
<Textarea bind:value={trigger.trigger_meta.slack_body} />
|
||||
@@ -482,9 +490,11 @@
|
||||
<p class="text-muted-foreground text-sm">Create your own email design</p>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Use Mustache variables. Available: site_name, site_url, logo_url, alert_name, status, severity,
|
||||
description, metric, current_value, threshold, action_text, action_url, is_triggered, is_resolved,
|
||||
color_up, color_down, color_degraded, color_maintenance
|
||||
Use Mustache variables. Available: alert_id, alert_name, alert_for, alert_value, alert_status,
|
||||
alert_severity, alert_message, alert_source, alert_timestamp, alert_cta_url, alert_cta_text,
|
||||
alert_incident_id, alert_incident_url, alert_failure_threshold, alert_success_threshold, is_resolved,
|
||||
is_triggered, site_url, site_name, site_logo_url, colors_up, colors_down, colors_degraded,
|
||||
colors_maintenance
|
||||
</p>
|
||||
<div class="overflow-hidden rounded-md border">
|
||||
<CodeMirror
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as Card from "$lib/components/ui/card/index.js";
|
||||
import * as Table from "$lib/components/ui/table/index.js";
|
||||
import * as Select from "$lib/components/ui/select/index.js";
|
||||
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
||||
import * as Sheet from "$lib/components/ui/sheet/index.js";
|
||||
import * as Alert from "$lib/components/ui/alert/index.js";
|
||||
import { Checkbox } from "$lib/components/ui/checkbox/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { Input } from "$lib/components/ui/input/index.js";
|
||||
import { Label } from "$lib/components/ui/label/index.js";
|
||||
@@ -27,7 +27,7 @@
|
||||
import { toast } from "svelte-sonner";
|
||||
import { format } from "date-fns";
|
||||
import { onMount } from "svelte";
|
||||
import type { UserRecordDashboard, UserRecordPublic } from "$lib/server/types/db.js";
|
||||
import type { UserRecordDashboard, UserRecordPublic, RoleRecord } from "$lib/server/types/db.js";
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
interface NewUser {
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
role_ids: string[];
|
||||
}
|
||||
|
||||
interface EditUser extends UserRecordDashboard {
|
||||
@@ -50,6 +50,7 @@
|
||||
|
||||
interface PageData {
|
||||
userDb: UserRecordPublic;
|
||||
userPermissions: string[];
|
||||
canSendEmail: boolean;
|
||||
}
|
||||
|
||||
@@ -57,15 +58,22 @@
|
||||
|
||||
// Derived from data
|
||||
let currentUser = $derived(data.userDb);
|
||||
let userPermissions = $derived(data.userPermissions);
|
||||
let canSendEmail = $derived(data.canSendEmail);
|
||||
|
||||
function hasPermission(perm: string): boolean {
|
||||
return userPermissions.includes(perm);
|
||||
}
|
||||
|
||||
// State
|
||||
let loading = $state(true);
|
||||
let users = $state<UserRecordDashboard[]>([]);
|
||||
let roles = $state<RoleRecord[]>([]);
|
||||
let page = $state(1);
|
||||
let limit = $state(10);
|
||||
let total = $state(0);
|
||||
let totalPages = $state(0);
|
||||
let statusFilter = $state<"ACTIVE" | "INACTIVE">("ACTIVE");
|
||||
|
||||
// Add user modal state
|
||||
let showAddUserDialog = $state(false);
|
||||
@@ -74,8 +82,7 @@
|
||||
let newUser = $state<NewUser>({
|
||||
name: "",
|
||||
email: "",
|
||||
|
||||
role: "member"
|
||||
role_ids: []
|
||||
});
|
||||
|
||||
// Edit user sheet state
|
||||
@@ -104,7 +111,7 @@
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "getUsers",
|
||||
data: { page, limit }
|
||||
data: { page, limit, is_active: statusFilter === "ACTIVE" ? 1 : 0 }
|
||||
})
|
||||
});
|
||||
const result = await res.json();
|
||||
@@ -148,8 +155,8 @@
|
||||
creatingUserError = "Please enter a valid email address";
|
||||
return;
|
||||
}
|
||||
if (!["admin", "editor", "member"].includes(newUser.role)) {
|
||||
creatingUserError = "Invalid role selected";
|
||||
if (newUser.role_ids.length === 0) {
|
||||
creatingUserError = "At least one role must be selected";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -189,8 +196,7 @@
|
||||
newUser = {
|
||||
name: "",
|
||||
email: "",
|
||||
|
||||
role: "member"
|
||||
role_ids: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -198,7 +204,6 @@
|
||||
function openSettingsSheet(user: UserRecordDashboard) {
|
||||
toEditUser = {
|
||||
...JSON.parse(JSON.stringify(user)),
|
||||
|
||||
actions: {
|
||||
sendingVerificationEmail: false,
|
||||
resendingInvitation: false,
|
||||
@@ -308,32 +313,79 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Role badge variant
|
||||
function getRoleBadgeVariant(role: string): "default" | "secondary" | "outline" {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return "default";
|
||||
case "editor":
|
||||
return "secondary";
|
||||
default:
|
||||
return "outline";
|
||||
// Role badge variant by precedence: admin > editor > others
|
||||
function getRoleBadgeVariant(roleIds: string[]): "default" | "secondary" | "outline" {
|
||||
if (roleIds.includes("admin")) return "default";
|
||||
if (roleIds.includes("editor")) return "secondary";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
let activeRoles = $derived(roles.filter((r) => r.status === "ACTIVE"));
|
||||
|
||||
// Fetch roles
|
||||
async function fetchRoles() {
|
||||
try {
|
||||
const res = await fetch(clientResolver(resolve, "/manage/api"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "getRoles", data: {} })
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!result.error) {
|
||||
roles = result;
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to load roles");
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRole(roleId: string, currentList: string[]): string[] {
|
||||
if (currentList.includes(roleId)) {
|
||||
return currentList.filter((r) => r !== roleId);
|
||||
} else {
|
||||
return [...currentList, roleId];
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load
|
||||
onMount(() => {
|
||||
fetchUsers();
|
||||
fetchRoles();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto space-y-6 py-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-end">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant={statusFilter === "ACTIVE" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
statusFilter = "ACTIVE";
|
||||
page = 1;
|
||||
fetchUsers();
|
||||
}}
|
||||
>
|
||||
Active
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === "INACTIVE" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
statusFilter = "INACTIVE";
|
||||
page = 1;
|
||||
fetchUsers();
|
||||
}}
|
||||
>
|
||||
Inactive
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if loading}
|
||||
<Spinner class="size-5" />
|
||||
{/if}
|
||||
{#if currentUser.role === "admin" || currentUser.role === "editor"}
|
||||
{#if hasPermission("users.write")}
|
||||
{#if !canSendEmail}
|
||||
<p class="text-muted-foreground max-w-xs text-xs">
|
||||
Email service not configured. Cannot invite new users. Please go to
|
||||
@@ -394,8 +446,8 @@
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant={getRoleBadgeVariant(user.role)} class="uppercase">
|
||||
{user.role}
|
||||
<Badge variant={getRoleBadgeVariant(user.role_ids)} class="uppercase">
|
||||
{user.role_ids.join(", ")}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
@@ -406,7 +458,7 @@
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-center">
|
||||
{#if currentUser.role === "admin" && currentUser.id !== user.id && (user.role !== "admin" || currentUser.is_owner === "YES")}
|
||||
{#if hasPermission("users.write") && currentUser.id !== user.id}
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" onclick={() => openSettingsSheet(user)}>
|
||||
<SettingsIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -486,19 +538,23 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="role">Role</Label>
|
||||
<Select.Root type="single" value={newUser.role} onValueChange={(v) => v && (newUser.role = v)}>
|
||||
<Select.Trigger class="w-full">
|
||||
{newUser.role.toUpperCase()}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#if currentUser.role === "admin"}
|
||||
<Select.Item value="admin">ADMIN</Select.Item>
|
||||
{/if}
|
||||
<Select.Item value="editor">EDITOR</Select.Item>
|
||||
<Select.Item value="member">MEMBER</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Label>Roles</Label>
|
||||
<div class="space-y-2">
|
||||
{#each activeRoles as role (role.id)}
|
||||
<label class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={newUser.role_ids.includes(role.id)}
|
||||
onCheckedChange={() => {
|
||||
newUser.role_ids = toggleRole(role.id, newUser.role_ids);
|
||||
}}
|
||||
/>
|
||||
<span class="text-sm uppercase">{role.role_name}</span>
|
||||
</label>
|
||||
{/each}
|
||||
{#if activeRoles.length === 0}
|
||||
<p class="text-muted-foreground text-sm">No roles available</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if creatingUserError}
|
||||
<p class="text-destructive text-sm font-medium">{creatingUserError}</p>
|
||||
@@ -578,42 +634,41 @@
|
||||
<Card.Root>
|
||||
<Card.Content class="p-4">
|
||||
<p class="mb-3 text-sm">
|
||||
Change the role of the user. The user will have different permissions based on the role.
|
||||
Change the roles of the user. The user will have different permissions based on assigned roles.
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={toEditUser.role}
|
||||
onValueChange={(v) => v && (toEditUser!.role = v)}
|
||||
disabled={toEditUser.actions.updatingRole}
|
||||
>
|
||||
<Select.Trigger class="w-48">
|
||||
{toEditUser.role.toUpperCase()}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#if currentUser.role === "admin"}
|
||||
<Select.Item value="admin">ADMIN</Select.Item>
|
||||
{/if}
|
||||
<Select.Item value="editor">EDITOR</Select.Item>
|
||||
<Select.Item value="member">MEMBER</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={toEditUser.actions.updatingRole}
|
||||
onclick={() => {
|
||||
toEditUser!.actions.updatingRole = true;
|
||||
manualUpdateData("role").then(() => {
|
||||
toEditUser!.actions.updatingRole = false;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{#if toEditUser.actions.updatingRole}
|
||||
<Spinner class="size-4" />
|
||||
{/if}
|
||||
Update Role
|
||||
</Button>
|
||||
<div class="space-y-2">
|
||||
{#each activeRoles as role (role.id)}
|
||||
<label class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={toEditUser.role_ids.includes(role.id)}
|
||||
disabled={toEditUser.actions.updatingRole}
|
||||
onCheckedChange={() => {
|
||||
toEditUser!.role_ids = toggleRole(role.id, toEditUser!.role_ids);
|
||||
}}
|
||||
/>
|
||||
<span class="text-sm uppercase">{role.role_name}</span>
|
||||
</label>
|
||||
{/each}
|
||||
{#if activeRoles.length === 0}
|
||||
<p class="text-muted-foreground text-sm">No roles available</p>
|
||||
{/if}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="mt-3"
|
||||
disabled={toEditUser.actions.updatingRole || toEditUser.role_ids.length === 0}
|
||||
onclick={() => {
|
||||
toEditUser!.actions.updatingRole = true;
|
||||
manualUpdateData("role").then(() => {
|
||||
toEditUser!.actions.updatingRole = false;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{#if toEditUser.actions.updatingRole}
|
||||
<Spinner class="size-4" />
|
||||
{/if}
|
||||
Update Roles
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import * as Card from "$lib/components/ui/card/index.js";
|
||||
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { Input } from "$lib/components/ui/input/index.js";
|
||||
import { Label } from "$lib/components/ui/label/index.js";
|
||||
import { Spinner } from "$lib/components/ui/spinner/index.js";
|
||||
import { Textarea } from "$lib/components/ui/textarea/index.js";
|
||||
import KeyIcon from "@lucide/svelte/icons/key";
|
||||
import PlusIcon from "@lucide/svelte/icons/plus";
|
||||
import PencilIcon from "@lucide/svelte/icons/pencil";
|
||||
import TrashIcon from "@lucide/svelte/icons/trash-2";
|
||||
import EyeIcon from "@lucide/svelte/icons/eye";
|
||||
import EyeOffIcon from "@lucide/svelte/icons/eye-off";
|
||||
import SaveIcon from "@lucide/svelte/icons/save";
|
||||
import XIcon from "@lucide/svelte/icons/x";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { onMount } from "svelte";
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
|
||||
// Types
|
||||
interface VaultSecret {
|
||||
id: number;
|
||||
secret_name: string;
|
||||
secret_value: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// State
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let secrets = $state<VaultSecret[]>([]);
|
||||
let visibleSecrets = new SvelteSet<number>();
|
||||
|
||||
// Form state
|
||||
let isEditing = $state(false);
|
||||
let editingId = $state<number | null>(null);
|
||||
let formName = $state("");
|
||||
let formValue = $state("");
|
||||
|
||||
// Delete confirmation state
|
||||
let deleteDialogOpen = $state(false);
|
||||
let secretToDelete = $state<VaultSecret | null>(null);
|
||||
let isDeleting = $state(false);
|
||||
|
||||
// API functions
|
||||
async function loadSecrets() {
|
||||
try {
|
||||
const res = await fetch(clientResolver(resolve, "/manage/api"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "getVaultSecrets" })
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else if (Array.isArray(result)) {
|
||||
secrets = result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading secrets:", error);
|
||||
toast.error("Failed to load secrets");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSecret() {
|
||||
if (!formName.trim()) {
|
||||
toast.error("Secret name is required");
|
||||
return;
|
||||
}
|
||||
if (!formValue.trim()) {
|
||||
toast.error("Secret value is required");
|
||||
return;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
const action = editingId ? "updateVaultSecret" : "createVaultSecret";
|
||||
const payload: Record<string, unknown> = {
|
||||
action,
|
||||
data: {
|
||||
secret_name: formName.trim(),
|
||||
secret_value: formValue
|
||||
}
|
||||
};
|
||||
if (editingId) {
|
||||
payload.data = { ...(payload.data as object), id: editingId };
|
||||
}
|
||||
|
||||
const res = await fetch(clientResolver(resolve, "/manage/api"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success(editingId ? "Secret updated successfully" : "Secret created successfully");
|
||||
resetForm();
|
||||
await loadSecrets();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving secret:", error);
|
||||
toast.error("Failed to save secret");
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!secretToDelete) return;
|
||||
|
||||
isDeleting = true;
|
||||
try {
|
||||
const res = await fetch(clientResolver(resolve, "/manage/api"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "deleteVaultSecret",
|
||||
data: { id: secretToDelete.id }
|
||||
})
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success("Secret deleted successfully");
|
||||
await loadSecrets();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting secret:", error);
|
||||
toast.error("Failed to delete secret");
|
||||
} finally {
|
||||
isDeleting = false;
|
||||
deleteDialogOpen = false;
|
||||
secretToDelete = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(secret: VaultSecret) {
|
||||
editingId = secret.id;
|
||||
formName = secret.secret_name;
|
||||
formValue = secret.secret_value;
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
editingId = null;
|
||||
formName = "";
|
||||
formValue = "";
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingId = null;
|
||||
formName = "";
|
||||
formValue = "";
|
||||
isEditing = false;
|
||||
}
|
||||
|
||||
function openDeleteDialog(secret: VaultSecret) {
|
||||
secretToDelete = secret;
|
||||
deleteDialogOpen = true;
|
||||
}
|
||||
|
||||
function toggleSecretVisibility(id: number) {
|
||||
if (visibleSecrets.has(id)) {
|
||||
visibleSecrets.delete(id);
|
||||
} else {
|
||||
visibleSecrets.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
function maskValue(value: string): string {
|
||||
return "*".repeat(Math.min(value.length, 20));
|
||||
}
|
||||
|
||||
// Initial load
|
||||
onMount(async () => {
|
||||
await loadSecrets();
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto space-y-6 py-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<KeyIcon class="text-muted-foreground size-6" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Vault</h1>
|
||||
<p class="text-muted-foreground text-sm">Securely store and manage your secrets</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if !isEditing}
|
||||
<Button onclick={startCreate}>
|
||||
<PlusIcon class="size-4" />
|
||||
Add Secret
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex h-96 items-center justify-center">
|
||||
<Spinner class="size-8" />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Form Card -->
|
||||
{#if isEditing}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{editingId ? "Edit Secret" : "Add New Secret"}</Card.Title>
|
||||
<Card.Description>
|
||||
{editingId ? "Update the secret details below" : "Enter the secret name and value below"}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="secret-name">Secret Name</Label>
|
||||
<Input id="secret-name" placeholder="e.g., API_KEY" bind:value={formName} disabled={saving} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="secret-value">Secret Value</Label>
|
||||
<Textarea
|
||||
id="secret-value"
|
||||
placeholder="Enter secret value..."
|
||||
bind:value={formValue}
|
||||
disabled={saving}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-end gap-2">
|
||||
<Button variant="outline" onclick={resetForm} disabled={saving}>
|
||||
<XIcon class="size-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onclick={saveSecret} disabled={saving}>
|
||||
{#if saving}
|
||||
<Spinner class="size-4" />
|
||||
{:else}
|
||||
<SaveIcon class="size-4" />
|
||||
{/if}
|
||||
{editingId ? "Update Secret" : "Save Secret"}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Secrets List -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex items-center gap-2">
|
||||
<KeyIcon class="text-muted-foreground size-5" />
|
||||
<div>
|
||||
<Card.Title>Stored Secrets</Card.Title>
|
||||
<Card.Description>All secrets are encrypted using KENER_SECRET_KEY</Card.Description>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if secrets.length === 0}
|
||||
<div class="text-muted-foreground py-8 text-center">
|
||||
No secrets stored yet. Click "Add Secret" to create your first secret.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each secrets as secret (secret.id)}
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="flex-1 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<KeyIcon class="text-muted-foreground size-4" />
|
||||
<span class="font-medium">{secret.secret_name}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="bg-muted rounded px-2 py-1 font-mono text-sm">
|
||||
{visibleSecrets.has(secret.id) ? secret.secret_value : maskValue(secret.secret_value)}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="size-8 p-0"
|
||||
onclick={() => toggleSecretVisibility(secret.id)}
|
||||
>
|
||||
{#if visibleSecrets.has(secret.id)}
|
||||
<EyeOffIcon class="size-4" />
|
||||
{:else}
|
||||
<EyeIcon class="size-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onclick={() => startEdit(secret)} disabled={isEditing}>
|
||||
<PencilIcon class="size-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onclick={() => openDeleteDialog(secret)} disabled={isEditing}>
|
||||
<TrashIcon class="size-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<AlertDialog.Root bind:open={deleteDialogOpen}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete Secret</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Are you sure you want to delete the secret "{secretToDelete?.secret_name}"? This action cannot be undone.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel disabled={isDeleting}>Cancel</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
onclick={confirmDelete}
|
||||
disabled={isDeleting}
|
||||
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{#if isDeleting}
|
||||
<Spinner class="size-4" />
|
||||
{/if}
|
||||
Delete
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -59,15 +59,14 @@
|
||||
|
||||
// Role badge styling
|
||||
let roleBadgeClass = $derived.by(() => {
|
||||
switch (user.role) {
|
||||
case "admin":
|
||||
return "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300";
|
||||
case "editor":
|
||||
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300";
|
||||
case "member":
|
||||
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
|
||||
if (user.role_ids.includes("admin")) {
|
||||
return "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300";
|
||||
} else if (user.role_ids.includes("editor")) {
|
||||
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300";
|
||||
} else if (user.role_ids.includes("member")) {
|
||||
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300";
|
||||
} else {
|
||||
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
|
||||
}
|
||||
});
|
||||
|
||||
@@ -227,7 +226,7 @@
|
||||
{user.email}
|
||||
</span>
|
||||
<span class="text-foreground rounded-sm font-medium uppercase">
|
||||
{user.role}
|
||||
{user.role_ids.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
</Dialog.Description>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
|
||||
// This boundary renders when a route group's layout load fails (e.g. the
|
||||
// database is unreachable), so it must not depend on app CSS or any server
|
||||
// data — everything here is self-contained.
|
||||
const isServerFailure = $derived(page.status >= 500);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{page.status} — {isServerFailure ? "Status page temporarily unavailable" : "Something went wrong"}</title>
|
||||
{#if isServerFailure}
|
||||
<meta http-equiv="refresh" content="30" />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<div class="error-wrap">
|
||||
<div class="error-card">
|
||||
{#if isServerFailure}
|
||||
<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>
|
||||
{:else}
|
||||
<h1>Something went wrong</h1>
|
||||
<p>{page.error?.message || "The page you requested could not be loaded."}</p>
|
||||
{/if}
|
||||
<div class="error-code">{page.status}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
}
|
||||
.error-wrap {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #ffffff;
|
||||
color: #09090b;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
}
|
||||
.error-card {
|
||||
max-width: 28rem;
|
||||
margin: 1rem;
|
||||
padding: 2rem;
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
p {
|
||||
color: #71717a;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.error-code {
|
||||
color: #71717a;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.error-wrap {
|
||||
background: #09090b;
|
||||
color: #fafafa;
|
||||
}
|
||||
.error-card {
|
||||
border-color: #27272a;
|
||||
}
|
||||
p,
|
||||
.error-code {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+24
-96
@@ -1,110 +1,38 @@
|
||||
@import url("https://fonts.bunny.net/css?family=bodoni-moda:400,400i,500,500i,600,600i,700,700i,800,800i,900,900i&family=plus-jakarta-sans:300,400,500,600,700,800");
|
||||
@import url("https://fonts.bunny.net/css?family=geist:400,500,600,700|geist-mono:400,500");
|
||||
|
||||
/* Prose/Content Body Styles - needed for markdown rendering */
|
||||
.docs-content-body {
|
||||
font-size: 1rem;
|
||||
line-height: 1.75;
|
||||
/* Docs typography + brand accent.
|
||||
Single family (Geist) with weight contrast; Geist Mono for code and labels.
|
||||
Brand accent is the Kener logo orange (#ff6600 = oklch(0.696 0.204 43.5)),
|
||||
deepened in light mode so it passes AA as text on white (4.6:1) and
|
||||
brightened in dark mode (6.9:1 on the dark background).
|
||||
Tokens live on body so portaled dialogs inherit them too. */
|
||||
body {
|
||||
font-family:
|
||||
"Geist",
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
--primary: oklch(0.58 0.175 43);
|
||||
--primary-foreground: oklch(0.985 0.005 43);
|
||||
--ring: oklch(0.58 0.175 43);
|
||||
}
|
||||
|
||||
.docs-content-body h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
.dark body {
|
||||
--primary: oklch(0.7 0.18 43);
|
||||
--primary-foreground: oklch(0.18 0.035 43);
|
||||
--ring: oklch(0.7 0.18 43);
|
||||
}
|
||||
|
||||
.docs-content-body h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.docs-content-body h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.docs-content-body p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.docs-content-body a {
|
||||
color: var(--accent-foreground);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.docs-content-body a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.docs-content-body code:not(pre code) {
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.875em;
|
||||
background-color: var(--muted);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.docs-content-body pre {
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--muted) !important;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.docs-content-body pre code {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.docs-content-body ul,
|
||||
.docs-content-body ol {
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.docs-content-body li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.docs-content-body table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.docs-content-body th,
|
||||
.docs-content-body td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.docs-content-body th {
|
||||
background-color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.docs-content-body blockquote {
|
||||
border-left: 4px solid var(--accent-foreground);
|
||||
padding-left: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: var(--muted-foreground);
|
||||
font-style: italic;
|
||||
::selection {
|
||||
background: color-mix(in oklch, var(--primary) 25%, transparent);
|
||||
}
|
||||
|
||||
/* Prose dark mode fixes */
|
||||
.dark .prose {
|
||||
--tw-prose-body: var(--foreground);
|
||||
--tw-prose-headings: var(--foreground);
|
||||
--tw-prose-links: var(--accent-foreground);
|
||||
--tw-prose-links: var(--primary);
|
||||
--tw-prose-bold: var(--foreground);
|
||||
--tw-prose-code: var(--foreground);
|
||||
--tw-prose-pre-bg: var(--muted);
|
||||
|
||||
@@ -323,3 +323,23 @@ body::-webkit-scrollbar {
|
||||
.theme-plus-bar button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Frosted backdrop behind the fixed nav (KenerNav) and the sticky theme-plus bar (ThemePlus).
|
||||
The theme-plus bar has no background of its own, and the gap between it and the fixed nav
|
||||
stays transparent, so page content shows through both sections while scrolling. This paints
|
||||
one blurred layer behind both: above page content, below the nav (z-10) and the bar (z-20).
|
||||
Override --top-glass-h if the nav/bar wrap onto extra lines. */
|
||||
.kener-public::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0 0 auto 0;
|
||||
height: var(--top-glass-h, 7.5rem);
|
||||
z-index: 1;
|
||||
background-color: color-mix(in oklab, var(--background) 80%, transparent);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(12px);
|
||||
pointer-events: none;
|
||||
}
|
||||
:is(.dark) .kener-public::before {
|
||||
background-color: color-mix(in oklab, var(--background) 70%, transparent);
|
||||
}
|
||||
|
||||
+27
-5
@@ -18,7 +18,13 @@
|
||||
}
|
||||
|
||||
.prose :where(a):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
@apply text-foreground hover:underline;
|
||||
@apply text-primary hover:underline;
|
||||
}
|
||||
|
||||
.prose code,
|
||||
.prose pre,
|
||||
.prose .code-block {
|
||||
font-family: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
@@ -30,8 +36,6 @@
|
||||
word-break: keep-all;
|
||||
display: inline-block;
|
||||
opacity: 0.85;
|
||||
font-family:
|
||||
"SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
.prose pre code,
|
||||
@@ -81,8 +85,8 @@
|
||||
|
||||
.prose p,
|
||||
.prose li {
|
||||
@apply text-foreground/70;
|
||||
font-weight: 300;
|
||||
@apply text-foreground/75;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.prose.user-message p,
|
||||
@@ -100,6 +104,24 @@
|
||||
@apply border-border border-t;
|
||||
}
|
||||
|
||||
/* Blockquotes: full hairline border + brand tint (replaces the typography
|
||||
plugin's thick border-left stripe and its quote marks) */
|
||||
.prose blockquote {
|
||||
border: 1px solid color-mix(in oklch, var(--primary) 25%, transparent);
|
||||
border-inline-start-width: 1px;
|
||||
background: color-mix(in oklch, var(--primary) 5%, transparent);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.5rem 1rem;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.prose blockquote p:first-of-type::before,
|
||||
.prose blockquote p:last-of-type::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.prose {
|
||||
:where(code):not(:where([class~="not-prose"], [class~="not-prose"] *))::before {
|
||||
content: "";
|
||||
|
||||
@@ -950,7 +950,8 @@
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "The home page (addressed as `~home`) accepts all fields except `page_path`, which is fixed and returns `400` if changed."
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["Pages"],
|
||||
@@ -973,7 +974,8 @@
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "The home page (addressed as `~home`) cannot be deleted and returns `400`."
|
||||
}
|
||||
},
|
||||
"/api/v4/site": {
|
||||
@@ -1584,8 +1586,19 @@
|
||||
"name": "page_path",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "The page's path segment. Use the special token `~home` to address the home page, whose stored `page_path` is an empty string and can not appear as a URL segment. No real page can be named `~home` because the path sanitizer strips `~`.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"examples": {
|
||||
"regular": {
|
||||
"value": "services",
|
||||
"summary": "A regular page"
|
||||
},
|
||||
"home": {
|
||||
"value": "~home",
|
||||
"summary": "The home page (empty page_path)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConfigKey": {
|
||||
@@ -1969,6 +1982,12 @@
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MonitorImpact"
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Absolute URL of the public incident page.",
|
||||
"examples": ["https://status.example.com/incidents/12"]
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
@@ -2082,7 +2101,9 @@
|
||||
"required": ["page_path", "page_title", "page_header"],
|
||||
"properties": {
|
||||
"page_path": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "The page's path segment. The home page renders as the addressable token `~home` (its stored path is empty); its public URL is the site root.",
|
||||
"examples": ["services", "~home"]
|
||||
},
|
||||
"page_title": {
|
||||
"type": "string"
|
||||
@@ -2097,8 +2118,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"page_settings": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"$ref": "#/components/schemas/PageSettings"
|
||||
},
|
||||
"monitors": {
|
||||
"type": "array",
|
||||
@@ -2132,8 +2152,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"page_settings": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"$ref": "#/components/schemas/PageSettings"
|
||||
},
|
||||
"monitors": {
|
||||
"type": "array",
|
||||
@@ -2148,7 +2167,8 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page_path": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "New path for the page, sanitized to a lowercase single segment. For the home page (addressed as `~home`), sending back `~home` is a no-op and any other value returns `400` — its path is fixed."
|
||||
},
|
||||
"page_title": {
|
||||
"type": "string"
|
||||
@@ -2163,8 +2183,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"page_settings": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
"$ref": "#/components/schemas/PageSettings"
|
||||
},
|
||||
"monitors": {
|
||||
"type": "array",
|
||||
@@ -2221,6 +2240,12 @@
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MonitorImpact"
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Absolute URL of the public page for this maintenance. The public /maintenances/{id} route is keyed by maintenance EVENT id by default, so this URL carries ?type=maintenance. Always link via this field; never build links by concatenating `id` onto a path.",
|
||||
"examples": ["https://status.example.com/maintenances/4?type=maintenance"]
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
@@ -2308,6 +2333,12 @@
|
||||
"event_status": {
|
||||
"type": "string",
|
||||
"enum": ["SCHEDULED", "ONGOING", "COMPLETED", "CANCELLED", "READY"]
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Absolute URL of the public page for this maintenance event.",
|
||||
"examples": ["https://status.example.com/maintenances/9"]
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
@@ -2341,6 +2372,59 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PageSettings": {
|
||||
"type": "object",
|
||||
"description": "Per-page display configuration. On update, provided fields are deep-merged into the current settings; omitted fields are left untouched.",
|
||||
"properties": {
|
||||
"incidents": {
|
||||
"type": "object",
|
||||
"description": "Incident display preferences for this page.",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"include_maintenances": {
|
||||
"type": "object",
|
||||
"description": "Maintenance display preferences for this page.",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"monitor_status_history_days": {
|
||||
"type": "object",
|
||||
"description": "Days of status history shown on the page, per device class.",
|
||||
"properties": {
|
||||
"desktop": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 365,
|
||||
"default": 90
|
||||
},
|
||||
"mobile": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 365,
|
||||
"default": 30
|
||||
}
|
||||
}
|
||||
},
|
||||
"monitor_layout_style": {
|
||||
"type": "string",
|
||||
"enum": ["default-list", "default-grid", "compact-list", "compact-grid"],
|
||||
"default": "default-list",
|
||||
"description": "How monitors are laid out on the status page."
|
||||
},
|
||||
"meta_page_title": {
|
||||
"type": "string",
|
||||
"description": "Per-page override for the meta title."
|
||||
},
|
||||
"meta_page_description": {
|
||||
"type": "string",
|
||||
"description": "Per-page override for the meta description."
|
||||
},
|
||||
"social_page_preview_image": {
|
||||
"type": "string",
|
||||
"description": "Per-page override for the social preview image."
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user