Compare commits

..

11 Commits

Author SHA1 Message Date
Raj Nandan Sharma 74b6311e81 style: prettify files touched by Last Known Status work
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 23:18:22 +05:30
Raj Nandan Sharma c4c16d65a6 docs: document Last known status default and amend ADR 0005
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:53:57 +05:30
Raj Nandan Sharma 758cf5e4d5 feat(manage): Last known status option with callout in Default Status dropdown
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:49:10 +05:30
Raj Nandan Sharma 7c17db12dc style(migrations): match down() signature to house convention
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:44:58 +05:30
Raj Nandan Sharma d9954ea085 feat(db): migrate default_status to closed value set
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:41:57 +05:30
Raj Nandan Sharma 305c0a05fe feat(api): enforce closed default_status set with LAST_KNOWN scope rule
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:35:42 +05:30
Raj Nandan Sharma f83447f806 feat(scheduler): write CARRIED samples for LAST_KNOWN default status fixes #721
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:30:12 +05:30
Raj Nandan Sharma 42ca67b172 docs(db): clarify carry-chain inclusion and any-type caveat on latest-data queries
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:28:37 +05:30
Raj Nandan Sharma 806df0d73c feat(db): add getLatestAlertVisibleData query for last-known-status carry source
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:25:03 +05:30
Raj Nandan Sharma 5f66cada43 feat(constants): add CARRIED sample type and LAST_KNOWN default status
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:20:42 +05:30
Raj Nandan Sharma 85788c76f7 docs: design for Last Known Status default (fix #721)
ADR 0006, glossary terms, and implementation plan from the grilling session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:18:48 +05:30
140 changed files with 2278 additions and 3578 deletions
+1 -6
View File
@@ -34,9 +34,4 @@ temp.js
.DS_Store
knip-output.txt
check-output.txt
translation-report.json
# AI workflow docs (not version-controlled)
CONTEXT.md
docs/adr/
docs/superpowers/
translation-report.json
+107
View File
@@ -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
@@ -0,0 +1,5 @@
# Group membership is an explicit stored list, not a rule
When making group-monitor member selection searchable (#694), the requester also proposed dynamic membership by tag pattern (e.g. `site1-*` auto-adds matching monitors). We decided group membership stays an explicit, stored list of members. Dynamic membership contradicts the group model: each member carries an explicit weight (weights must sum to 1) and a manual execution order — a rule that adds/removes members over time would need an auto-weighting policy, silent weight redistribution when monitors are created or deleted, and an undefined execution order for matched members. Bulk needs are served in the editor instead: search plus "Add all N matching" makes large explicit groups cheap to build.
If wildcard groups are requested again, the answer is here: it's a different feature (a rule-based aggregate without weights or order), not an extension of Group Monitors.
@@ -0,0 +1,5 @@
# Public maintenance page is keyed by Maintenance Event id
The public route `/maintenances/<id>` interprets `<id>` as a Maintenance Event id by default (with `?type=maintenance` to address a Maintenance definition instead), even though the route param is named `maintenance_id`. Issue #723 showed this misleads API consumers: `/api/v4/maintenances` returns Maintenance ids, and linking those to `/maintenances/<id>` lands on whatever Event happens to carry that id — an apparent "off-by-one title mismatch" with no actual data corruption.
We considered flipping the route default to Maintenance ids but rejected it: every internal status-page link, subscriber email, and externally bookmarked URL is keyed by Event id, and all of those would break. Instead, v4 API responses carry an absolute `url` field (built from the configured Site URL) that resolves correctly — `?type=maintenance` for Maintenance objects, the plain Event-id path for Maintenance Events. Consumers should link via `url`, never by concatenating ids onto paths.
@@ -0,0 +1,9 @@
# Fail-fast, self-healing database pool defaults
`knexfile.ts` overrides knex's pool defaults for network databases (Postgres, MySQL): `pool.min` is 0 instead of 2, acquire/create timeouts are 15s instead of 60s/30s, and TCP keepalive is enabled on connections. All knobs are overridable via `DATABASE_*` env vars.
Two production incidents drove this. On Railway, a Postgres outage caused every request to hang for knex's default 60s `acquireConnectionTimeout` before failing with `KnexTimeoutError`, and after the database recovered the app stayed broken until a manual restart. In Docker Swarm (#692), the overlay network's conntrack silently dropped idle TCP connections after ~20 minutes, so the first request after an idle period drew a dead socket from the pool and returned a 500; the reporter worked around it with server-side Postgres `tcp_keepalives_*` settings and asked for an application-level fix.
Both share one root cause: knex keeps `pool.min` connections forever and never validates them. Those permanently-idle sockets are exactly the ones cloud networks (Railway proxies, Swarm overlays, k8s) silently kill, and after any database blip they wedge the pool with corpses. `min: 0` lets the reaper retire every idle connection (`idleTimeoutMillis` 30s, well under typical conntrack windows), keepalive lets the OS detect silently-dropped sockets, and the 15s timeouts turn a minute-long hang into a fast failure during an outage.
The trade-off: a quiet instance pays connection setup on the first query after idle (tens of milliseconds), and a database that takes longer than 15s to accept connections will see failures where the old defaults would have waited a minute. Deployments with such databases can raise `DATABASE_ACQUIRE_TIMEOUT_MS` / `DATABASE_CREATE_TIMEOUT_MS` rather than the project reverting to defaults that wedge everyone else.
+9
View File
@@ -0,0 +1,9 @@
# Home page is addressed as `~home` in the v4 API
The Home Page is stored with an empty `page_path`, which can not appear as a URL segment, so `/api/v4/pages/{page_path}` could not address it at all (#737). The v4 API now accepts the special segment `~home` for it: the request middleware maps `~home` to a lookup of the empty path before handlers run.
We considered the reporter's suggestion of a `default` keyword, and `home`, but both are valid page paths under the sanitizer (`[a-z0-9_-]`), so a real page could shadow the keyword or force reservation rules and migration edge cases. We also considered addressing pages by id, which either breaks the existing path-based contract or is ambiguous with numeric page paths. `~home` can never collide because the sanitizer strips `~`, and tilde is an RFC 3986 unreserved character, so clients never need to encode it (percent-encoded `%7Ehome` works too, since the middleware decodes segments).
Semantics follow the server-side invariants the manage UI relies on: `PATCH` via `~home` accepts every field except `page_path`, which is fixed for the home page (the UI disables the field), and `DELETE` is rejected — `DeletePage` in `pagesController` throws "Cannot delete the home page" and the rest of the app assumes the home page exists.
API responses also render the home page's `page_path` as `~home` (list, single, and write responses), so what a consumer reads is exactly what it can address — list → pick → `PATCH` round-trips cleanly, including read-modify-write bodies that send `~home` back (treated as "no path change"). The stored path remains empty and the public URL remains the site root; consumers must not build public URLs by concatenating `page_path`.
@@ -0,0 +1,9 @@
# Alerts evaluate alert-visible samples, not just REALTIME ones
The consecutive-sample checks behind alert evaluation (`consecutivelyStatusFor`, `consecutivelyLatencyGreaterThan`, `consecutivelyLatencyLessThan` in `src/lib/server/db/repositories/monitoring.ts`) consider samples whose type is `REALTIME`, `ERROR`, `TIMEOUT`, `MANUAL`, or `DEFAULT_STATUS` — the "alert-visible" set — instead of `REALTIME` only. Both data-API PATCH endpoints (single timestamp and range) enqueue one alert evaluation after writing `MANUAL` rows. `SIGNAL` rows and `INCIDENT`/`MAINTENANCE` overlay rows remain invisible to alerting. 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.
+7
View File
@@ -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,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.
+12 -39
View File
@@ -17,24 +17,9 @@ const intFromEnv = (name: string, fallback: number): number => {
// TCP keepalive on pooled connections, on by default. Cloud networks (Railway,
// Docker Swarm overlays, k8s) silently drop idle TCP connections; without
// keepalive the pool keeps handing out dead sockets after an idle period or a
// database restart. See docs .../setup/database-setup.md.
// database restart. See docs/adr/0003-fail-fast-self-healing-db-pool.md.
const keepAliveEnabled = process.env.DATABASE_KEEPALIVE !== "false";
interface PoolConfig {
min: number;
max: number;
idleTimeoutMillis: number;
createTimeoutMillis: number;
}
// Two pools share one process (Postgres/MySQL only): the WEB pool serves
// SvelteKit requests; the WORKER pool serves background jobs (BullMQ workers +
// schedulers, routed via src/lib/server/db/poolContext.ts). Isolating them
// stops a burst of background jobs from exhausting the connections that serve
// page loads. Budget across both pools: replicas * (web + worker) must stay
// under the database's max_connections. SQLite has no real pool and reuses a
// single connection, so the split does not apply there.
//
// Pool defaults deviate from knex's on purpose:
// - min 0: knex's min 2 connections are never reaped, so they are exactly the
// ones that go stale and wedge the app until a manual restart
@@ -42,17 +27,14 @@ interface PoolConfig {
// knex's default 60s during a database outage
// Tarn requires max >= 1 and min <= max; clamp so a bad env value can not
// produce a pool that fails every acquire
const idleTimeoutMillis = intFromEnv("DATABASE_IDLE_TIMEOUT_MS", 30000);
const createTimeoutMillis = intFromEnv("DATABASE_CREATE_TIMEOUT_MS", 15000);
const poolMin = intFromEnv("DATABASE_POOL_MIN", 0);
const buildPool = (max: number): PoolConfig => ({
min: Math.min(poolMin, max),
max,
idleTimeoutMillis,
createTimeoutMillis,
});
const webPool = buildPool(Math.max(1, intFromEnv("DATABASE_POOL_MAX", 10)));
const workerPool = buildPool(Math.max(1, intFromEnv("DATABASE_WORKER_POOL_MAX", 5)));
const 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 {
@@ -62,7 +44,7 @@ interface KnexConfig {
client?: string;
connection?: string | { filename: string } | Record<string, unknown>;
useNullAsDefault?: boolean;
pool?: PoolConfig;
pool?: typeof pool;
acquireConnectionTimeout?: number;
}
@@ -75,12 +57,6 @@ const knexOb: KnexConfig = {
},
databaseType,
};
// Worker pool config for Postgres/MySQL — same connection as the web config,
// but with the worker pool. Stays null for SQLite (single shared connection),
// in which case the app reuses the web instance for background work too.
let workerKnexOb: KnexConfig | null = null;
console.log(`Configuring database with type ${databaseType}`);
if (databaseType === "sqlite") {
knexOb.client = "better-sqlite3";
@@ -94,9 +70,8 @@ if (databaseType === "sqlite") {
connectionString: databaseURL,
keepAlive: keepAliveEnabled,
};
knexOb.pool = webPool;
knexOb.pool = pool;
knexOb.acquireConnectionTimeout = acquireConnectionTimeout;
workerKnexOb = { ...knexOb, pool: workerPool };
} else if (databaseType === "mysql") {
knexOb.client = "mysql2";
knexOb.connection = {
@@ -104,13 +79,11 @@ if (databaseType === "sqlite") {
enableKeepAlive: keepAliveEnabled,
keepAliveInitialDelay: 10000,
};
knexOb.pool = webPool;
knexOb.pool = pool;
knexOb.acquireConnectionTimeout = acquireConnectionTimeout;
workerKnexOb = { ...knexOb, pool: workerPool };
} else {
console.error("Invalid database type");
process.exit(1);
}
export { workerKnexOb };
export default knexOb;
@@ -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.
}
@@ -1,27 +0,0 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn("monitors", "confirmation_threshold"))) {
await knex.schema.alterTable("monitors", (table) => {
table.integer("confirmation_threshold").unsigned().notNullable().defaultTo(1);
});
}
if (!(await knex.schema.hasColumn("monitoring_data", "raw_status"))) {
await knex.schema.alterTable("monitoring_data", (table) => {
table.text("raw_status").nullable();
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn("monitors", "confirmation_threshold")) {
await knex.schema.alterTable("monitors", (table) => {
table.dropColumn("confirmation_threshold");
});
}
if (await knex.schema.hasColumn("monitoring_data", "raw_status")) {
await knex.schema.alterTable("monitoring_data", (table) => {
table.dropColumn("raw_status");
});
}
}
@@ -1,28 +0,0 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Covering index for the grouped status-count aggregation used by the
// monitor-bars dashboard endpoint. The query filters by
// (monitor_tag, timestamp range) and reads status + latency for every row.
// Including status and latency in the index lets the database satisfy the
// query from the index alone, avoiding a heap lookup per matched row.
try {
await knex.schema.alterTable("monitoring_data", (table) => {
table.index(
["monitor_tag", "timestamp", "status", "latency"],
"idx_monitoring_data_monitor_tag_timestamp_status_latency",
);
});
} catch (_e) {
/* index already exists */
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable("monitoring_data", (table) => {
table.dropIndex(
["monitor_tag", "timestamp", "status", "latency"],
"idx_monitoring_data_monitor_tag_timestamp_status_latency",
);
});
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "kener",
"version": "4.1.2",
"version": "4.0.23",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kener",
"version": "4.1.2",
"version": "4.0.23",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kener",
"version": "4.1.2",
"version": "4.0.23",
"type": "module",
"private": false,
"license": "MIT",
-22
View File
@@ -1,22 +0,0 @@
import type { Reroute } from "@sveltejs/kit";
// Back-compat for issue #759: heartbeat URLs used to be `/ext/heartbeat/<tag>:<secret>`,
// one path segment joined by a colon. A `:` is illegal in Windows file paths, so the
// route is now `/ext/heartbeat/<tag>/<secret>` (two segments). Legacy colon-form URLs
// live forever in external cron jobs / uptime pingers, so rewrite them internally to
// the new path. Returns a 200 (no redirect) — heartbeat clients often don't follow 3xx.
//
// `reroute` is a *universal* hook: it MUST be in src/hooks.ts. A `reroute` exported from
// src/hooks.server.ts is silently ignored by SvelteKit. Keep this file free of
// server-only imports — it is bundled for the client too. Must stay pure/side-effect-free.
//
// The transform is in-place (no path reconstruction), so any KENER_BASE_PATH prefix is
// preserved automatically. `[^/:]+` matches the validated tag charset; only the first
// colon after `/ext/heartbeat/<tag>` is rewritten.
const LEGACY_HEARTBEAT = /(\/ext\/heartbeat\/[^/:]+):/;
export const reroute: Reroute = ({ url }) => {
if (LEGACY_HEARTBEAT.test(url.pathname)) {
return url.pathname.replace(LEGACY_HEARTBEAT, "$1/");
}
};
+1 -2
View File
@@ -11,7 +11,7 @@
* incidents.write → createIncident, updateIncident, deleteIncident, addMonitor, removeMonitor, addComment, deleteComment, updateComment
*
* maintenances.read → getMaintenances, getMaintenance, getMaintenanceEvents, getMaintenanceEvent, getMaintenanceMonitors
* maintenances.write → createMaintenance, updateMaintenance, deleteMaintenance, createMaintenanceEvent, updateMaintenanceEvent, updateMaintenanceEventStatus, deleteMaintenanceEvent, addMonitorToMaintenance, removeMonitorFromMaintenance, updateMaintenanceMonitorImpact
* maintenances.write → createMaintenance, updateMaintenance, deleteMaintenance, createMaintenanceEvent, updateMaintenanceEvent, deleteMaintenanceEvent, addMonitorToMaintenance, removeMonitorFromMaintenance, updateMaintenanceMonitorImpact
*
* pages.read → getPages
* pages.write → createPage, updatePage, deletePage, addMonitorToPage, removeMonitorFromPage, reorderPageMonitors
@@ -149,7 +149,6 @@ export const ACTION_PERMISSION_MAP: Record<string, string | null> = {
deleteMaintenance: "maintenances.write",
createMaintenanceEvent: "maintenances.write",
updateMaintenanceEvent: "maintenances.write",
updateMaintenanceEventStatus: "maintenances.write",
deleteMaintenanceEvent: "maintenances.write",
addMonitorToMaintenance: "maintenances.write",
removeMonitorFromMaintenance: "maintenances.write",
-19
View File
@@ -1,19 +0,0 @@
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
import type { NotificationEvent } from "$lib/types/notifications.js";
interface NotificationsResponse {
notifications?: NotificationEvent[];
}
export async function requestNotifications(monitorTags: string[] = []): Promise<NotificationEvent[]> {
const query = monitorTags.length > 0 ? `?tags=${encodeURIComponent(monitorTags.join(","))}` : "";
const response = await fetch(clientResolver(resolve, "/dashboard-apis/notifications") + query);
if (!response.ok) {
throw new Error("Failed to fetch notifications");
}
const payload = (await response.json()) as NotificationsResponse;
return payload.notifications || [];
}
+34 -58
View File
@@ -1,5 +1,5 @@
import type { TimestampStatusCount } from "$lib/server/types/db";
import GC, { PAGE_STATUS_MESSAGES, type StatusType } from "$lib/global-constants";
import { PAGE_STATUS_MESSAGES } from "$lib/global-constants";
function ParseLatency(latencyMs: number): string {
if (!!!latencyMs) {
@@ -152,21 +152,6 @@ function IsValidNameServer(nameServer: string): boolean {
const regex = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/;
return regex.test(nameServer);
}
function IsValidDnsResolver(resolver: string): boolean {
const normalizedResolver = resolver.trim();
const ipv4Parts = normalizedResolver.split(".");
if (
ipv4Parts.length === 4 &&
ipv4Parts.every((part) => /^\d+$/.test(part) && Number(part) >= 0 && Number(part) <= 255)
) {
return true;
}
const ipType = ValidateIpAddress(normalizedResolver);
if (ipType === "IP6") {
return true;
}
return IsValidHost(normalizedResolver);
}
const IsValidURL = function (url: string): boolean {
return /^(http|https):\/\/[^ "]+$/.test(url);
};
@@ -331,53 +316,46 @@ interface GameItem {
function GetGameFromId(list: GameItem[], id: string): GameItem | undefined {
return list.find((game: GameItem) => game.id === id);
}
type StatusCounts = Pick<TimestampStatusCount, "countOfUp" | "countOfDown" | "countOfDegraded" | "countOfMaintenance">;
// Canonical Overall Status collapse: the worst state wins, and maintenance
// never masks an active problem. See docs/adr/0007-problem-first-overall-status.md.
function CollapseStatusCounts(counts: StatusCounts): StatusType {
const total = counts.countOfUp + counts.countOfDown + counts.countOfDegraded + counts.countOfMaintenance;
if (total === 0) return GC.NO_DATA;
if (counts.countOfDown > 0) return GC.DOWN;
if (counts.countOfDegraded > 0) return GC.DEGRADED;
if (counts.countOfMaintenance > 0) return GC.MAINTENANCE;
return GC.UP;
}
function GetStatusSummary(item: TimestampStatusCount): string {
const total = item.countOfUp + item.countOfDown + item.countOfDegraded + item.countOfMaintenance;
if (total === 0) return PAGE_STATUS_MESSAGES.NO_DATA;
switch (CollapseStatusCounts(item)) {
case GC.DOWN:
return (item.countOfDown / total) * 100 >= 75
? PAGE_STATUS_MESSAGES.MAJOR_OUTAGE
: PAGE_STATUS_MESSAGES.PARTIAL_OUTAGE;
case GC.DEGRADED:
return (item.countOfDegraded / total) * 100 >= 75
? PAGE_STATUS_MESSAGES.DEGRADED_PERFORMANCE
: PAGE_STATUS_MESSAGES.PARTIAL_DEGRADED;
case GC.MAINTENANCE:
return PAGE_STATUS_MESSAGES.UNDER_MAINTENANCE;
case GC.UP:
return PAGE_STATUS_MESSAGES.ALL_OPERATIONAL;
default:
return PAGE_STATUS_MESSAGES.NO_DATA;
const maintenancePercent = (item.countOfMaintenance / total) * 100;
const downPercent = (item.countOfDown / total) * 100;
const degradedPercent = (item.countOfDegraded / total) * 100;
if (maintenancePercent > 0) {
return PAGE_STATUS_MESSAGES.UNDER_MAINTENANCE;
} else if (downPercent >= 75) {
return PAGE_STATUS_MESSAGES.MAJOR_OUTAGE;
} else if (downPercent >= 50) {
return PAGE_STATUS_MESSAGES.PARTIAL_OUTAGE;
} else if (item.countOfDown > 0) {
return PAGE_STATUS_MESSAGES.PARTIAL_OUTAGE;
} else if (degradedPercent >= 75) {
return PAGE_STATUS_MESSAGES.DEGRADED_PERFORMANCE;
} else if (degradedPercent >= 50) {
return PAGE_STATUS_MESSAGES.PARTIAL_DEGRADED;
} else if (item.countOfDegraded > 0) {
return PAGE_STATUS_MESSAGES.PARTIAL_DEGRADED;
} else if (item.countOfUp === total) {
return PAGE_STATUS_MESSAGES.ALL_OPERATIONAL;
}
return PAGE_STATUS_MESSAGES.NO_DATA;
}
function GetStatusColor(item: TimestampStatusCount): string {
switch (CollapseStatusCounts(item)) {
case GC.DOWN:
return "text-down";
case GC.DEGRADED:
return "text-degraded";
case GC.MAINTENANCE:
return "text-maintenance";
case GC.UP:
return "text-up";
default:
return "text-muted-foreground";
}
const total = item.countOfUp + item.countOfDown + item.countOfDegraded + item.countOfMaintenance;
if (total === 0) return "text-muted-foreground";
const maintenancePercent = (item.countOfMaintenance / total) * 100;
const downPercent = (item.countOfDown / total) * 100;
if (maintenancePercent > 0) return "text-maintenance";
if (downPercent > 0) return "text-down";
if (item.countOfDegraded > 0) return "text-degraded";
return "text-up";
}
function GetStatusBgColor(item: TimestampStatusCount): string {
@@ -398,10 +376,8 @@ export {
ValidateIpAddress,
IsValidHost,
IsValidNameServer,
IsValidDnsResolver,
IsValidURL,
IsValidPort,
CollapseStatusCounts,
GetStatusSummary,
GetStatusColor,
GetStatusBgColor,
+3 -7
View File
@@ -12,8 +12,6 @@
import clientResolver from "$lib/client/resolver.js";
import { GetInitials } from "$lib/clientTools.js";
import type { MaintenanceEventsMonitorList } from "$lib/server/types/db";
import { SveltePurify } from "@humanspeak/svelte-purify";
import mdToHTML from "$lib/marked";
import { page } from "$app/state";
interface Props {
@@ -44,11 +42,9 @@
</div>
{#if maintenance.description}
<div
class="prose prose-sm dark:prose-invert text-muted-foreground mt-1 max-w-none min-w-0 overflow-x-auto text-sm wrap-break-word"
>
<SveltePurify html={mdToHTML(maintenance.description)} />
</div>
<p class="text-muted-foreground mt-1 text-sm">
{maintenance.description}
</p>
{/if}
{#if maintenance.monitors && maintenance.monitors.length > 0 && !hideMonitors}
-118
View File
@@ -1,118 +0,0 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { page } from "$app/state";
import { requestNotifications } from "$lib/client/notifications-client.js";
import clientResolver from "$lib/client/resolver.js";
import { Button } from "$lib/components/ui/button/index.js";
import { formatDate, formatDuration } from "$lib/stores/datetime";
import { t } from "$lib/stores/i18n";
import type { NotificationEvent } from "$lib/types/notifications.js";
import Calendar from "@lucide/svelte/icons/calendar-1";
import { format } from "date-fns";
import { onMount } from "svelte";
interface Props {
monitorTags?: string[];
eventsPath?: string;
notifications?: NotificationEvent[];
loading?: boolean;
fetchOnMount?: boolean;
}
let {
monitorTags = [],
eventsPath = "",
notifications = $bindable<NotificationEvent[]>([]),
loading = $bindable(false),
fetchOnMount = true
}: Props = $props();
const defaultEventsPath = $derived(`/events/${format(new Date(), "MMMM-yyyy")}`);
const resolvedEventsPath = $derived.by(() => {
const finalEventsPath = eventsPath || defaultEventsPath;
if (page.data?.globalPageVisibilitySettings?.forceExclusivity) {
const currentPagePath = page.params?.page_path?.trim();
return currentPagePath ? `/${currentPagePath}${finalEventsPath}` : finalEventsPath;
}
return finalEventsPath;
});
async function fetchNotifications() {
loading = true;
try {
notifications = await requestNotifications(monitorTags);
} catch {
// silently fail
} finally {
loading = false;
}
}
onMount(() => {
if (fetchOnMount) {
void fetchNotifications();
}
});
function getEventId(eventURL: string) {
return eventURL.split("/").filter(Boolean).at(-1) || "";
}
</script>
{#snippet notificationItem(item: NotificationEvent)}
<div class="my-0.5 flex items-center justify-between gap-2 text-xs">
<span class="text-muted-foreground text-[11px] uppercase">{$t(item.eventType)}</span>
<span class="text-{item.eventStatus.toLowerCase()}">{$t(item.eventStatus)}</span>
</div>
<div class="flex items-start justify-between gap-2">
<p class="line-clamp-2 text-sm">{item.eventTitle}</p>
</div>
<div class="text-muted-foreground mt-1 flex flex-wrap items-center gap-2 text-xs">
<span>{$formatDate(item.eventDate, page.data.dateAndTimeFormat.datePlusTime)}</span>
<span></span>
<span>{$formatDuration(item.eventStartDateTime, item.eventEndDateTime, $t("Ongoing"))}</span>
</div>
{/snippet}
<div class="flex items-center justify-between border-b px-4 py-3">
<h4 class="text-sm font-semibold">{$t("Events")}</h4>
<Button
variant="outline"
href={clientResolver(resolve, resolvedEventsPath)}
size="icon-sm"
class="rounded-btn"
aria-label={$t("Open events page")}
title={$t("Open events page")}
>
<Calendar class="size-4" />
</Button>
</div>
{#if notifications.length === 0}
<div class="text-muted-foreground px-4 py-6 text-center text-sm">
{$t("No events to show")}
</div>
{:else}
<div class="scrollbar-hidden max-h-96 overflow-y-auto">
{#each notifications as item, i (`${item.eventType}-${item.eventURL}-${item.eventDate}-${i}`)}
{@const eventId = getEventId(item.eventURL)}
{#if item.eventURL.startsWith("/incidents/")}
<a
href={resolve("/(kener)/incidents/[incident_id]", { incident_id: eventId })}
class="hover:bg-muted/60 block border-b px-4 py-3 last:border-b-0"
>
{@render notificationItem(item)}
</a>
{:else}
<a
href={resolve("/(kener)/maintenances/[maintenance_id]", { maintenance_id: eventId })}
class="hover:bg-muted/60 block border-b px-4 py-3 last:border-b-0"
>
{@render notificationItem(item)}
</a>
{/if}
{/each}
</div>
{/if}
+61 -7
View File
@@ -1,18 +1,22 @@
<script lang="ts">
import NotificationsList from "$lib/components/NotificationsList.svelte";
import { resolve } from "$app/paths";
import { page } from "$app/state";
import { Button } from "$lib/components/ui/button/index.js";
import * as Popover from "$lib/components/ui/popover/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { requestNotifications } from "$lib/client/notifications-client.js";
import ICONS from "$lib/icons";
import clientResolver from "$lib/client/resolver.js";
import { formatDate, formatDuration } from "$lib/stores/datetime";
import { t } from "$lib/stores/i18n";
import type { NotificationEvent } from "$lib/types/notifications.js";
import type { NotificationEvent } from "$lib/server/controllers/dashboardController.js";
import Calendar from "@lucide/svelte/icons/calendar-1";
import { format } from "date-fns";
import { onMount } from "svelte";
interface Props {
monitorTags?: string[];
compact?: boolean;
eventsPath?: string;
eventsPath: string;
}
let { monitorTags = [], compact = true, eventsPath = "" }: Props = $props();
@@ -20,10 +24,26 @@
let notifications = $state<NotificationEvent[]>([]);
let loading = $state(false);
const defaultEventsPath = $derived(`/events/${format(new Date(), "MMMM-yyyy")}`);
const resolvedEventsPath = $derived.by(() => {
const finalEventsPath = eventsPath || defaultEventsPath;
if (page.data?.globalPageVisibilitySettings?.forceExclusivity) {
const currentPagePath = page.params?.page_path?.trim();
return currentPagePath ? `/${currentPagePath}${finalEventsPath}` : finalEventsPath;
}
return finalEventsPath;
});
async function fetchNotifications() {
loading = true;
try {
notifications = await requestNotifications(monitorTags);
const query = monitorTags.length > 0 ? `?tags=${encodeURIComponent(monitorTags.join(","))}` : "";
const response = await fetch(clientResolver(resolve, "/dashboard-apis/notifications") + query);
if (response.ok) {
const payload = await response.json();
notifications = payload.notifications || [];
}
} catch {
// silently fail
} finally {
@@ -32,7 +52,7 @@
}
onMount(() => {
void fetchNotifications();
fetchNotifications();
});
</script>
@@ -67,6 +87,40 @@
class="bg-background/30 supports-backdrop-filter:bg-background/20 w-96 rounded-3xl border p-0 shadow-2xl backdrop-blur-2xl"
sideOffset={8}
>
<NotificationsList {monitorTags} {eventsPath} fetchOnMount={false} bind:notifications bind:loading />
<div class="flex items-center justify-between border-b px-4 py-3">
<h4 class="text-sm font-semibold">{$t("Events")}</h4>
<Button variant="outline" href={clientResolver(resolve, resolvedEventsPath)} size="icon-sm" class="rounded-btn">
<Calendar class="size-4" />
</Button>
</div>
{#if notifications.length === 0}
<div class="text-muted-foreground px-4 py-6 text-sm">
{$t("No events to show")}
</div>
{:else}
<div class="scrollbar-hidden max-h-96 overflow-y-auto">
{#each notifications as item, i (`${item.eventType}-${item.eventURL}-${item.eventDate}-${i}`)}
<a
href={clientResolver(resolve, item.eventURL)}
class="hover:bg-muted/60 block border-b px-4 py-3 last:border-b-0"
>
<div class="my-0.5 flex items-center justify-between gap-2 text-xs">
<span class="text-muted-foreground text-[11px] uppercase">{$t(item.eventType)}</span>
<span class="text-{item.eventStatus.toLowerCase()}">{$t(item.eventStatus)}</span>
</div>
<div class="flex items-start justify-between gap-2">
<p class="line-clamp-2 text-sm">{item.eventTitle}</p>
</div>
<div class="text-muted-foreground mt-1 flex flex-wrap items-center gap-2 text-xs">
<span>{$formatDate(item.eventDate, page.data.dateAndTimeFormat.datePlusTime)}</span>
<span></span>
<span>{$formatDuration(item.eventStartDateTime, item.eventEndDateTime, $t("Ongoing"))}</span>
</div>
</a>
{/each}
</div>
{/if}
</Popover.Content>
</Popover.Root>
+2 -32
View File
@@ -10,7 +10,6 @@
import Sun from "@lucide/svelte/icons/sun";
import Moon from "@lucide/svelte/icons/moon";
import Share from "@lucide/svelte/icons/share-2";
import Rss from "@lucide/svelte/icons/rss";
import { format } from "date-fns";
import SubscribeMenu from "$lib/components/SubscribeMenu.svelte";
import CopyButton from "$lib/components/CopyButton.svelte";
@@ -27,23 +26,14 @@
interface Props {
monitor_tags?: string[];
embedMonitorTag?: string;
hideNotificationsPopover?: boolean;
}
let { monitor_tags = [], embedMonitorTag = "", hideNotificationsPopover = false }: Props = $props();
let { monitor_tags = [], embedMonitorTag = "" }: Props = $props();
let protocol = $state("");
let domain = $state("");
let shareLink = $state("");
const eventsPath = $derived(`/events/${format(new Date(), "MMMM-yyyy")}`);
const showNotificationsPopover = $derived(!hideNotificationsPopover);
const rssHref = $derived.by(() => {
const params = page.params;
if (params.monitor_tag) return clientResolver(resolve, `/monitors/${params.monitor_tag}/rss.xml`);
if (params.page_path) return clientResolver(resolve, `/${params.page_path}/rss.xml`);
return clientResolver(resolve, "/rss.xml");
});
const loginDetails = $derived.by((): { label: string; url: string } | null => {
if (!page.data?.loggedInUser) return null;
@@ -105,24 +95,6 @@
</ButtonGroup.Root>
{/if}
{#if page.data.subMenuOptions?.showRssFeed !== false}
<ButtonGroup.Root class="rounded-btn-grp shrink-0">
<Button
variant="outline"
size="icon-sm"
href={rssHref}
target="_blank"
rel="alternate"
aria-label={$t("RSS feed")}
title={$t("RSS feed")}
class="bg-background/80 dark:bg-background/70 border-foreground/10 cursor-pointer rounded-full border shadow-none backdrop-blur-md"
onclick={() => trackEvent("rss_opened", { source: "theme_plus" })}
>
<Rss />
</Button>
</ButtonGroup.Root>
{/if}
<ButtonGroup.Root class="rounded-btn-grp shrink-0">
<CopyButton
variant="outline"
@@ -185,9 +157,7 @@
<TimezoneSelector />
{/if}
</ButtonGroup.Root>
{#if showNotificationsPopover}
<NotificationsPopover {eventsPath} monitorTags={monitor_tags} compact={true} />
{/if}
<NotificationsPopover {eventsPath} monitorTags={monitor_tags} compact={true} />
{#if loginDetails}
<Button
size="sm"
@@ -17,7 +17,7 @@
config: ChartConfig;
} = $props();
const chartId = $derived(`chart-${id || uid.replace(/:/g, "")}`);
const chartId = `chart-${id || uid.replace(/:/g, "")}`;
setChartContext({
get config() {
@@ -5,10 +5,8 @@
type ToggleVariants = VariantProps<typeof toggleVariants>;
interface ToggleGroupContext {
get variant(): ToggleVariants["variant"];
get size(): ToggleVariants["size"];
get spacing(): number | undefined;
interface ToggleGroupContext extends ToggleVariants {
spacing?: number;
}
export function setToggleGroupCtx(props: ToggleGroupContext) {
@@ -35,15 +33,9 @@
}: ToggleGroupPrimitive.RootProps & ToggleVariants & { spacing?: number } = $props();
setToggleGroupCtx({
get variant() {
return variant;
},
get size() {
return size;
},
get spacing() {
return spacing;
},
variant,
size,
spacing,
});
</script>
+2
View File
@@ -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.",
-1
View File
@@ -116,7 +116,6 @@
"Recent Incidents": "Nedávné incidenty",
"Recurring": "Opakující se",
"RESOLVED": "VYŘEŠENO",
"RSS feed": "RSS feed",
"SCHEDULED": "NAPLÁNOVÁNO",
"Scheduled Events (%count)": "Plánované události (%count)",
"Scheduled Windows": "Naplánované úlohy",
-1
View File
@@ -100,7 +100,6 @@
"READY": "BEREIT",
"Recurring": "Wiederkehrend",
"RESOLVED": "BEHOBEN",
"RSS feed": "RSS feed",
"SCHEDULED": "GEPLANT",
"Scheduled Events (%count)": "Geplante Ereignisse (%count)",
"Script": "Skript",
-1
View File
@@ -100,7 +100,6 @@
"READY": "KLAR",
"Recurring": "Tilbagevendende",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "PLANLAGT",
"Scheduled Events (%count)": "Planlagte begivenheder (%count)",
"Script": "Manuskript",
-2
View File
@@ -87,7 +87,6 @@
"Notifications": "Notifications",
"One-time": "One-time",
"Ongoing": "Ongoing",
"Open events page": "Open events page",
"Operational": "Operational",
"Partial Degraded Performance": "Partial Degraded Performance",
"Partial System Outage": "Partial System Outage",
@@ -101,7 +100,6 @@
"READY": "READY",
"Recurring": "Recurring",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "SCHEDULED",
"Scheduled Events (%count)": "Scheduled Events (%count)",
"Script": "Script",
-1
View File
@@ -100,7 +100,6 @@
"READY": "LISTO",
"Recurring": "Recurrente",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "PROGRAMADO",
"Scheduled Events (%count)": "Eventos programados (%count)",
"Script": "Guion",
-1
View File
@@ -100,7 +100,6 @@
"READY": "آماده",
"Recurring": "دوره‌ای",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "زمان‌بندی شده",
"Scheduled Events (%count)": "رویدادهای زمان‌بندی شده (%count)",
"Script": "اسکریپت",
-1
View File
@@ -100,7 +100,6 @@
"READY": "PRÊT",
"Recurring": "Récurrent",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "PLANIFIÉ",
"Scheduled Events (%count)": "Événements planifiés (%count)",
"Script": "Scénario",
-1
View File
@@ -100,7 +100,6 @@
"READY": "तैयार",
"Recurring": "आवर्ती",
"RESOLVED": "सुलझा हुआ",
"RSS feed": "RSS feed",
"SCHEDULED": "अनुसूचित",
"Scheduled Events (%count)": "अनुसूचित कार्यक्रम (%count)",
"Script": "स्क्रिप्ट",
-1
View File
@@ -100,7 +100,6 @@
"READY": "PRONTO",
"Recurring": "Ricorrente",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "PROGRAMMATO",
"Scheduled Events (%count)": "Eventi programmati (%count)",
"Script": "Copione",
-1
View File
@@ -100,7 +100,6 @@
"READY": "準備完了",
"Recurring": "繰り返し",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "予定済み",
"Scheduled Events (%count)": "予定イベント (%count)",
"Script": "スクリプト",
-1
View File
@@ -100,7 +100,6 @@
"READY": "준비됨",
"Recurring": "반복",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "예정됨",
"Scheduled Events (%count)": "예정된 이벤트 (%count)",
"Script": "스크립트",
-1
View File
@@ -100,7 +100,6 @@
"READY": "KLAR",
"Recurring": "Gjentakende",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "PLANLAGT",
"Scheduled Events (%count)": "Planlagte hendelser (%count)",
"Script": "Manus",
-1
View File
@@ -100,7 +100,6 @@
"READY": "GEREED",
"Recurring": "Terugkerend",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "GEPLAND",
"Scheduled Events (%count)": "Geplande evenementen (%count)",
"Script": "Script",
-1
View File
@@ -100,7 +100,6 @@
"READY": "GOTOWE",
"Recurring": "Cykliczne",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "ZAPLANOWANE",
"Scheduled Events (%count)": "Zaplanowane wydarzenia (%count)",
"Script": "Scenariusz",
-1
View File
@@ -100,7 +100,6 @@
"READY": "PRONTO",
"Recurring": "Recorrente",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "AGENDADO",
"Scheduled Events (%count)": "Eventos agendados (%count)",
"Script": "Roteiro",
-1
View File
@@ -101,7 +101,6 @@
"READY": "ГОТОВО",
"Recurring": "Повторяющийся",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "ЗАПЛАНИРОВАНО",
"Scheduled Events (%count)": "Запланированные события (%count)",
"Script": "Скрипт",
-1
View File
@@ -116,7 +116,6 @@
"Recent Incidents": "Nedávne incidenty",
"Recurring": "Opakujúce sa",
"RESOLVED": "VYRIEŠENÉ",
"RSS feed": "RSS feed",
"SCHEDULED": "NAPLÁNOVANÉ",
"Scheduled Events (%count)": "Plánované udalosti (%count)",
"Scheduled Windows": "Naplánované úlohy",
-1
View File
@@ -100,7 +100,6 @@
"READY": "HAZIR",
"Recurring": "Tekrarlayan",
"RESOLVED": "RESOLVED",
"RSS feed": "RSS feed",
"SCHEDULED": "PLANLANMIŞ",
"Scheduled Events (%count)": "Planlanan etkinlikler (%count)",
"Script": "Senaryo",
+1 -2
View File
@@ -101,7 +101,6 @@
"READY": "ГОТОВО",
"Recurring": "Повторюване",
"RESOLVED": "ВИРІШЕНО",
"RSS feed": "RSS feed",
"SCHEDULED": "ЗАПЛАНОВАНО",
"Scheduled Events (%count)": "Заплановані події (%count)",
"Script": "Скрипт",
@@ -136,4 +135,4 @@
"Verifying": "Перевірка",
"We sent a 6-digit code to": "Ми надіслали 6-значний код на"
}
}
}
+42 -44
View File
@@ -1,32 +1,32 @@
{
"name": "Tiếng Việt",
"mappings": {
"%latency %metric latency": "Độ trễ %metric: %latency",
"Affected Monitors (%count)": "Mục giám sát bị ảnh hưởng (%count)",
"All Systems Operational": "Hệ thống hoạt động bình thường",
"%latency %metric latency": "%latency %metric độ trễ",
"Affected Monitors (%count)": "Màn hình bị ảnh hưởng (%count)",
"All Systems Operational": "Tất cả hệ thống đang hoạt động",
"Average Latency": "Độ trễ trung bình",
"Avg Latency": "Độ trễ TB",
"Back": "Quay lại",
"Avg Latency": "Độ trễ trung bình",
"Back": "Mặt sau",
"Badges": "Huy hiệu",
"CANCELLED": "ĐÃ HỦY",
"COMPLETED": "HOÀN THÀNH",
"Continue": "Tiếp tục",
"Copied": "Đã sao chép",
"Current": "Hiện tại",
"Dark": "Tối",
"Dark": "Tối tăm",
"Day": "Ngày",
"Day Uptime": "Thời gian hoạt động trong ngày",
"Days": "Ngày",
"Degraded": "Suy giảm",
"DEGRADED": "SUY GIẢM",
"Degraded Performance": "Hiệu năng suy giảm",
"Didn't receive the code? Resend": "Không nhận được mã? Gửi lại",
"Degraded Performance": "Hiệu suất suy giảm",
"Didn't receive the code? Resend": "Không nhận được mã? ",
"Down": "Ngừng hoạt động",
"DOWN": "NGỪNG HOẠT ĐỘNG",
"Duration": "Thời lượng",
"Duration": "Khoảng thời gian",
"Email address": "Địa chỉ email",
"Embed Monitor": "Nhúng mục giám sát",
"Embed this monitor in your website or app": "Nhúng mục giám sát này vào trang web hoặc ứng dụng của bạn",
"Embed Monitor": "Màn hình nhúng",
"Embed this monitor in your website or app": "Nhúng màn hình này vào trang web hoặc ứng dụng của bạn",
"End Time": "Thời gian kết thúc",
"Enter the verification code sent to your email.": "Nhập mã xác minh được gửi tới email của bạn.",
"Events": "Sự kiện",
@@ -35,18 +35,18 @@
"Failed to load status data for this day": "Không thể tải dữ liệu trạng thái cho ngày này",
"Failed to send verification code": "Không gửi được mã xác minh",
"Failed to update preference": "Không thể cập nhật tùy chọn",
"Get badges for this monitor": "Lấy huy hiệu cho mục giám sát này",
"Get notified about incidents and scheduled maintenance.": "Nhận thông báo về sự cố và bảo trì đã lên lịch.",
"Get notified about incidents updates": "Nhận thông báo về các cập nhật sự cố",
"Get notified about scheduled maintenance": "Nhận thông báo khi có lịch bảo trì",
"IDENTIFIED": "ĐÃ XÁC ĐỊNH",
"Get badges for this monitor": "Nhận huy hiệu cho màn hình này",
"Get notified about incidents and scheduled maintenance.": "Nhận thông báo về sự cố và bảo trì theo lịch trình.",
"Get notified about incidents updates": "Nhận thông báo về cập nhật sự cố",
"Get notified about scheduled maintenance": "Nhận thông báo về bảo trì theo lịch trình",
"IDENTIFIED": "IDENTIFIED",
"iFrame": "iFrame",
"Impact": "Mức ảnh hưởng",
"Impact": "Sự va chạm",
"incident": "Sự cố",
"Incident Updates": "Cập nhật sự cố",
"Incidents": "Sự cố",
"Included Monitors (%count)": "Mục giám sát được bao gồm (%count)",
"INVESTIGATING": "ĐANG ĐIỀU TRA",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Cập nhật lần cuối",
"Latency": "Độ trễ",
"Latency Embed": "Nhúng độ trễ",
@@ -54,14 +54,14 @@
"Latency Trend": "Xu hướng độ trễ",
"Latest Latency": "Độ trễ mới nhất",
"Latest Status": "Trạng thái mới nhất",
"Light": "Sáng",
"Light": "Ánh sáng",
"Live Status": "Trạng thái trực tiếp",
"Loading your preferences...": "Đang tải tùy chọn của bạn...",
"maintenance": "Bảo trì",
"MAINTENANCE": "BẢO TRÌ",
"Maintenance Updates": "Cập nhật bảo trì",
"Maintenances": "Bảo trì",
"Major System Outage": "Gián đoạn hệ thống nghiêm trọng",
"Major System Outage": "Sự cố hệ thống nghiêm trọng",
"Manage Site": "Quản lý trang",
"Manage your notification preferences.": "Quản lý tùy chọn thông báo của bạn.",
"Max Latency": "Độ trễ tối đa",
@@ -69,39 +69,37 @@
"Min Latency": "Độ trễ tối thiểu",
"Minimum Latency": "Độ trễ tối thiểu",
"Minute-by-minute status data for this day": "Dữ liệu trạng thái từng phút cho ngày này",
"MONITORING": "ĐANG THEO DÕI",
"Network error. Please try again.": "Lỗi mạng. Vui lòng thử lại.",
"MONITORING": "MONITORING",
"Network error. Please try again.": "Lỗi mạng. ",
"No Events in %currentMonth": "Không có sự kiện nào trong %currentMonth",
"No events to show": "Không có sự kiện nào để hiển thị",
"No incidents for this day": "Không có sự cố nào trong ngày này",
"No latency data available for this day": "Không có dữ liệu về độ trễ cho ngày này",
"No maintenances for this day": "Không có đợt bảo trì nào trong ngày này",
"No monitors affected": "Không có mục giám sát nào bị ảnh hưởng",
"No monitors available.": "Không có mục giám sát nào.",
"No ongoing maintenances": "Không có đợt bảo trì nào đang diễn ra",
"No past maintenances": "Không có đợt bảo trì nào đã qua",
"No maintenances for this day": "Không bảo trì trong ngày này",
"No monitors affected": "Không có màn hình nào bị ảnh hưởng",
"No monitors available.": "Không có trình giám sát nào khả dụng.",
"No ongoing maintenances": "Không có bảo trì liên tục",
"No past maintenances": "Không có bảo trì trước đây",
"No Status Available": "Không có trạng thái khả dụng",
"No upcoming maintenances": "Không có đợt bảo trì nào sắp tới",
"No upcoming maintenances": "Không có bảo trì sắp tới",
"No Updates": "Không có cập nhật",
"No updates yet": "Chưa có cập nhật nào",
"Notifications": "Thông báo",
"One-time": "Một lần",
"Ongoing": "Đang thực hiện",
"Open events page": "Mở trang sự kiện",
"Operational": "Hoạt động bình thường",
"Partial Degraded Performance": "Hiệu năng suy giảm một phần",
"Partial System Outage": "Gián đoạn hệ thống một phần",
"Past": "Đã qua",
"Per-Minute Status": "Trạng thái từng phút",
"Pinging": "Đang ping",
"Operational": "Hoạt động",
"Partial Degraded Performance": "Hiệu suất suy giảm một phần",
"Partial System Outage": "Sự cố hệ thống một phần",
"Past": "Quá khứ",
"Per-Minute Status": "Trạng thái mỗi phút",
"Pinging": "Ping",
"Please enter a valid email address": "Vui lòng nhập địa chỉ email hợp lệ",
"Please enter the 6-digit verification code": "Vui lòng nhập mã xác minh gồm 6 chữ số",
"Read less": "Thu gọn",
"Read less": "Đọc ít hơn",
"Read more": "Đọc thêm",
"READY": "SẴN SÀNG",
"Recurring": "Định kỳ",
"RESOLVED": "ĐÃ KHẮC PHỤC",
"RSS feed": "Nguồn RSS",
"RESOLVED": "RESOLVED",
"SCHEDULED": "ĐÃ LÊN LỊCH",
"Scheduled Events (%count)": "Sự kiện đã lên lịch (%count)",
"Script": "Kịch bản",
@@ -113,16 +111,16 @@
"Start Time": "Thời gian bắt đầu",
"Status": "Trạng thái",
"Status Badge": "Huy hiệu trạng thái",
"Status Embed": "Nhúng trạng thái",
"Status Embed": "Trạng thái nhúng",
"Status history and latency trend": "Lịch sử trạng thái và xu hướng độ trễ",
"Subscribe": ăng ký",
"Subscribe to Updates": "Đăng ký nhận cập nhật",
"There are no incidents or maintenances scheduled for this month.": "Không có sự cố hoặc đợt bảo trì nào được lên lịch trong tháng này.",
"Subscribe": ặt mua",
"Subscribe to Updates": "Đăng ký cập nhật",
"There are no incidents or maintenances scheduled for this month.": "Không có sự cố hoặc bảo trì nào được lên kế hoạch trong tháng này.",
"There are no ongoing incidents or maintenance events.": "Không có sự cố hoặc sự kiện bảo trì nào đang diễn ra.",
"Total Incidents": "Tổng số sự cố",
"Total Maintenances": "Tổng số lần bảo trì",
"Under Maintenance": "Đang bảo trì",
"Unknown impact": "Không xác định được mức ảnh hưởng",
"Unknown impact": "Tác động không xác định",
"UP": "HOẠT ĐỘNG",
"Upcoming": "Sắp tới",
"Update Incident": "Cập nhật sự cố",
+39 -40
View File
@@ -2,65 +2,65 @@
"name": "简体中文",
"mappings": {
"%latency %metric latency": "%latency %metric 延迟",
"Affected Monitors (%count)": "受影响的监控项 (%count)",
"Affected Monitors (%count)": "受影响的监视器 (%count)",
"All Systems Operational": "所有系统运行正常",
"Average Latency": "平均延迟",
"Avg Latency": "平均延迟",
"Back": "返回",
"Back": "后退",
"Badges": "徽章",
"CANCELLED": "已取消",
"COMPLETED": "已完成",
"Continue": "继续",
"Copied": "已复制",
"Current": "当前",
"Dark": "深色模式",
"Dark": "黑暗的",
"Day": "天",
"Day Uptime": "日正常运行时",
"Day Uptime": "日正常运行时",
"Days": "天",
"Degraded": "系统降级",
"DEGRADED": "系统降级",
"Degraded": "降级",
"DEGRADED": "降级",
"Degraded Performance": "性能下降",
"Didn't receive the code? Resend": "没有收到代码?重新发送",
"Didn't receive the code? Resend": "没有收到代码?",
"Down": "宕机",
"DOWN": "故障",
"Duration": "持续时间",
"Duration": "间",
"Email address": "电子邮件",
"Embed Monitor": "嵌入监控项",
"Embed this monitor in your website or app": "将此监控项嵌入您的网站或应用程序中",
"Embed Monitor": "嵌入监视器",
"Embed this monitor in your website or app": "将此监视器嵌入您的网站或应用程序中",
"End Time": "结束时间",
"Enter the verification code sent to your email.": "输入发送到您的电子邮件的验证码。",
"Events": "动",
"Events": "动",
"Failed to load data": "加载数据失败",
"Failed to load latency data": "加载延迟数据失败",
"Failed to load status data for this day": "无法加载当天的状态数据",
"Failed to send verification code": "发送验证码失败",
"Failed to update preference": "无法更新偏好设置",
"Get badges for this monitor": "获取此监控项的徽章",
"Get badges for this monitor": "获取此显示器的徽章",
"Get notified about incidents and scheduled maintenance.": "获取有关事件和定期维护的通知。",
"Get notified about incidents updates": "获取有关事件更新的通知",
"Get notified about scheduled maintenance": "获取有关定期维护的通知",
"IDENTIFIED": "已确认",
"IDENTIFIED": "IDENTIFIED",
"iFrame": "框架",
"Impact": "影响",
"incident": "事件",
"Incident Updates": "事件更新",
"Incidents": "事件",
"Included Monitors (%count)": "包括的监控项 (%count)",
"INVESTIGATING": "调查中",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "最后更新",
"Latency": "延迟",
"Latency Embed": "嵌入延迟",
"Latency Over Time": "历史延迟",
"Latency Embed": "延迟嵌入",
"Latency Over Time": "随着时间的推移延迟",
"Latency Trend": "延迟趋势",
"Latest Latency": "最新延迟时间",
"Latest Status": "最新状态",
"Light": "浅色模式",
"Light": "",
"Live Status": "实时状态",
"Loading your preferences...": "正在加载您的偏好设置...",
"maintenance": "维护",
"MAINTENANCE": "维护中",
"Maintenance Updates": "维护更新",
"Maintenances": "例行维护",
"Maintenances": "维护保养",
"Major System Outage": "重大系统故障",
"Manage Site": "管理站点",
"Manage your notification preferences.": "管理您的通知首选项。",
@@ -69,39 +69,38 @@
"Min Latency": "最短延迟",
"Minimum Latency": "最短延迟",
"Minute-by-minute status data for this day": "当日每分钟的状态数据",
"MONITORING": "监视中",
"Network error. Please try again.": "网络错误。请稍后再试。",
"MONITORING": "MONITORING",
"Network error. Please try again.": "网络错误。",
"No Events in %currentMonth": "%currentMonth 没有活动",
"No events to show": "没有可显示的事件",
"No incidents for this day": "这一天没有发生任何事件",
"No latency data available for this day": "当天没有可用的延迟数据",
"No maintenances for this day": "这一天无维护",
"No monitors affected": "没有监控项受到影响",
"No maintenances for this day": "今日无维护",
"No monitors affected": "没有显示器受到影响",
"No monitors available.": "没有可用的监控项。",
"No ongoing maintenances": "没有需要持续维护",
"No ongoing maintenances": "无需持续维护",
"No past maintenances": "过去没有维护过",
"No Status Available": "无可用状态",
"No upcoming maintenances": "没有即将进行的维护",
"No Updates": "没有更新",
"No updates yet": "暂时没有更新",
"No updates yet": "没有更新",
"Notifications": "通知",
"One-time": "单次",
"One-time": "一度",
"Ongoing": "进行中",
"Operational": "正常运行",
"Partial Degraded Performance": "部分性能下降",
"Partial System Outage": "部分系统故障",
"Partial System Outage": "部分系统故障.",
"Past": "过去的",
"Per-Minute Status": "每分钟状态",
"Pinging": "检测中",
"Pinging": "pinging",
"Please enter a valid email address": "请输入有效的电子邮件地址",
"Please enter the 6-digit verification code": "请输入6位验证码",
"Read less": "收起",
"Read more": "更多",
"Read less": "少读书",
"Read more": "阅读更多",
"READY": "就绪",
"Recurring": "周期性",
"RESOLVED": "已解决",
"RSS feed": "RSS 订阅源",
"SCHEDULED": "已计划",
"RESOLVED": "RESOLVED",
"SCHEDULED": "已安排",
"Scheduled Events (%count)": "计划事件 (%count)",
"Script": "脚本",
"Select Language": "选择语言",
@@ -110,20 +109,20 @@
"Sending...": "正在发送...",
"Standard": "标准",
"Start Time": "开始时间",
"Status": "状态",
"Status": "地位",
"Status Badge": "状态徽章",
"Status Embed": "状态嵌入",
"Status history and latency trend": "状态历史和延迟趋势",
"Subscribe": "订阅",
"Subscribe to Updates": "订阅更新",
"There are no incidents or maintenances scheduled for this month.": "本月没有任何事故或安排的维护。",
"There are no ongoing incidents or maintenance events.": "当前没有正在进行的事件或维护。",
"Total Incidents": "事总数",
"Total Maintenances": "维护总数",
"There are no incidents or maintenances scheduled for this month.": "本月没有安排任何事故或维护。",
"There are no ongoing incidents or maintenance events.": "当前没有正在进行的事件或维护活动。",
"Total Incidents": "事总数",
"Total Maintenances": "全面维护",
"Under Maintenance": "维护中",
"Unknown impact": "未知影响",
"UP": "正常",
"Upcoming": "即将进行",
"Upcoming": "即将推出",
"Update Incident": "更新事件",
"Update Maintenance": "更新维护",
"Updates": "更新",
@@ -131,7 +130,7 @@
"Uptime": "正常运行时间",
"Uptime Badge": "正常运行时间徽章",
"Verification failed": "验证失败",
"Verify": "验证",
"Verify": "核实",
"Verifying": "正在验证",
"We sent a 6-digit code to": "我们发送了一个 6 位代码至"
}
-138
View File
@@ -1,138 +0,0 @@
{
"name": "繁體中文(香港)",
"mappings": {
"%latency %metric latency": "%latency %metric 延遲",
"Affected Monitors (%count)": "受影響的監控項 (%count)",
"All Systems Operational": "所有系統運行正常",
"Average Latency": "平均延遲",
"Avg Latency": "平均延遲",
"Back": "返回",
"Badges": "徽章",
"CANCELLED": "已取消",
"COMPLETED": "已完成",
"Continue": "繼續",
"Copied": "已複製",
"Current": "當前",
"Dark": "深色模式",
"Day": "天",
"Day Uptime": "今日正常運行時長",
"Days": "天",
"Degraded": "系統降級",
"DEGRADED": "系統降級",
"Degraded Performance": "效能下降",
"Didn't receive the code? Resend": "沒有收到驗證碼?重新發送",
"Down": "當機",
"DOWN": "故障",
"Duration": "持續時間",
"Email address": "電郵地址",
"Embed Monitor": "嵌入監控項",
"Embed this monitor in your website or app": "將此監控項嵌入您的網站或應用程式中",
"End Time": "結束時間",
"Enter the verification code sent to your email.": "輸入發送到您電郵的驗證碼。",
"Events": "動態",
"Failed to load data": "載入數據失敗",
"Failed to load latency data": "載入延遲數據失敗",
"Failed to load status data for this day": "無法載入當天的狀態數據",
"Failed to send verification code": "發送驗證碼失敗",
"Failed to update preference": "無法更新偏好設定",
"Get badges for this monitor": "獲取此監控項的徽章",
"Get notified about incidents and scheduled maintenance.": "獲取有關事件和定期維護的通知。",
"Get notified about incidents updates": "獲取有關事件更新的通知",
"Get notified about scheduled maintenance": "獲取有關定期維護的通知",
"IDENTIFIED": "已確認",
"iFrame": "框架",
"Impact": "影響",
"incident": "事件",
"Incident Updates": "事件更新",
"Incidents": "事件",
"Included Monitors (%count)": "包括的監控項 (%count)",
"INVESTIGATING": "調查中",
"Last Updated": "最後更新",
"Latency": "延遲",
"Latency Embed": "嵌入延遲",
"Latency Over Time": "歷史延遲",
"Latency Trend": "延遲趨勢",
"Latest Latency": "最新延遲時間",
"Latest Status": "最新狀態",
"Light": "淺色模式",
"Live Status": "即時狀態",
"Loading your preferences...": "正在載入您的偏好設定...",
"maintenance": "維護",
"MAINTENANCE": "維護中",
"Maintenance Updates": "維護更新",
"Maintenances": "例行維護",
"Major System Outage": "重大系統故障",
"Manage Site": "管理站點",
"Manage your notification preferences.": "管理您的通知偏好設定。",
"Max Latency": "最大延遲",
"Maximum Latency": "最大延遲",
"Min Latency": "最短延遲",
"Minimum Latency": "最短延遲",
"Minute-by-minute status data for this day": "當日每分鐘的狀態數據",
"MONITORING": "監察中",
"Network error. Please try again.": "網絡錯誤。請稍後再試。",
"No Events in %currentMonth": "%currentMonth 沒有活動",
"No events to show": "沒有可顯示的事件",
"No incidents for this day": "這一天沒有發生任何事件",
"No latency data available for this day": "當天沒有可用的延遲數據",
"No maintenances for this day": "這一天無維護",
"No monitors affected": "沒有監控項受到影響",
"No monitors available.": "沒有可用的監控項。",
"No ongoing maintenances": "沒有需要持續的維護",
"No past maintenances": "過去沒有維護過",
"No Status Available": "無可用狀態",
"No upcoming maintenances": "沒有即將進行的維護",
"No Updates": "沒有更新",
"No updates yet": "暫時沒有更新",
"Notifications": "通知",
"One-time": "單次",
"Ongoing": "進行中",
"Operational": "正常運行",
"Partial Degraded Performance": "部分效能下降",
"Partial System Outage": "部分系統故障",
"Past": "過去的",
"Per-Minute Status": "每分鐘狀態",
"Pinging": "檢測中",
"Please enter a valid email address": "請輸入有效的電郵地址",
"Please enter the 6-digit verification code": "請輸入6位驗證碼",
"Read less": "收起",
"Read more": "更多",
"READY": "就緒",
"Recurring": "週期性",
"RESOLVED": "已解決",
"RSS feed": "RSS 訂閱源",
"SCHEDULED": "已計劃",
"Scheduled Events (%count)": "計劃事件 (%count)",
"Script": "腳本",
"Select Language": "選擇語言",
"Select latency metric to display": "選擇要顯示的延遲指標",
"Select Range": "選擇範圍",
"Sending...": "正在發送...",
"Standard": "標準",
"Start Time": "開始時間",
"Status": "狀態",
"Status Badge": "狀態徽章",
"Status Embed": "狀態嵌入",
"Status history and latency trend": "狀態歷史和延遲趨勢",
"Subscribe": "訂閱",
"Subscribe to Updates": "訂閱更新",
"There are no incidents or maintenances scheduled for this month.": "本月沒有任何事故或安排的維護。",
"There are no ongoing incidents or maintenance events.": "當前沒有正在進行的事件或維護。",
"Total Incidents": "事件總數",
"Total Maintenances": "維護總數",
"Under Maintenance": "維護中",
"Unknown impact": "未知影響",
"UP": "正常",
"Upcoming": "即將進行",
"Update Incident": "更新事件",
"Update Maintenance": "更新維護",
"Updates": "更新",
"Updates (%count)": "更新 (%count)",
"Uptime": "正常運行時間",
"Uptime Badge": "正常運行時間徽章",
"Verification failed": "驗證失敗",
"Verify": "驗證",
"Verifying": "正在驗證",
"We sent a 6-digit code to": "我們已發送一個6位驗證碼至"
}
}
-138
View File
@@ -1,138 +0,0 @@
{
"name": "繁體中文(澳門)",
"mappings": {
"%latency %metric latency": "%latency %metric 延遲",
"Affected Monitors (%count)": "受影響的監控項 (%count)",
"All Systems Operational": "所有系統運行正常",
"Average Latency": "平均延遲",
"Avg Latency": "平均延遲",
"Back": "返回",
"Badges": "徽章",
"CANCELLED": "已取消",
"COMPLETED": "已完成",
"Continue": "繼續",
"Copied": "已複製",
"Current": "當前",
"Dark": "深色模式",
"Day": "天",
"Day Uptime": "今日正常運行時長",
"Days": "天",
"Degraded": "系統降級",
"DEGRADED": "系統降級",
"Degraded Performance": "效能下降",
"Didn't receive the code? Resend": "沒有收到驗證碼?重新發送",
"Down": "當機",
"DOWN": "故障",
"Duration": "持續時間",
"Email address": "電郵地址",
"Embed Monitor": "嵌入監控項",
"Embed this monitor in your website or app": "將此監控項嵌入您的網站或應用程式中",
"End Time": "結束時間",
"Enter the verification code sent to your email.": "輸入發送到您電郵的驗證碼。",
"Events": "動態",
"Failed to load data": "載入數據失敗",
"Failed to load latency data": "載入延遲數據失敗",
"Failed to load status data for this day": "無法載入當天的狀態數據",
"Failed to send verification code": "發送驗證碼失敗",
"Failed to update preference": "無法更新偏好設定",
"Get badges for this monitor": "獲取此監控項的徽章",
"Get notified about incidents and scheduled maintenance.": "獲取有關事件和定期維護的通知。",
"Get notified about incidents updates": "獲取有關事件更新的通知",
"Get notified about scheduled maintenance": "獲取有關定期維護的通知",
"IDENTIFIED": "已確認",
"iFrame": "框架",
"Impact": "影響",
"incident": "事件",
"Incident Updates": "事件更新",
"Incidents": "事件",
"Included Monitors (%count)": "包括的監控項 (%count)",
"INVESTIGATING": "調查中",
"Last Updated": "最後更新",
"Latency": "延遲",
"Latency Embed": "嵌入延遲",
"Latency Over Time": "歷史延遲",
"Latency Trend": "延遲趨勢",
"Latest Latency": "最新延遲時間",
"Latest Status": "最新狀態",
"Light": "淺色模式",
"Live Status": "即時狀態",
"Loading your preferences...": "正在載入您的偏好設定...",
"maintenance": "維護",
"MAINTENANCE": "維護中",
"Maintenance Updates": "維護更新",
"Maintenances": "例行維護",
"Major System Outage": "重大系統故障",
"Manage Site": "管理站點",
"Manage your notification preferences.": "管理您的通知偏好設定。",
"Max Latency": "最大延遲",
"Maximum Latency": "最大延遲",
"Min Latency": "最短延遲",
"Minimum Latency": "最短延遲",
"Minute-by-minute status data for this day": "當日每分鐘的狀態數據",
"MONITORING": "監察中",
"Network error. Please try again.": "網絡錯誤。請稍後再試。",
"No Events in %currentMonth": "%currentMonth 沒有活動",
"No events to show": "沒有可顯示的事件",
"No incidents for this day": "這一天沒有發生任何事件",
"No latency data available for this day": "當天沒有可用的延遲數據",
"No maintenances for this day": "這一天無維護",
"No monitors affected": "沒有監控項受到影響",
"No monitors available.": "沒有可用的監控項。",
"No ongoing maintenances": "沒有需要持續的維護",
"No past maintenances": "過去沒有維護過",
"No Status Available": "無可用狀態",
"No upcoming maintenances": "沒有即將進行的維護",
"No Updates": "沒有更新",
"No updates yet": "暫時沒有更新",
"Notifications": "通知",
"One-time": "單次",
"Ongoing": "進行中",
"Operational": "正常運行",
"Partial Degraded Performance": "部分效能下降",
"Partial System Outage": "部分系統故障",
"Past": "過去的",
"Per-Minute Status": "每分鐘狀態",
"Pinging": "檢測中",
"Please enter a valid email address": "請輸入有效的電郵地址",
"Please enter the 6-digit verification code": "請輸入6位驗證碼",
"Read less": "收起",
"Read more": "更多",
"READY": "就緒",
"Recurring": "週期性",
"RESOLVED": "已解決",
"RSS feed": "RSS 訂閱源",
"SCHEDULED": "已計劃",
"Scheduled Events (%count)": "計劃事件 (%count)",
"Script": "腳本",
"Select Language": "選擇語言",
"Select latency metric to display": "選擇要顯示的延遲指標",
"Select Range": "選擇範圍",
"Sending...": "正在發送...",
"Standard": "標準",
"Start Time": "開始時間",
"Status": "狀態",
"Status Badge": "狀態徽章",
"Status Embed": "狀態嵌入",
"Status history and latency trend": "狀態歷史和延遲趨勢",
"Subscribe": "訂閱",
"Subscribe to Updates": "訂閱更新",
"There are no incidents or maintenances scheduled for this month.": "本月沒有任何事故或安排的維護。",
"There are no ongoing incidents or maintenance events.": "當前沒有正在進行的事件或維護。",
"Total Incidents": "事件總數",
"Total Maintenances": "維護總數",
"Under Maintenance": "維護中",
"Unknown impact": "未知影響",
"UP": "正常",
"Upcoming": "即將進行",
"Update Incident": "更新事件",
"Update Maintenance": "更新維護",
"Updates": "更新",
"Updates (%count)": "更新 (%count)",
"Uptime": "正常運行時間",
"Uptime Badge": "正常運行時間徽章",
"Verification failed": "驗證失敗",
"Verify": "驗證",
"Verifying": "正在驗證",
"We sent a 6-digit code to": "我們已發送一個6位驗證碼至"
}
}
+69 -74
View File
@@ -2,8 +2,8 @@
"name": "繁體中文(台灣)",
"mappings": {
"%latency %metric latency": "%latency %metric 延遲",
"Affected Monitors (%count)": "受影響的監控項 (%count)",
"All Systems Operational": "所有系統運行正常",
"Affected Monitors (%count)": "受影響的監控項 (%count)",
"All Systems Operational": "所有系統正常運作",
"Average Latency": "平均延遲",
"Avg Latency": "平均延遲",
"Back": "返回",
@@ -12,127 +12,122 @@
"COMPLETED": "已完成",
"Continue": "繼續",
"Copied": "已複製",
"Current": "前",
"Dark": "深色模式",
"Current": "前",
"Dark": "深色",
"Day": "天",
"Day Uptime": "日正常運行時長",
"Day Uptime": "日正常運作時間",
"Days": "天",
"Degraded": "系統降級",
"DEGRADED": "系統降級",
"Degraded Performance": "效能下降",
"Didn't receive the code? Resend": "沒有收到代碼?重新發送",
"Down": "當機",
"DEGRADED": "降級",
"Degraded Performance": "效能降低",
"Didn't receive the code? Resend": "沒有收到驗證碼?重新傳送",
"DOWN": "故障",
"Duration": "持續時間",
"Email address": "電子郵件",
"Embed Monitor": "嵌入監控項",
"Embed this monitor in your website or app": "將此監控項嵌入您的網站或應用程式中",
"Edit Monitor": "編輯監控",
"Email address": "電子郵件地址",
"Embed Monitor": "嵌入監控",
"Embed this monitor in your website or app": "將此監控嵌入您的網站或應用程式",
"End Time": "結束時間",
"Enter the verification code sent to your email.": "輸入發送到您的電子郵件的驗證碼。",
"Events": "動態",
"Failed to load data": "載入數據失敗",
"Failed to load latency data": "載入延遲數據失敗",
"Failed to load status data for this day": "無法載入當的狀態數據",
"Failed to send verification code": "送驗證碼失敗",
"Failed to update preference": "無法更新偏好設定",
"Get badges for this monitor": "取此監控的徽章",
"Get notified about incidents and scheduled maintenance.": "獲取有關事件和定期維護的通知。",
"Get notified about incidents updates": "獲取有關事件更新通知",
"Get notified about scheduled maintenance": "獲取有關定期維護通知",
"Enter the verification code sent to your email.": "輸入傳送至您電子郵件的驗證碼。",
"Events": "事件",
"Failed to load data": "載入資料失敗",
"Failed to load latency data": "載入延遲資料失敗",
"Failed to load status data for this day": "無法載入當的狀態資料",
"Failed to send verification code": "送驗證碼失敗",
"Failed to update preference": "更新偏好設定失敗",
"Get badges for this monitor": "取此監控的徽章",
"Get notified about incidents and scheduled maintenance.": "接收事件與排程維護的通知。",
"Get notified about incidents updates": "接收事件更新通知",
"Get notified about scheduled maintenance": "接收排程維護通知",
"IDENTIFIED": "已確認",
"iFrame": "框架",
"iFrame": "iFrame",
"Impact": "影響",
"incident": "事件",
"Incident Updates": "事件更新",
"Incidents": "事件",
"Included Monitors (%count)": "包的監控項 (%count)",
"Included Monitors (%count)": "包的監控項 (%count)",
"INVESTIGATING": "調查中",
"Last Updated": "最後更新",
"Latency": "延遲",
"Latency Embed": "嵌入延遲",
"Latency Over Time": "歷史延遲",
"Latency Embed": "延遲嵌入",
"Latency Over Time": "延遲趨勢圖",
"Latency Trend": "延遲趨勢",
"Latest Latency": "最新延遲時間",
"Latest Latency": "最新延遲",
"Latest Status": "最新狀態",
"Light": "淺色模式",
"Light": "淺色",
"Live Status": "即時狀態",
"Loading your preferences...": "正在載入您的偏好設定...",
"maintenance": "維護",
"Loading your preferences...": "正在載入您的偏好設定……",
"MAINTENANCE": "維護中",
"Maintenance Updates": "維護更新",
"Maintenances": "例行維護",
"Major System Outage": "重大系統故障",
"Manage Site": "管理站",
"Manage your notification preferences.": "管理您的通知首選項。",
"Maintenances": "維護作業",
"Major System Outage": "重大系統中斷",
"Manage Site": "管理站",
"Manage your notification preferences.": "管理您的通知偏好設定。",
"Max Latency": "最大延遲",
"Maximum Latency": "最大延遲",
"Min Latency": "最延遲",
"Minimum Latency": "最延遲",
"Minute-by-minute status data for this day": "當日分鐘狀態數據",
"MONITORING": "監中",
"Network error. Please try again.": "網路錯誤。請稍後再試。",
"No Events in %currentMonth": "%currentMonth 沒有活動",
"Min Latency": "最延遲",
"Minimum Latency": "最延遲",
"Minute-by-minute status data for this day": "當日分鐘狀態資料",
"MONITORING": "監中",
"Network error. Please try again.": "網路錯誤,請重試。",
"No Events in %currentMonth": "%currentMonth 沒有事件",
"No events to show": "沒有可顯示的事件",
"No incidents for this day": "這一天沒有發生任何事件",
"No latency data available for this day": "當天沒有可用的延遲數據",
"No maintenances for this day": "這一天無維護",
"No monitors affected": "沒有監控項受到影響",
"No monitors available.": "沒有可用的監控項。",
"No ongoing maintenances": "沒有需要持續的維護",
"No past maintenances": "過去沒有維護過",
"No incidents for this day": "當日無事件",
"No latency data available for this day": "當日無延遲資料",
"No maintenances for this day": "當日無維護作業",
"No monitors affected": "沒有受影響的監控項目",
"No monitors available.": "沒有可用的監控項。",
"No ongoing maintenances": "沒有進行中的維護作業",
"No past maintenances": "沒有過去的維護作業",
"No Status Available": "無可用狀態",
"No upcoming maintenances": "沒有即將進行的維護",
"No upcoming maintenances": "沒有即將進行的維護作業",
"No Updates": "沒有更新",
"No updates yet": "暫時沒有更新",
"No updates yet": "尚無更新",
"Notifications": "通知",
"One-time": "單次",
"Ongoing": "進行中",
"Operational": "正常運行",
"Partial Degraded Performance": "部分效能下降",
"Partial System Outage": "部分系統故障",
"Past": "過去的",
"Per-Minute Status": "每分鐘狀態",
"Pinging": "檢測中",
"Partial Degraded Performance": "部分效能降低",
"Partial System Outage": "部分系統中斷",
"Past": "過去",
"Per-Minute Status": "逐分鐘狀態",
"Pinging": "偵測中",
"Please enter a valid email address": "請輸入有效的電子郵件地址",
"Please enter the 6-digit verification code": "請輸入6位驗證碼",
"Read less": "收",
"Read more": "更多",
"Please enter the 6-digit verification code": "請輸入 6 位數驗證碼",
"Read less": "收",
"Read more": "展開",
"READY": "就緒",
"Recurring": "週期性",
"RESOLVED": "已解決",
"RSS feed": "RSS 訂閱源",
"SCHEDULED": "已計劃",
"Scheduled Events (%count)": "計劃事件 (%count)",
"Script": "腳本",
"SCHEDULED": "已排程",
"Scheduled Events (%count)": "排程事件 (%count)",
"Script": "程式碼",
"Select Language": "選擇語言",
"Select latency metric to display": "選擇要顯示的延遲指標",
"Select Range": "選擇範圍",
"Sending...": "正在發送...",
"Sending...": "傳送中……",
"Standard": "標準",
"Start Time": "開始時間",
"Status": "狀態",
"Status Badge": "狀態徽章",
"Status Embed": "狀態嵌入",
"Status history and latency trend": "狀態歷史和延遲趨勢",
"Status history and latency trend": "狀態歷程與延遲趨勢",
"Subscribe": "訂閱",
"Subscribe to Updates": "訂閱更新",
"There are no incidents or maintenances scheduled for this month.": "本月沒有任何事故或安排的維護。",
"There are no ongoing incidents or maintenance events.": "前沒有正在進行的事件或維護。",
"There are no incidents or maintenances scheduled for this month.": "本月沒有排程的事件或維護作業。",
"There are no ongoing incidents or maintenance events.": "前沒有進行的事件或維護作業。",
"Total Incidents": "事件總數",
"Total Maintenances": "維護總數",
"Under Maintenance": "維護中",
"Unknown impact": "未知影響",
"Unknown impact": "影響不明",
"UP": "正常",
"Upcoming": "即將進行",
"Update Incident": "更新事件",
"Update Maintenance": "更新維護",
"Updates": "更新",
"Updates (%count)": "更新 (%count)",
"Uptime": "正常運時間",
"Uptime Badge": "正常運時間徽章",
"Uptime": "正常運時間",
"Uptime Badge": "正常運時間徽章",
"Verification failed": "驗證失敗",
"Verify": "驗證",
"Verifying": "正在驗證",
"We sent a 6-digit code to": "我們發送了一個 6 位碼至"
"Verifying": "驗證",
"We sent a 6-digit code to": "我們已傳送 6 位數驗證碼至"
}
}
@@ -17,9 +17,6 @@ import type {
import type { GroupMonitorTypeData } from "../types/monitor.js";
import GC from "../../global-constants.js";
import type { LayoutServerData } from "./layoutController.js";
import type { NotificationEvent } from "../../types/notifications.js";
export type { NotificationEvent };
// Default page settings
const defaultPageSettings: PageSettingsType = {
@@ -30,6 +27,16 @@ const defaultPageSettings: PageSettingsType = {
monitor_layout_style: GC.DEFAULT_MONITOR_LAYOUT_STYLE,
};
export interface NotificationEvent {
eventURL: string;
eventTitle: string;
eventDate: string;
eventType: string;
eventStartDateTime: number;
eventEndDateTime: number | null;
eventStatus: string;
}
export interface NotificationPayload {
notifications: NotificationEvent[];
}
@@ -370,18 +377,17 @@ export const GetPageDashboardData = async (
};
}
const eventSettings = layoutData.eventDisplaySettings;
const showInlineEvents = eventSettings.showInlineEvents === true;
// Fetch all dashboard data in parallel (respecting feature toggles)
const [latestData, parsedMonitors, ongoingIncidents, ongoingMaintenances, upcomingMaintenances] = await Promise.all([
GetLatestMonitoringDataAllActive(monitorTags),
GetMonitorsParsed({ tags: monitorTags, status: "ACTIVE", is_hidden: "NO" }),
showInlineEvents && eventSettings.incidents.enabled && eventSettings.incidents.ongoing.show
eventSettings.incidents.enabled && eventSettings.incidents.ongoing.show
? GetOngoingIncidentsForMonitorList(monitorTags)
: Promise.resolve([] as IncidentForMonitorListWithComments[]),
showInlineEvents && eventSettings.maintenances.enabled && eventSettings.maintenances.ongoing.show
eventSettings.maintenances.enabled && eventSettings.maintenances.ongoing.show
? GetOngoingMaintenances(monitorTags, nowTs)
: Promise.resolve([] as MaintenanceEventsMonitorList[]),
showInlineEvents && eventSettings.maintenances.enabled && eventSettings.maintenances.upcoming.show
eventSettings.maintenances.enabled && eventSettings.maintenances.upcoming.show
? GetUpcomingMaintenanceEventsForMonitorList(
monitorTags,
eventSettings.maintenances.upcoming.maxCount,
@@ -17,11 +17,6 @@ import type {
} from "../types/db.js";
import GC from "../../global-constants.js";
import { getUnixTime, differenceInSeconds } from "date-fns";
import { siteDataToVariables } from "../notification/notification_utils.js";
import { GetAllSiteData } from "./siteDataController.js";
import subscriberQueue from "../queues/subscriberQueue.js";
import mdToHTML from "../../marked.js";
import type { SubscriptionVariableMap } from "../notification/types.js";
interface IncidentsDashboardInput {
page: number;
@@ -327,6 +322,11 @@ export const CreateIncident = async (data: IncidentInput): Promise<{ incident_id
let newIncident = await db.createIncident(incident);
//we need to
// queueController.PushDataToQueue(newIncident.id, "createIncident", {
// message: `${incident.incident_type} Created`,
// ...incident,
// });
return {
incident_id: newIncident.id,
};
@@ -354,6 +354,25 @@ export const UpdateIncident = async (incident_id: number, data: IncidentUpdateIn
is_global: data.is_global !== undefined ? data.is_global : incidentExists.is_global,
};
//check if updateObject same as incidentExists
// if (
// JSON.stringify(updateObject) ===
// JSON.stringify({
// id: incidentExists.id,
// title: incidentExists.title,
// start_date_time: incidentExists.start_date_time,
// status: incidentExists.status,
// state: incidentExists.state,
// end_date_time: incidentExists.end_date_time,
// })
// ) {
// queueController.PushDataToQueue(incident_id, "updateIncident", {
// message: `${incidentExists.incident_type} has been updated to ${updateObject.state}`,
// incident_type: incidentExists.incident_type,
// title: incidentExists.title,
// });
// }
return await db.updateIncident(updateObject as IncidentRecord);
};
@@ -383,6 +402,11 @@ export const AddIncidentMonitor = async (
throw new Error(`Incident with id ${incident_id} does not exist`);
}
// queueController.PushDataToQueue(incident_id, "insertIncidentMonitor", {
// title: incidentExists.title,
// message: `Monitor ${monitor_tag} added to ${incidentExists.incident_type}. Impact is ${monitor_impact}`,
// incident_type: incidentExists.incident_type,
// });
return await db.insertIncidentMonitorWithMerge({
incident_id,
monitor_tag,
@@ -405,6 +429,11 @@ export const UpdateCommentByID = async (
if (!commentExists) {
throw new Error(`Comment with id ${comment_id} does not exist`);
}
// queueController.PushDataToQueue(incident_id, "updateIncidentComment", {
// title: incidentExists.title,
// message: `${comment}`,
// incident_type: incidentExists.incident_type,
// });
let c = await db.updateIncidentCommentByID(comment_id, comment, state, commented_at);
if (c) {
let incidentUpdate: IncidentUpdateInput = {
@@ -421,38 +450,6 @@ export const UpdateCommentByID = async (
}
return c;
};
// Subscriber notifications are driven solely by incident comments: the comment
// timeline is the incident's public communication channel, so posting a comment
// is the one event that emails "incidents" subscribers — regardless of whether
// the incident came from an alert, the dashboard, or the API. This is the single
// choke point; alertingQueue must not push its own incident notifications, or
// alert-driven incidents would notify twice.
const notifySubscribersOfComment = async (
incident: Pick<IncidentRecord, "id" | "title">,
comment: IncidentCommentRecord,
): Promise<void> => {
try {
const siteData = await GetAllSiteData();
const siteUrl = siteDataToVariables(siteData).site_url;
const variables: SubscriptionVariableMap = {
title: incident.title,
cta_url: `${siteUrl}incidents/${incident.id}`,
cta_text: "View Incident",
update_text: mdToHTML(comment.comment),
update_subject: `[#${incident.id}:${comment.state}] ${incident.title}`,
update_id: String(comment.id),
event_type: "incidents",
};
// Stable dedup id per comment so a retried/double push notifies once — without
// it subscriberQueue falls back to a Date.now()-suffixed id that never dedupes.
await subscriberQueue.push(variables, {
deduplication: { id: `subscriber-incidents-${comment.id}` },
});
} catch (err) {
console.error(`Error sending subscriber notification for incident ${incident.id}:`, err);
}
};
export const AddIncidentComment = async (
incident_id: number,
comment: string,
@@ -483,7 +480,6 @@ export const AddIncidentComment = async (
}
}
await UpdateIncident(incident_id, incidentUpdate);
await notifySubscribersOfComment(incidentExists, c);
}
return c;
+1 -39
View File
@@ -48,7 +48,6 @@ export interface LayoutServerData {
subMenuOptions: {
showShareBadgeMonitor: boolean;
showShareEmbedMonitor: boolean;
showRssFeed: boolean;
};
isTimezoneEnabled: boolean;
isThemeToggleEnabled: boolean;
@@ -76,43 +75,6 @@ export interface LayoutServerData {
metaSiteDescription?: string;
}
function NormalizeEventDisplaySettings(settings?: Partial<EventDisplaySettings>): EventDisplaySettings {
const defaults = structuredClone(seedSiteData.eventDisplaySettings);
return {
showInlineEvents:
typeof settings?.showInlineEvents === "boolean" ? settings.showInlineEvents : defaults.showInlineEvents,
incidents: {
...defaults.incidents,
...settings?.incidents,
ongoing: {
...defaults.incidents.ongoing,
...settings?.incidents?.ongoing,
},
resolved: {
...defaults.incidents.resolved,
...settings?.incidents?.resolved,
},
},
maintenances: {
...defaults.maintenances,
...settings?.maintenances,
ongoing: {
...defaults.maintenances.ongoing,
...settings?.maintenances?.ongoing,
},
past: {
...defaults.maintenances.past,
...settings?.maintenances?.past,
},
upcoming: {
...defaults.maintenances.upcoming,
...settings?.maintenances?.upcoming,
},
},
};
}
export async function GetLayoutServerData(cookies: Cookies, request: Request): Promise<LayoutServerData> {
const userAgent = request.headers.get("user-agent") ?? "";
const md = new MobileDetect(userAgent);
@@ -176,7 +138,7 @@ export async function GetLayoutServerData(cookies: Cookies, request: Request): P
font,
canSendEmail,
announcement: siteData.announcement,
eventDisplaySettings: NormalizeEventDisplaySettings(siteData.eventDisplaySettings),
eventDisplaySettings: siteData.eventDisplaySettings || seedSiteData.eventDisplaySettings,
socialPreviewImage: siteData.socialPreviewImage,
customCSS: siteData.customCSS,
globalPageVisibilitySettings: siteData.globalPageVisibilitySettings || seedSiteData.globalPageVisibilitySettings,
@@ -486,75 +486,18 @@ export const UpdateMaintenanceEvent = async (
return await db.updateMaintenanceEvent(id, data);
};
/**
* Manually transition a maintenance event to a terminal status.
* Allowed transitions: ONGOING → COMPLETED, SCHEDULED/READY/ONGOING → CANCELLED.
* An event that already started has its end_date_time moved to the moment it was
* ended (the record reflects what actually happened); an event that never started
* keeps its planned window. See docs/adr/0006-manual-maintenance-event-transitions.md
*/
export const UpdateMaintenanceEventStatus = async (id: number, status: string): Promise<MaintenanceEventRecord> => {
if (status !== GC.COMPLETED && status !== GC.CANCELLED) {
throw new Error(`Invalid status: ${status}. Allowed values are ${GC.COMPLETED} and ${GC.CANCELLED}`);
export const UpdateMaintenanceEventStatus = async (id: number, status: string): Promise<number> => {
const validStatuses = ["SCHEDULED", "IN_PROGRESS", "COMPLETED", "CANCELLED"];
if (!validStatuses.includes(status)) {
throw new Error(`Invalid status: ${status}`);
}
const targetStatus = status as "COMPLETED" | "CANCELLED";
const existing = await db.getMaintenanceEventById(id);
if (!existing) {
throw new Error(`Maintenance event with id ${id} does not exist`);
}
const allowedFrom: string[] = targetStatus === GC.COMPLETED ? [GC.ONGOING] : [GC.SCHEDULED, GC.READY, GC.ONGOING];
if (!allowedFrom.includes(existing.status)) {
throw new Error(`Cannot transition event from ${existing.status} to ${targetStatus}`);
}
if (existing.status === GC.ONGOING) {
// Ended now, but never before its first minute nor after its planned end
const endDateTime = Math.min(
existing.end_date_time,
Math.max(GetMinuteStartNowTimestampUTC(), existing.start_date_time + 60),
);
await db.updateMaintenanceEvent(id, { status: targetStatus, end_date_time: endDateTime });
} else {
await db.updateMaintenanceEventStatus(id, targetStatus);
}
const updated = await db.getMaintenanceEventById(id);
if (!updated) {
throw new Error(`Maintenance event with id ${id} does not exist`);
}
try {
const siteData = await GetAllSiteData();
const notificationSettings =
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
if (notificationSettings.event_types.ended) {
const siteVars = siteDataToVariables(siteData);
const siteUrl = siteVars.site_url;
const maintenance = await db.getMaintenanceById(updated.maintenance_id);
const monitors = await db.getMonitorsByMaintenanceId(updated.maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const eventDetailed: MaintenanceEventRecordDetailed = {
...updated,
title: maintenance?.title || "",
description: maintenance?.description || null,
};
const update = maintenanceToVariables(
eventDetailed,
monitorNames,
targetStatus === GC.COMPLETED ? "**has been completed**" : "**has been cancelled**",
targetStatus === GC.COMPLETED ? "completed" : "cancelled",
targetStatus === GC.COMPLETED ? "Maintenance Completed" : "Maintenance Cancelled",
siteUrl,
);
await subscriberQueue.push(update);
}
} catch (err) {
console.error(`Error sending ${targetStatus} notification for maintenance event ${id}:`, err);
}
return updated;
return await db.updateMaintenanceEventStatus(id, status);
};
export const DeleteMaintenanceEvent = async (id: number): Promise<number> => {
@@ -328,8 +328,7 @@ export async function DeleteMonitorAlertConfig(id: number): Promise<boolean> {
throw new Error(`Monitor alert config with id '${id}' not found`);
}
// The repository deletes trigger/monitor junctions and v2 alerts explicitly;
// FK cascades are not enforced on SQLite
// Triggers will be deleted automatically due to CASCADE
const deleted = await db.deleteMonitorAlertConfig(id);
return deleted > 0;
}
@@ -21,11 +21,11 @@ import type {
import type { MonitorFilter } from "../db/repositories/base.js";
import db from "../db/db.js";
import type { PaginationInput } from "../../types/common.js";
import type { DayWiseStatus, NumberWithChange } from "../../types/monitor.js";
import GC, { getBadgeStyle, type BadgeStyle } from "../../global-constants.js";
import { makeBadge } from "badge-maker";
import { ErrorSvg } from "../../anywhere.js";
import { GetLastMonitoringValue, SetLastHeartbeat, DeleteMonitorCaches } from "../cache/setGet.js";
import { CollapseStatusCounts } from "../../clientTools.js";
import { translate, isLocaleAvailable } from "../i18n.js";
import type { HeartbeatMonitor, GroupMonitorTypeData } from "../types/monitor.js";
@@ -92,7 +92,6 @@ interface MonitoringDataInput {
latency?: number;
type: string;
error_message?: string | null;
raw_status?: string | null;
}
interface InterpolatedDataEntry {
@@ -113,7 +112,6 @@ export const InsertMonitoringData = async (data: MonitoringDataInput): Promise<M
latency: data.latency || 0,
type: data.type,
error_message: data.error_message,
raw_status: data.raw_status,
});
};
@@ -192,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 {
@@ -266,7 +287,6 @@ export const CloneMonitor = async ({ sourceTag, newTag, newName }: CloneMonitorI
type_data: source.type_data,
day_degraded_minimum_count: source.day_degraded_minimum_count,
day_down_minimum_count: source.day_down_minimum_count,
confirmation_threshold: source.confirmation_threshold,
include_degraded_in_downtime: source.include_degraded_in_downtime,
is_hidden: source.is_hidden,
monitor_settings_json: source.monitor_settings_json,
@@ -293,7 +313,7 @@ export const GetLatestMonitoringData = async (monitor_tag: string): Promise<Moni
};
export const GetLatestStatusActiveAll = async (): Promise<{ status: string }> => {
//get all the active not hidden monitor tags
const monitors = await db.getMonitors({ status: GC.ACTIVE, is_hidden: GC.NO });
const monitors = await db.getMonitors({ status: "ACTIVE", is_hidden: "NO" });
const monitor_tags = monitors.map((m) => m.tag);
const latestData: MonitoringData[] = [];
@@ -305,20 +325,19 @@ export const GetLatestStatusActiveAll = async (): Promise<{ status: string }> =>
}
}
const counts = { countOfUp: 0, countOfDown: 0, countOfDegraded: 0, countOfMaintenance: 0 };
for (const data of latestData) {
if (data.status === GC.UP) {
counts.countOfUp++;
} else if (data.status === GC.DOWN) {
counts.countOfDown++;
} else if (data.status === GC.DEGRADED) {
counts.countOfDegraded++;
} else if (data.status === GC.MAINTENANCE) {
counts.countOfMaintenance++;
let status: string = GC.NO_DATA;
for (let i = 0; i < latestData.length; i++) {
//if any status is down then status = down, if any is degraded then status = degraded, down > degraded > up
if (latestData[i].status === GC.DOWN) {
status = GC.DOWN;
} else if (latestData[i].status === GC.DEGRADED && status !== GC.DOWN) {
status = GC.DEGRADED;
} else if (latestData[i].status === GC.UP && status !== GC.DOWN && status !== GC.DEGRADED) {
status = GC.UP;
}
}
return {
status: CollapseStatusCounts(counts),
status: status,
};
};
@@ -423,7 +442,6 @@ async function removeTagFromGroupMonitors(tag: string): Promise<void> {
type_data: JSON.stringify(typeData),
day_degraded_minimum_count: group.day_degraded_minimum_count,
day_down_minimum_count: group.day_down_minimum_count,
confirmation_threshold: group.confirmation_threshold,
include_degraded_in_downtime: group.include_degraded_in_downtime,
is_hidden: group.is_hidden,
monitor_settings_json:
@@ -441,7 +459,6 @@ export const DeleteMonitorCompletelyUsingTag = async (tag: string): Promise<numb
await db.deleteMonitorDataByTag(tag);
await db.deleteIncidentMonitorsByTag(tag);
await db.deleteMonitorAlertsByTag(tag);
await db.deleteMonitorAlertConfigsByMonitorTag(tag);
await db.deletePageMonitorsByTag(tag);
await db.deleteMaintenanceMonitorsByTag(tag);
await removeTagFromGroupMonitors(tag);
@@ -467,6 +484,9 @@ export const GetAllAlertsPaginated = async (
export const GetMonitoringData = async (tag: string, since: number, now: number): Promise<MonitoringData[]> => {
return await db.getMonitoringData(tag, since, now);
};
export const GetMonitoringDataAll = async (tags: string[], since: number, now: number): Promise<MonitoringData[]> => {
return await db.getMonitoringDataAll(tags, since, now);
};
export const InsertNewAlert = async (data: MonitorAlertInsert): Promise<MonitorAlert | undefined> => {
if (await db.alertExists(data.monitor_tag, data.monitor_status, data.alert_status)) {
@@ -550,7 +570,7 @@ export const GetBadge = async (badgeType: BadgeType, params: BadgeParams): Promi
lastObj = await GetLatestStatusActiveAll();
} else {
// Single monitor status
const monitors = await GetMonitorsParsed({ tag, status: GC.ACTIVE, is_hidden: GC.NO });
const monitors = await GetMonitorsParsed({ tag, status: "ACTIVE", is_hidden: "NO" });
if (monitors.length === 0) {
return new Response(ErrorSvg, {
headers: { "Content-Type": "image/svg+xml" },
@@ -638,14 +658,14 @@ export const GetBadge = async (badgeType: BadgeType, params: BadgeParams): Promi
const siteData = await db.getSiteDataByKey("siteName");
const siteName = siteData?.value as string | undefined;
name = siteName || "All Monitors";
const goodMonitors = await GetMonitorsParsed({ status: GC.ACTIVE, is_hidden: GC.NO });
const goodMonitors = await GetMonitorsParsed({ status: "ACTIVE", is_hidden: "NO" });
const activeTags = goodMonitors.map((monitor) => monitor.tag);
stats = await db.getStatusCountsByInterval(activeTags, since, now - since, 1);
uptimeData = UptimeCalculator(stats);
} else {
// Single monitor badge
const monitors = await GetMonitorsParsed({ tag, status: GC.ACTIVE, is_hidden: GC.NO });
const monitors = await GetMonitorsParsed({ tag });
if (monitors.length === 0) {
return new Response(ErrorSvg, {
headers: { "Content-Type": "image/svg+xml" },
@@ -754,6 +774,3 @@ export const GetStatusCountsByIntervalGroupedByMonitor = async (
await setCache(cacheKey, result, 60);
return result;
};
export const GetLastKnownStatus = async (monitor_tag: string): Promise<MonitoringData | undefined> => {
return await db.getLastKnownStatus(monitor_tag);
};
+2 -2
View File
@@ -1,5 +1,5 @@
import DbImpl from "./dbimpl";
import knexOb, { workerKnexOb } from "../../../../knexfile.js";
import knexOb from "../../../../knexfile.js";
const instance: DbImpl = new DbImpl(knexOb, workerKnexOb);
const instance: DbImpl = new DbImpl(knexOb);
export default instance;
+6 -29
View File
@@ -1,6 +1,5 @@
import Knex from "knex";
import type { Knex as KnexType } from "knex";
import { runWithWorkerKnex } from "./poolContext.js";
// Import all repositories
import { MonitoringRepository } from "./repositories/monitoring.js";
@@ -30,9 +29,6 @@ export type * from "../types/db.js";
*/
class DbImpl {
private knex: KnexType;
// Dedicated pool for background jobs (Postgres/MySQL). Equals `knex` when
// there is no separate worker pool (e.g. SQLite).
private workerKnex: KnexType;
// Domain repositories
private monitoring!: MonitoringRepository;
@@ -52,7 +48,9 @@ class DbImpl {
// ============ Monitoring Data ============
insertMonitoringData!: MonitoringRepository["insertMonitoringData"];
getMonitoringData!: MonitoringRepository["getMonitoringData"];
getMonitoringDataAll!: MonitoringRepository["getMonitoringDataAll"];
getLatestMonitoringData!: MonitoringRepository["getLatestMonitoringData"];
getLatestAlertVisibleData!: MonitoringRepository["getLatestAlertVisibleData"];
getLatestMonitoringDataN!: MonitoringRepository["getLatestMonitoringDataN"];
getMonitoringDataPaginated!: MonitoringRepository["getMonitoringDataPaginated"];
getMonitoringDataCount!: MonitoringRepository["getMonitoringDataCount"];
@@ -68,15 +66,11 @@ class DbImpl {
consecutivelyStatusFor!: MonitoringRepository["consecutivelyStatusFor"];
consecutivelyLatencyGreaterThan!: MonitoringRepository["consecutivelyLatencyGreaterThan"];
consecutivelyLatencyLessThan!: MonitoringRepository["consecutivelyLatencyLessThan"];
getRecentSamplesForConfirmation!: MonitoringRepository["getRecentSamplesForConfirmation"];
getLastObservedStatus!: MonitoringRepository["getLastObservedStatus"];
backfillConfirmedStatus!: MonitoringRepository["backfillConfirmedStatus"];
updateMonitoringData!: MonitoringRepository["updateMonitoringData"];
deleteMonitorDataByTag!: MonitoringRepository["deleteMonitorDataByTag"];
getStatusCountsByInterval!: MonitoringRepository["getStatusCountsByInterval"];
getStatusCountsByIntervalGroupedByMonitor!: MonitoringRepository["getStatusCountsByIntervalGroupedByMonitor"];
getStatusCountsForLastN!: MonitoringRepository["getStatusCountsForLastN"];
getLastKnownStatus!: MonitoringRepository["getLastKnownStatus"];
// ============ Monitors ============
getMonitorsByTags!: MonitorsRepository["getMonitorsByTags"];
@@ -378,11 +372,8 @@ class DbImpl {
deleteEmailTemplate!: EmailTemplateConfigRepository["deleteEmailTemplate"];
upsertEmailTemplate!: EmailTemplateConfigRepository["upsertEmailTemplate"];
constructor(opts: KnexType.Config, workerOpts?: KnexType.Config | null) {
constructor(opts: KnexType.Config) {
this.knex = Knex(opts);
// Separate pool for background jobs when configured (Postgres/MySQL);
// otherwise reuse the web pool (SQLite has a single connection).
this.workerKnex = workerOpts ? Knex(workerOpts) : this.knex;
// Initialize repositories
this.monitoring = new MonitoringRepository(this.knex);
@@ -418,7 +409,9 @@ class DbImpl {
private bindMonitoringMethods(): void {
this.insertMonitoringData = this.monitoring.insertMonitoringData.bind(this.monitoring);
this.getMonitoringData = this.monitoring.getMonitoringData.bind(this.monitoring);
this.getMonitoringDataAll = this.monitoring.getMonitoringDataAll.bind(this.monitoring);
this.getLatestMonitoringData = this.monitoring.getLatestMonitoringData.bind(this.monitoring);
this.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);
@@ -434,9 +427,6 @@ class DbImpl {
this.consecutivelyStatusFor = this.monitoring.consecutivelyStatusFor.bind(this.monitoring);
this.consecutivelyLatencyGreaterThan = this.monitoring.consecutivelyLatencyGreaterThan.bind(this.monitoring);
this.consecutivelyLatencyLessThan = this.monitoring.consecutivelyLatencyLessThan.bind(this.monitoring);
this.getRecentSamplesForConfirmation = this.monitoring.getRecentSamplesForConfirmation.bind(this.monitoring);
this.getLastObservedStatus = this.monitoring.getLastObservedStatus.bind(this.monitoring);
this.backfillConfirmedStatus = this.monitoring.backfillConfirmedStatus.bind(this.monitoring);
this.updateMonitoringData = this.monitoring.updateMonitoringData.bind(this.monitoring);
this.deleteMonitorDataByTag = this.monitoring.deleteMonitorDataByTag.bind(this.monitoring);
this.getStatusCountsByInterval = this.monitoring.getStatusCountsByInterval.bind(this.monitoring);
@@ -444,7 +434,6 @@ class DbImpl {
this.monitoring,
);
this.getStatusCountsForLastN = this.monitoring.getStatusCountsForLastN.bind(this.monitoring);
this.getLastKnownStatus = this.monitoring.getLastKnownStatus.bind(this.monitoring);
}
private bindMonitorsMethods(): void {
@@ -847,15 +836,6 @@ class DbImpl {
async init(): Promise<void> {}
/**
* Runs `fn` with all repository queries routed to the worker connection pool.
* Wrap background work (BullMQ job processors, schedulers) with this so a
* burst of jobs cannot exhaust the web pool that serves page loads.
*/
runInWorkerContext<T>(fn: () => Promise<T>): Promise<T> {
return runWithWorkerKnex(this.workerKnex, fn);
}
/** Probes database connectivity with a trivial query. Never throws. */
async ping(): Promise<boolean> {
try {
@@ -867,10 +847,7 @@ class DbImpl {
}
async close(): Promise<void> {
await this.knex.destroy();
if (this.workerKnex !== this.knex) {
await this.workerKnex.destroy();
}
return await this.knex.destroy();
}
}
-26
View File
@@ -1,26 +0,0 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type { Knex as KnexType } from "knex";
// Per-execution-context selection of the database connection pool.
//
// Kener runs SvelteKit requests, the cron scheduler, and the BullMQ workers in
// a single process, all sharing one Knex instance. A burst of background jobs
// could therefore exhaust the connection pool and time out user-facing page
// loads (KnexTimeoutError on acquire). To prevent that, background work runs
// against a dedicated worker pool: queues/q.ts wraps every job processor in
// runWithWorkerKnex(), and BaseRepository reads getWorkerKnex() so its queries
// route to that pool. Anything outside a job (requests, startup, migrations)
// has no store set and falls back to the web pool.
//
// See knexfile.ts for pool sizing and docs .../setup/database-setup.md.
const workerKnexStorage = new AsyncLocalStorage<KnexType>();
/** Runs `fn` with all repository queries routed to the worker pool `knex`. */
export function runWithWorkerKnex<T>(knex: KnexType, fn: () => Promise<T>): Promise<T> {
return workerKnexStorage.run(knex, fn);
}
/** The worker pool for the current context, or undefined when not in a job. */
export function getWorkerKnex(): KnexType | undefined {
return workerKnexStorage.getStore();
}
+2 -16
View File
@@ -1,5 +1,4 @@
import type { Knex as KnexType } from "knex";
import { getWorkerKnex } from "../poolContext.js";
// Filter types for queries
export interface MonitorFilter {
@@ -36,22 +35,9 @@ export interface CountResult {
* Base repository class that provides access to the Knex instance
*/
export abstract class BaseRepository {
private readonly fallbackKnex: KnexType;
protected knex: KnexType;
constructor(knex: KnexType) {
this.fallbackKnex = knex;
}
/**
* The Knex instance for the current execution context.
*
* Background jobs run inside a worker-pool context (set in queues/q.ts), so
* their queries use the dedicated worker connection pool. Everything else —
* SvelteKit requests, startup — falls back to the web pool this repository
* was constructed with. This keeps a burst of background jobs from exhausting
* the connections that serve page loads. See poolContext.ts and knexfile.ts.
*/
protected get knex(): KnexType {
return getWorkerKnex() ?? this.fallbackKnex;
this.knex = knex;
}
}
@@ -483,7 +483,6 @@ export class MaintenancesRepository extends BaseRepository {
"maintenances_events.created_at",
"maintenances_events.updated_at",
"maintenances_events.status as status",
"maintenances.is_global as is_global",
)
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
@@ -517,7 +516,6 @@ export class MaintenancesRepository extends BaseRepository {
"maintenances_events.created_at",
"maintenances_events.updated_at",
"maintenances_events.status as status",
"maintenances.is_global as is_global",
)
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
@@ -566,7 +564,6 @@ export class MaintenancesRepository extends BaseRepository {
"monitors.is_hidden as monitor_is_hidden",
"maintenances_events.created_at",
"maintenances_events.updated_at",
"maintenances.is_global as is_global",
)
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
@@ -609,7 +606,6 @@ export class MaintenancesRepository extends BaseRepository {
"maintenances_events.created_at",
"maintenances_events.updated_at",
"maintenances_events.status as status",
"maintenances.is_global as is_global",
)
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
@@ -652,7 +648,6 @@ export class MaintenancesRepository extends BaseRepository {
"maintenances_events.created_at",
"maintenances_events.updated_at",
"maintenances_events.status as status",
"maintenances.is_global as is_global",
)
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
@@ -691,7 +686,6 @@ export class MaintenancesRepository extends BaseRepository {
"maintenances_events.created_at",
"maintenances_events.updated_at",
"maintenances_events.status as status",
"maintenances.is_global as is_global",
)
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
@@ -720,7 +714,6 @@ export class MaintenancesRepository extends BaseRepository {
start_date_time: row.start_date_time,
end_date_time: row.end_date_time,
status: row.status,
is_global: row.is_global,
created_at: row.created_at,
updated_at: row.updated_at,
monitors: [],
@@ -141,17 +141,9 @@ export class MonitorAlertConfigRepository extends BaseRepository {
}
/**
* Delete a monitor alert config by ID, including all child rows.
*
* Child rows are removed explicitly even though FK cascades are declared:
* SQLite never enforces them (foreign_keys pragma is off), so relying on
* CASCADE orphans children on the default deployment. See
* docs/adr/0008-explicit-deletes-over-fk-cascades.md.
* Delete a monitor alert config by ID
*/
async deleteMonitorAlertConfig(id: number): Promise<number> {
await this.knex("monitor_alerts_v2").where({ config_id: id }).del();
await this.knex("monitor_alerts_config_triggers").where({ monitor_alerts_id: id }).del();
await this.knex("monitor_alerts_config_monitors").where({ monitor_alerts_id: id }).del();
return await this.knex("monitor_alerts_config").where({ id }).del();
}
@@ -166,11 +158,7 @@ export class MonitorAlertConfigRepository extends BaseRepository {
if (configIds.length === 0) return 0;
// Remove the monitor from the junction table, along with its per-monitor
// alert state — a shared config survives the detach, but its v2 rows for
// this tag would otherwise dangle (see deleteMonitorAlertConfig on why
// FK cascades can't be relied on)
await this.knex("monitor_alerts_v2").where({ monitor_tag: monitorTag }).del();
// Remove the monitor from the junction table
await this.knex("monitor_alerts_config_monitors").where({ monitor_tag: monitorTag }).del();
// Delete any configs that now have zero monitors
@@ -182,7 +170,7 @@ export class MonitorAlertConfigRepository extends BaseRepository {
.where({ monitor_alerts_id: id })
.first<CountResult>();
if (Number(remainingMonitors?.count) === 0) {
await this.deleteMonitorAlertConfig(id);
await this.knex("monitor_alerts_config").where({ id }).del();
deletedCount++;
}
}
+50 -127
View File
@@ -11,41 +11,28 @@ import type {
} from "../../types/db.js";
/**
* Sample types alert evaluation can see (see docs/adr/0005-alerts-evaluate-alert-visible-samples.md).
* 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), and data-API pushes (MANUAL).
* (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];
/**
* Scheduled-check sample types that count toward a monitor's Confirmation Threshold
* (issue #712). Intentionally narrower than ALERT_VISIBLE_TYPES: MANUAL pushes
* and DEFAULT_STATUS fill stay transparent to threshold counting.
*/
const OBSERVED_CHECK_TYPES = [GC.REALTIME, GC.TIMEOUT, GC.ERROR];
/**
* Overlay sample types that FREEZE Confirmation Threshold counting (issue #712):
* while one is active the count does not advance, and it acts as a hard boundary the
* pending run cannot cross. Included in the confirmation lookback (unlike MANUAL/DEFAULT,
* which stay transparent) so the resolver can detect the boundary.
*/
const OVERLAY_TYPES = [GC.INCIDENT, GC.MAINTENANCE];
const ALERT_VISIBLE_TYPES = [GC.REALTIME, GC.ERROR, GC.TIMEOUT, GC.MANUAL, GC.DEFAULT_STATUS, GC.CARRIED];
/**
* Repository for monitoring data operations
*/
export class MonitoringRepository extends BaseRepository {
async insertMonitoringData(data: MonitoringDataInsert): Promise<MonitoringData | null> {
const { monitor_tag, timestamp, status, latency, type, error_message, raw_status } = data;
const { monitor_tag, timestamp, status, latency, type, error_message } = data;
// Perform insert/update - works across PostgreSQL, MySQL, and SQLite
await this.knex("monitoring_data")
.insert({ monitor_tag, timestamp, status, latency, type, error_message, raw_status })
.insert({ monitor_tag, timestamp, status, latency, type, error_message })
.onConflict(["monitor_tag", "timestamp"])
.merge({ status, latency, type, error_message, raw_status });
.merge({ status, latency, type, error_message });
// Query and return the inserted/updated record (works consistently across all databases)
const record = await this.knex("monitoring_data")
@@ -64,6 +51,31 @@ export class MonitoringRepository extends BaseRepository {
.orderBy("timestamp", "asc");
}
// Groups by timestamp and applies priority: DOWN > DEGRADED > UP
async getMonitoringDataAll(monitor_tags: string[], start: number, end: number): Promise<MonitoringData[]> {
return await this.knex("monitoring_data")
.select(
"timestamp",
this.knex.raw(`
CASE
WHEN MAX(CASE WHEN status = 'DOWN' THEN 1 ELSE 0 END) = 1 THEN 'DOWN'
WHEN MAX(CASE WHEN status = 'DEGRADED' THEN 1 ELSE 0 END) = 1 THEN 'DEGRADED'
ELSE 'UP'
END as status
`),
)
.whereIn("monitor_tag", monitor_tags)
.where("timestamp", ">=", start)
.where("timestamp", "<=", end)
.whereNotNull("status")
.groupBy("timestamp")
.orderBy("timestamp", "asc");
}
/**
* 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)
@@ -72,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)
@@ -329,107 +358,6 @@ export class MonitoringRepository extends BaseRepository {
return result.is_recovered === 1;
}
/**
* Recent samples the Confirmation Threshold resolver needs, newest first: scheduled-check
* observations (REALTIME/TIMEOUT/ERROR) plus incident/maintenance overlays. MANUAL pushes
* and DEFAULT fill are excluded — they stay transparent to the counter. Returns `type` so
* the resolver can stop at overlay rows (freeze). Observations whose status is NO_DATA are
* excluded entirely (neutral — they neither advance nor reset the count and must not consume lookback slots).
*/
async getRecentSamplesForConfirmation(
monitor_tag: string,
beforeTs: number,
limit: number,
): Promise<Array<{ timestamp: number; status: string | null; raw_status: string | null; type: string | null }>> {
return await this.knex("monitoring_data")
.select("timestamp", "status", "raw_status", "type")
.where("monitor_tag", monitor_tag)
.where("timestamp", "<", beforeTs)
.whereIn("type", [...OBSERVED_CHECK_TYPES, ...OVERLAY_TYPES])
.whereNot("status", GC.NO_DATA)
.orderBy("timestamp", "desc")
.limit(limit);
}
/**
* The committed status of the most recent real scheduled-check observation before `beforeTs`
* — the Confirmation Threshold "anchor" (the side currently shown). Looks past overlays,
* MANUAL/DEFAULT, and NO_DATA so a long incident/maintenance window can never hide the anchor
* (issue #712). Returns null when there is no prior observation (cold start).
*/
async getLastObservedStatus(monitor_tag: string, beforeTs: number): Promise<string | null> {
const row = await this.knex("monitoring_data")
.select("status")
.where("monitor_tag", monitor_tag)
.where("timestamp", "<", beforeTs)
.whereIn("type", OBSERVED_CHECK_TYPES)
.whereNot("status", GC.NO_DATA)
.orderBy("timestamp", "desc")
.limit(1)
.first();
return row ? (row.status ?? null) : null;
}
/**
* Backfill a confirmed status flip: set each row's committed status to its observed raw_status.
* `confirmThreshold` is the number of consecutive checks that confirmed the flip — when it is a
* number the run resolved to an unhealthy side and a per-row note ("Down"/"Degraded confirmed
* after N consecutive checks", matching each row's own severity) is appended to the existing
* error text; when it is null the run resolved to UP (recovery) and the error text is cleared.
*/
async backfillConfirmedStatus(
monitor_tag: string,
timestamps: number[],
confirmThreshold: number | null,
): Promise<number> {
if (timestamps.length === 0) return 0;
// Recovery (confirmed UP): rows become the UP side — clear any held error text in one update.
if (confirmThreshold === null) {
return await this.knex("monitoring_data")
.where("monitor_tag", monitor_tag)
.whereIn("timestamp", timestamps)
.whereNotNull("raw_status")
.update({
status: this.knex.ref("raw_status"),
error_message: null,
});
}
// Confirmed unhealthy: set each row's status from its observed raw_status and APPEND a
// severity-matched confirmation note to the existing error text (preserving the observed
// failure reason). Done per-row for portable string concatenation (|| vs CONCAT differ across
// SQLite/PG/MySQL), per-row severity wording, and idempotency if the backfill is replayed.
// The whole read+update window runs in one transaction — a confirmation flip is one logical
// write, so it must not leave the window half-confirmed/half-held if a row update fails.
return await this.knex.transaction(async (trx: KnexType.Transaction) => {
const rows = await trx("monitoring_data")
.select("timestamp", "error_message", "raw_status")
.where("monitor_tag", monitor_tag)
.whereIn("timestamp", timestamps)
.whereNotNull("raw_status");
let updated = 0;
for (const row of rows) {
const severity = row.raw_status === GC.DEGRADED ? "Degraded" : "Down";
const note = `${severity} confirmed after ${confirmThreshold} consecutive checks`;
const existing: string | null = row.error_message;
let nextMessage: string;
if (!existing) {
nextMessage = note;
} else if (existing.indexOf(note) !== -1) {
nextMessage = existing; // already appended — keep idempotent
} else {
nextMessage = `${existing} | ${note}`;
}
updated += await trx("monitoring_data")
.where({ monitor_tag, timestamp: row.timestamp })
.update({ status: row.raw_status, error_message: nextMessage });
}
return updated;
});
}
async updateMonitoringData(
monitor_tag: string,
start: number,
@@ -691,9 +619,4 @@ export class MonitoringRepository extends BaseRepository {
minLatency: Number(result?.min_latency) || 0,
};
}
//get the last known status for a monitor
async getLastKnownStatus(monitor_tag: string): Promise<MonitoringData | undefined> {
return await this.knex("monitoring_data").where("monitor_tag", monitor_tag).orderBy("timestamp", "desc").first();
}
}
@@ -2,17 +2,6 @@ import type { Knex as KnexType } from "knex";
import { BaseRepository, type MonitorFilter, type CountResult } from "./base.js";
import type { MonitorRecord, MonitorRecordInsert } from "../../types/db.js";
/**
* Clamp the Confirmation Threshold to its 160 invariant at the data layer, so the bound holds
* for every app write path (v4 API, manage API, clone, group), not only the v4 API validator.
* A non-finite/missing value defaults to 1 (off).
*/
function clampConfirmationThreshold(value: number | null | undefined): number {
const n = Math.round(Number(value));
if (!Number.isFinite(n)) return 1;
return Math.min(60, Math.max(1, n));
}
/**
* Repository for monitors CRUD operations
*/
@@ -39,7 +28,6 @@ export class MonitorsRepository extends BaseRepository {
type_data: data.type_data,
day_degraded_minimum_count: data.day_degraded_minimum_count,
day_down_minimum_count: data.day_down_minimum_count,
confirmation_threshold: clampConfirmationThreshold(data.confirmation_threshold),
include_degraded_in_downtime: data.include_degraded_in_downtime,
is_hidden: data.is_hidden || "NO",
monitor_settings_json: data.monitor_settings_json,
@@ -63,7 +51,6 @@ export class MonitorsRepository extends BaseRepository {
type_data: data.type_data,
day_degraded_minimum_count: data.day_degraded_minimum_count,
day_down_minimum_count: data.day_down_minimum_count,
confirmation_threshold: clampConfirmationThreshold(data.confirmation_threshold),
include_degraded_in_downtime: data.include_degraded_in_downtime,
is_hidden: data.is_hidden,
monitor_settings_json: data.monitor_settings_json,
-2
View File
@@ -135,14 +135,12 @@ const seedSiteData = {
subMenuOptions: {
showShareBadgeMonitor: true,
showShareEmbedMonitor: true,
showRssFeed: true,
},
dataRetentionPolicy: {
enabled: true,
retentionDays: 90,
},
eventDisplaySettings: {
showInlineEvents: false,
incidents: {
enabled: true,
ongoing: { show: true },
+14 -146
View File
@@ -1,8 +1,7 @@
import dns2 from "dns2";
import dgram, { type Socket } from "dgram";
import tls from "tls";
import { Resolver as NodeResolver } from "node:dns/promises";
import { AllRecordTypes, IsValidHost } from "../clientTools";
import { AllRecordTypes } from "../clientTools";
interface DNSAnswer {
name: string;
@@ -26,32 +25,13 @@ interface DNSRecordResult {
data: unknown;
}
export interface DnsQueryOptions {
transport?: "UDP" | "TLS";
nameserverOverride?: string;
tlsPort?: number;
tlsServername?: string;
allowSelfSignedCert?: boolean;
timeoutMs?: number;
}
const DEFAULT_DOT_PORT = 853;
const DEFAULT_QUERY_TIMEOUT_MS = 3000;
class DNSResolver {
nameserver: string;
socket: Socket | null;
socket: Socket;
constructor() {
this.nameserver = "8.8.8.8";
this.socket = null;
}
private getUdpSocket(): Socket {
if (!this.socket) {
this.socket = dgram.createSocket("udp4");
}
return this.socket;
this.socket = dgram.createSocket("udp4");
}
createQuery(domain: string, type: string): InstanceType<typeof dns2.Packet> {
@@ -69,127 +49,36 @@ class DNSResolver {
return packet;
}
private resolveTlsServername(nameserver: string, tlsServername?: string): string | undefined {
const configuredServername = tlsServername?.trim();
if (configuredServername) {
return configuredServername;
}
if (IsValidHost(nameserver)) {
return nameserver;
}
return undefined;
}
async query(domain: string, recordType: string, nameserverOverride?: string): Promise<DNSResponse> {
const socket = this.getUdpSocket();
return new Promise((resolve, reject) => {
const query = this.createQuery(domain, recordType);
const buffer = query.toBuffer();
const targetNameserver = nameserverOverride || this.nameserver;
const timeoutMs = DEFAULT_QUERY_TIMEOUT_MS;
const onMessage = (message: Buffer) => {
clearTimeout(timeoutId);
socket.removeListener("message", onMessage);
try {
// @ts-expect-error dns2 types are incomplete
const response = dns2.Packet.parse(message) as DNSResponse;
resolve(response);
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)));
}
// @ts-expect-error dns2 types are incomplete
const response = dns2.Packet.parse(message) as DNSResponse;
resolve(response);
};
const timeoutId = setTimeout(() => {
socket.removeListener("message", onMessage);
socket.close();
if (this.socket === socket) {
this.socket = null;
}
this.socket.removeListener("message", onMessage);
reject(new Error(`DNS query timed out for ${domain} (${recordType}) via ${targetNameserver}`));
}, timeoutMs);
}, 3000);
socket.once("message", onMessage);
this.socket.once("message", onMessage);
socket.send(buffer, 0, buffer.length, 53, targetNameserver, (err: Error | null) => {
this.socket.send(buffer, 0, buffer.length, 53, targetNameserver, (err: Error | null) => {
if (err) {
clearTimeout(timeoutId);
socket.removeListener("message", onMessage);
this.socket.removeListener("message", onMessage);
reject(err);
}
});
});
}
async queryOverTls(
domain: string,
recordType: string,
nameserver: string,
options: Pick<DnsQueryOptions, "tlsPort" | "tlsServername" | "allowSelfSignedCert" | "timeoutMs"> = {},
): Promise<DNSResponse> {
const query = this.createQuery(domain, recordType);
const buffer = query.toBuffer();
const lengthPrefix = Buffer.alloc(2);
lengthPrefix.writeUInt16BE(buffer.length, 0);
const message = Buffer.concat([lengthPrefix, buffer]);
const port = options.tlsPort ?? DEFAULT_DOT_PORT;
const timeoutMs = options.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS;
const servername = this.resolveTlsServername(nameserver, options.tlsServername);
return new Promise((resolve, reject) => {
let responseBuffer = Buffer.alloc(0);
let expectedLength: number | null = null;
const socket = tls.connect({
host: nameserver,
port,
servername,
rejectUnauthorized: !options.allowSelfSignedCert,
});
const timeoutId = setTimeout(() => {
socket.destroy();
reject(new Error(`DNS-over-TLS query timed out for ${domain} (${recordType}) via ${nameserver}:${port}`));
}, timeoutMs);
const cleanup = () => {
clearTimeout(timeoutId);
};
socket.on("error", (err) => {
cleanup();
socket.destroy();
reject(err);
});
socket.on("data", (chunk: Buffer) => {
responseBuffer = Buffer.concat([responseBuffer, chunk]);
if (expectedLength === null && responseBuffer.length >= 2) {
expectedLength = responseBuffer.readUInt16BE(0);
}
if (expectedLength !== null && responseBuffer.length >= expectedLength + 2) {
cleanup();
const responseData = responseBuffer.subarray(2, 2 + expectedLength);
try {
// @ts-expect-error dns2 types are incomplete
const response = dns2.Packet.parse(responseData) as DNSResponse;
socket.destroy();
resolve(response);
} catch (error) {
socket.destroy();
reject(error instanceof Error ? error : new Error(String(error)));
}
}
});
socket.on("secureConnect", () => {
socket.write(message);
});
});
}
async getAuthoritativeNameServers(domain: string, resolverNameserver?: string): Promise<string[]> {
const resolver = new NodeResolver();
resolver.setServers([resolverNameserver || this.nameserver]);
@@ -251,30 +140,12 @@ class DNSResolver {
async getRecord(
domain: string,
recordType: string,
options: DnsQueryOptions = {},
nameserverOverride?: string,
): Promise<Record<string, DNSRecordResult[]>> {
const results: Record<string, DNSRecordResult[]> = {};
const transport = options.transport ?? "UDP";
const nameserverOverride = options.nameserverOverride?.trim() || undefined;
try {
let response: DNSResponse;
if (transport === "TLS") {
if (!nameserverOverride) {
throw new Error("Name server is required for DNS-over-TLS queries");
}
response = await this.queryOverTls(domain, recordType, nameserverOverride, {
tlsPort: options.tlsPort,
tlsServername: options.tlsServername,
allowSelfSignedCert: options.allowSelfSignedCert,
timeoutMs: options.timeoutMs,
});
} else {
response = await this.queryAuthoritativeRecord(domain, recordType, nameserverOverride);
}
const response = await this.queryAuthoritativeRecord(domain, recordType, nameserverOverride);
results[recordType] = response.answers.map((answer: DNSAnswer) => ({
name: answer.name,
type: recordType,
@@ -286,10 +157,7 @@ class DNSResolver {
console.error("Error querying DNS records:", error);
throw error;
} finally {
if (this.socket) {
this.socket.close();
this.socket = null;
}
this.socket.close();
}
}
}
+39 -5
View File
@@ -34,7 +34,9 @@ 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 type { SiteDataForNotification } from "../notification/types.js";
import type { SiteDataForNotification, SubscriptionVariableMap } from "../notification/types.js";
import mdToHTML from "../../marked.js";
import subscriberQueue from "./subscriberQueue.js";
let alertingQueue: Queue | null = null;
let worker: Worker | null = null;
@@ -61,6 +63,7 @@ async function createNewIncident(
config: MonitorAlertConfigRecord,
monitorName: string,
monitorTag: string,
siteUrl: string = "",
): Promise<{ incident_id: number }> {
let startDateTime = getUnixTime(new Date(alert.created_at));
let incidentInput: IncidentInput = {
@@ -69,8 +72,6 @@ async function createNewIncident(
incident_source: "ALERT",
};
// Subscriber notification comes from AddIncidentComment (via
// CreateNewIncidentWithCommentAndMonitor) — do not push here too.
let update = IncidentCreateAlertMarkdown(alert, config, monitorName, monitorTag, GC.TRIGGERED);
let incidentCreated = await CreateNewIncidentWithCommentAndMonitor(
incidentInput,
@@ -79,7 +80,23 @@ async function createNewIncident(
config.alert_value,
);
const updateVariables: SubscriptionVariableMap = {
title: incidentInput.title,
cta_url: siteUrl + "incidents/" + incidentCreated.incident_id,
cta_text: "View Incident",
update_text: mdToHTML(update),
update_subject: `[#${incidentCreated.incident_id}:${GC.TRIGGERED}] ${incidentInput.title}`,
update_id: String(incidentCreated.incident_id),
event_type: "incidents",
};
subscriberQueue.push(updateVariables);
return incidentCreated;
/*
subscriberQueue.push(updateVariables);*/
}
async function closeIncident(
@@ -87,6 +104,7 @@ async function closeIncident(
config: MonitorAlertConfigRecord,
monitorName: string,
monitorTag: string,
siteUrl: string = "",
): Promise<void> {
//check if incident is already resolved
if (!alert.incident_id) {
@@ -104,7 +122,16 @@ async function closeIncident(
let incident_id = alert.incident_id;
const comment = ClosureCommentAlertMarkdown(alert, config, monitorName, monitorTag, GC.RESOLVED);
const updatedAt = getUnixTime(new Date(alert.updated_at));
// Subscriber notification comes from AddIncidentComment — do not push here too.
const updateMessage: SubscriptionVariableMap = {
title: incident.title,
cta_url: `${siteUrl}incidents/${incident_id}`,
cta_text: "View Incident",
update_text: mdToHTML(comment),
update_subject: `[#${incident.id}:${GC.RESOLVED}] ${incident.title}`,
update_id: String(incident_id),
event_type: "incidents",
};
subscriberQueue.push(updateMessage);
await AddIncidentComment(incident_id, comment, GC.RESOLVED, updatedAt);
}
@@ -226,6 +253,7 @@ const addWorker = () => {
monitor_alerts_configured,
monitor_name,
monitor_tag,
templateSiteVars.site_url,
);
//update alert with incident number
if (newIncidentNumber && newIncidentNumber.incident_id > 0) {
@@ -264,7 +292,13 @@ const addWorker = () => {
// If alert has an incident, add closure comment
if (activeAlert.incident_id) {
await closeIncident(activeAlert, monitor_alerts_configured, monitor_name, monitor_tag);
await closeIncident(
activeAlert,
monitor_alerts_configured,
monitor_name,
monitor_tag,
templateSiteVars.site_url,
);
}
// Send resolution notifications
+23 -58
View File
@@ -7,7 +7,6 @@ import { GetMinuteStartNowTimestampUTC } from "../tool.js";
import db from "../db/db.js";
import monitorResponseQueue from "./monitorResponseQueue";
import GC from "../../global-constants.js";
import { resolveConfirmedStatus } from "../services/confirmationThreshold.js";
let monitorExecuteQueue: Queue | null = null;
let worker: Worker | null = null;
@@ -26,13 +25,8 @@ const getQueue = () => {
return monitorExecuteQueue;
};
async function manualMaintenance(
monitor: MonitorRecordTyped,
ts?: number,
): Promise<{ [timestamp: number]: MonitoringResult }> {
// Key by the job's `ts` (already a minute-start) so the overlay aligns with the realtime/default
// rows and the freeze gate; fall back to "now" only when called without a ts.
let startTs = ts !== undefined ? ts : GetMinuteStartNowTimestampUTC();
async function manualMaintenance(monitor: MonitorRecordTyped): Promise<{ [timestamp: number]: MonitoringResult }> {
let startTs = GetMinuteStartNowTimestampUTC();
let maintenanceArr = await db.getMaintenancesByMonitorTagRealtime(monitor.tag, startTs);
let impact = "";
@@ -72,13 +66,8 @@ async function manualMaintenance(
return manualData;
}
async function manualIncident(
monitor: MonitorRecordTyped,
ts?: number,
): Promise<{ [timestamp: number]: MonitoringResult }> {
// Key by the job's `ts` (already a minute-start) so the overlay aligns with the realtime/default
// rows and the freeze gate; fall back to "now" only when called without a ts.
let startTs = ts !== undefined ? ts : GetMinuteStartNowTimestampUTC();
async function manualIncident(monitor: MonitorRecordTyped): Promise<{ [timestamp: number]: MonitoringResult }> {
let startTs = GetMinuteStartNowTimestampUTC();
let incidentArr = await db.getIncidentsByMonitorTagRealtime(monitor.tag, startTs);
let impact = "";
@@ -126,43 +115,13 @@ const addWorker = () => {
const exeResult = await serviceClient.execute(ts);
// Fetch overlays AFTER the check runs so a maintenance/incident that starts mid-check is still
// detected, and key them by the job's `ts` so the freeze gate (incidentData[ts]) is
// timestamp-safe even if the job is delayed or retried (#756).
let incidentData: MonitoringResultTS = await manualIncident(monitor, ts);
let maintenanceData: MonitoringResultTS = await manualMaintenance(monitor, ts);
let realtimeData: MonitoringResultTS = {};
if (exeResult) {
realtimeData[ts] = exeResult;
// Always record what the check actually observed (forensics + grace counting).
realtimeData[ts].raw_status = exeResult.status;
// Confirmation Threshold damping (#712): scheduled checks only.
const threshold = Number(monitor.confirmation_threshold ?? 1);
const isScheduledCheck = ([GC.REALTIME, GC.TIMEOUT, GC.ERROR] as string[]).includes(exeResult.type);
// Confirmation Threshold freezes while an incident/maintenance overlay is active for this
// minute: the overlay wins display and the count must neither advance nor backfill (#756).
const overlayActive = incidentData[ts] !== undefined || maintenanceData[ts] !== undefined;
if (threshold > 1 && isScheduledCheck && !overlayActive) {
const resolved = await resolveConfirmedStatus({
monitor_tag: monitor.tag,
ts,
rawStatus: exeResult.status,
threshold,
});
realtimeData[ts].status = resolved.status;
if (resolved.pendingHold) {
// Hold the confirmed side for display, but PRESERVE the observed latency and error text —
// no diagnostic info is discarded. Tag the row to record that the status is being held
// during the grace period; on confirmation the backfill appends the confirmation note (#756).
const observedError = realtimeData[ts].error_message;
realtimeData[ts].error_message = observedError
? `${observedError} | Status held during grace period`
: "Status held during grace period";
}
}
}
let incidentData: MonitoringResultTS = await manualIncident(monitor);
let maintenanceData: MonitoringResultTS = await manualMaintenance(monitor);
let defaultData: MonitoringResultTS = {};
let mergedData: MonitoringResultTS = {};
@@ -176,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";
}
}
}
}
@@ -187,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;
}
@@ -227,15 +201,6 @@ const addWorker = () => {
}
}
// Preserve raw_status from realtime monitoring (overlays replace the merged object wholesale,
// so re-attach the observed value the resolver recorded).
for (const timestamp in mergedData) {
const ts = parseInt(timestamp);
if (realtimeData[ts]?.raw_status !== undefined) {
mergedData[ts].raw_status = realtimeData[ts].raw_status;
}
}
for (const timestamp in mergedData) {
monitorResponseQueue.push(monitor.tag, parseInt(timestamp), mergedData[timestamp]);
}
@@ -17,7 +17,6 @@ interface JobData {
monitorTag: string;
ts: number;
error_message?: string | null;
raw_status?: string | null;
}
const getQueue = () => {
@@ -31,7 +30,7 @@ const addWorker = () => {
if (worker) return worker;
worker = q.createWorker(getQueue(), async (job: Job): Promise<MonitoringData | null> => {
const { monitorTag, ts, status, latency, type, error_message, raw_status } = job.data as JobData;
const { monitorTag, ts, status, latency, type, error_message } = job.data as JobData;
const dbRes = await InsertMonitoringData({
monitor_tag: monitorTag,
@@ -40,7 +39,6 @@ const addWorker = () => {
latency: latency,
type: type,
error_message: error_message,
raw_status: raw_status,
});
if (!dbRes) {
+1 -10
View File
@@ -1,5 +1,4 @@
import { redisIOConnection } from "../redisConnector.js";
import db from "../db/db.js";
import {
Queue,
Worker,
@@ -41,15 +40,7 @@ export const createWorker = <T = unknown, R = unknown>(
concurrency: 5,
...options,
};
// Route every job's database access to the worker pool. This is the single
// chokepoint all BullMQ workers and schedulers flow through, so wrapping here
// isolates background work from the web request pool (see db/poolContext.ts).
// Sandboxed (string/URL) processors run out-of-process and pass through.
const wrapped: Processor<T, R> =
typeof processor === "function"
? (job, token) => db.runInWorkerContext(() => Promise.resolve(processor(job, token)))
: processor;
return new Worker<T, R>(queue.name, wrapped, opts);
return new Worker<T, R>(queue.name, processor, opts);
};
export default {
+2 -30
View File
@@ -1,45 +1,17 @@
import IORedis from "ioredis";
import Redis from "ioredis";
import type { RedisOptions } from "ioredis";
import dotenv from "dotenv";
dotenv.config();
let redisIOClient: IORedis | null = null;
let redisClient: IORedis | null = null;
function shouldReconnectAfterRedisError(message: string): boolean {
const m = message.toUpperCase();
// Failover: writes hit a replica until the client points at the new primary.
if (m.includes("READONLY")) return true;
// RDB/AOF reload after pod restart — commands fail until loading finishes.
if (m.includes("LOADING")) return true;
// Replication: primary unavailable during StatefulSet rollout.
if (m.includes("MASTERDOWN")) return true;
return false;
}
const redisClientOptions: RedisOptions = {
maxRetriesPerRequest: null,
// Detect dead peers during long K8s / network stalls (default ioredis keepAlive is off).
keepAlive: 30000,
// Allow long RDB reloads after a StatefulSet restart before giving up on "ready".
maxLoadingRetryTime: 120_000,
reconnectOnError: (error: Error) => {
const message = error?.message ?? "";
if (shouldReconnectAfterRedisError(message)) {
// Reconnect and retry the failed command once the connection is healthy again.
return 2;
}
return false;
},
};
export function redisIOConnection(): IORedis {
if (!redisIOClient) {
if (!process.env.REDIS_URL) {
throw new Error("REDIS_URL is not defined in environment variables");
}
redisIOClient = new IORedis(process.env.REDIS_URL, redisClientOptions);
redisIOClient = new IORedis(process.env.REDIS_URL, { maxRetriesPerRequest: null });
}
return redisIOClient;
}
@@ -49,7 +21,7 @@ export function redisConnection(): Redis {
if (!process.env.REDIS_URL) {
throw new Error("REDIS_URL is not defined in environment variables");
}
redisClient = new Redis(process.env.REDIS_URL, redisClientOptions);
redisClient = new Redis(process.env.REDIS_URL, { maxRetriesPerRequest: null });
}
return redisClient;
}
-243
View File
@@ -1,243 +0,0 @@
/**
* RSS 2.0 feed for Kener status pages.
*
* Two layers live in this file:
* 1. buildRssFeed — pure XML formatter (no I/O).
* 2. renderRssFeedResponse — fetches recent incidents + maintenances for a
* page, shapes them into items, and returns an HTTP Response. Shared by
* both the default-page and named-page route handlers.
*/
import db from "$lib/server/db/db.js";
import { GetAllSiteData } from "$lib/server/controllers/siteDataController.js";
import type { IncidentForMonitorListWithComments, MaintenanceEventsMonitorList } from "$lib/server/types/db.js";
export type RssFeedItemType = "incident" | "maintenance";
export interface RssFeedItem {
type: RssFeedItemType;
id: number;
title: string;
link: string;
pubDate: number;
description: string;
}
export interface BuildRssFeedArgs {
siteName: string;
siteURL: string;
basePath: string;
feedPath: string;
items: RssFeedItem[];
}
const TYPE_TITLE_PREFIX: Record<RssFeedItemType, string> = {
incident: "[Incident]",
maintenance: "[Maintenance]",
};
export function buildRssFeed(args: BuildRssFeedArgs): string {
const channelLink = joinUrl(args.siteURL, args.basePath);
const selfLink = joinUrl(args.siteURL, args.basePath, args.feedPath);
const channelTitle = `${args.siteName} — Incidents & Maintenance`;
const channelDescription = `Latest incidents and scheduled maintenance for ${args.siteName}`;
const lastBuildSeconds = args.items.length > 0 ? Math.max(...args.items.map((i) => i.pubDate)) : nowSeconds();
const itemsXml = args.items.map(renderItem).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(channelTitle)}</title>
<link>${escapeXml(channelLink)}</link>
<description>${escapeXml(channelDescription)}</description>
<language>en</language>
<lastBuildDate>${formatRfc822(lastBuildSeconds)}</lastBuildDate>
<atom:link href="${escapeXml(selfLink)}" rel="self" type="application/rss+xml" />
${itemsXml}
</channel>
</rss>
`;
}
function renderItem(item: RssFeedItem): string {
const prefixedTitle = `${TYPE_TITLE_PREFIX[item.type]} ${item.title}`;
const guid = `${item.type}-${item.id}`;
return ` <item>
<title>${escapeXml(prefixedTitle)}</title>
<link>${escapeXml(item.link)}</link>
<guid isPermaLink="false">${escapeXml(guid)}</guid>
<pubDate>${formatRfc822(item.pubDate)}</pubDate>
<description>${cdata(item.description)}</description>
</item>`;
}
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function cdata(value: string): string {
// Split any literal ]]> sequence so the CDATA section terminates only at ours.
const safe = value.replace(/]]>/g, "]]]]><![CDATA[>");
return `<![CDATA[${safe}]]>`;
}
function formatRfc822(seconds: number): string {
// Date#toUTCString returns RFC-1123 form, which RSS 2.0 readers accept as RFC-822.
return new Date(seconds * 1000).toUTCString();
}
function nowSeconds(): number {
return Math.floor(Date.now() / 1000);
}
function joinUrl(...parts: string[]): string {
const [origin, ...rest] = parts;
const trimmedOrigin = origin.replace(/\/+$/, "");
const path = rest
.map((p) => p.trim())
.filter((p) => p.length > 0)
.map((p) => "/" + p.replace(/^\/+/, "").replace(/\/+$/, ""))
.join("");
return trimmedOrigin + path;
}
// Window for items pulled into the feed. Bounded by both: last 90 days AND
// the most recent 50 entries after merging. Matches typical reader expectations
// without scanning the entire incident history.
const FEED_WINDOW_DAYS = 90;
const FEED_MAX_ITEMS = 50;
// Scope determines which monitors the feed covers.
// - page: scoped to a status page's monitor list (honors hidden-monitor
// stripping and globalPageVisibilitySettings.forceExclusivity when pagePath is null)
// - monitor: scoped to a single monitor by tag (404 if hidden, inactive, or unknown)
export type RenderRssFeedScope = { type: "page"; pagePath: string | null } | { type: "monitor"; tag: string };
export interface RenderRssFeedArgs {
scope: RenderRssFeedScope;
// Path of THIS feed under basePath, e.g. "/rss.xml" or "/monitors/foo/rss.xml".
feedPath: string;
}
export async function renderRssFeedResponse(args: RenderRssFeedArgs): Promise<Response> {
const siteData = await GetAllSiteData();
const siteURL = siteData.siteURL;
if (!siteURL) {
return new Response("Not found", { status: 404 });
}
const siteName = siteData.siteName || "Status";
const basePath = process.env.KENER_BASE_PATH || "";
let monitorTags: string[] | undefined = undefined;
if (args.scope.type === "monitor") {
const monitor = await db.getMonitorByTag(args.scope.tag);
if (!monitor || monitor.is_hidden === "YES" || monitor.status !== "ACTIVE") {
return new Response("Not found", { status: 404 });
}
monitorTags = [args.scope.tag];
} else {
let pagePath = args.scope.pagePath;
if (!!siteData.globalPageVisibilitySettings?.forceExclusivity && pagePath === null) {
pagePath = "";
}
if (pagePath !== null) {
const page = await db.getPageByPath(pagePath);
if (!page) {
return new Response("Not found", { status: 404 });
}
const pageMonitors = await db.getPageMonitorsExcludeHidden(page.id);
monitorTags = pageMonitors.map((m) => m.monitor_tag);
}
}
const nowTs = nowSeconds();
const startTs = nowTs - FEED_WINDOW_DAYS * 24 * 60 * 60;
// Maintenances need a future end too: a SCHEDULED maintenance has its
// start_date_time in the future, and we want subscribers to learn about
// upcoming windows. Incidents are always past, so they keep nowTs as end.
const futureTs = nowTs + FEED_WINDOW_DAYS * 24 * 60 * 60;
const [incidents, maintenances] = await Promise.all([
db.getIncidentsForEventsByDateRange(startTs, nowTs, monitorTags),
db.getMaintenanceEventsForEventsByDateRange(startTs, futureTs, monitorTags),
]);
const items: RssFeedItem[] = [];
for (const incident of incidents) {
items.push({
type: "incident",
id: incident.id,
title: incident.title,
link: joinUrl(siteURL, basePath, `/incidents/${incident.id}`),
pubDate: incident.comments[0]?.commented_at ?? incident.start_date_time,
description: buildIncidentDescription(incident),
});
}
for (const maintenance of maintenances) {
// Drop events whose affected monitors were all hidden: the DB layer strips
// hidden monitors from the row; a now-empty monitors[] means the public
// shouldn't see this on the events page either. A global maintenance is the
// exception — it has no per-monitor rows by design (it affects every
// monitor), so its empty monitors[] is expected and must still be published.
if (maintenance.monitors.length === 0 && maintenance.is_global !== "YES") continue;
items.push({
type: "maintenance",
id: maintenance.id,
title: maintenance.title,
link: joinUrl(siteURL, basePath, `/maintenances/${maintenance.id}`),
pubDate: maintenance.start_date_time,
description: buildMaintenanceDescription(maintenance),
});
}
items.sort((a, b) => b.pubDate - a.pubDate);
const capped = items.slice(0, FEED_MAX_ITEMS);
const xml = buildRssFeed({ siteName, siteURL, basePath, feedPath: args.feedPath, items: capped });
return new Response(xml, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=300",
},
});
}
function buildIncidentDescription(incident: IncidentForMonitorListWithComments): string {
const lines: string[] = [];
lines.push(`Status: ${incident.state}`);
if (incident.monitors.length > 0) {
const names = incident.monitors.map((m) => m.monitor_name).join(", ");
lines.push(`Affected: ${names}`);
}
const latest = incident.comments[0];
if (latest) {
lines.push("");
lines.push(latest.comment);
}
return lines.join("\n");
}
function buildMaintenanceDescription(maintenance: MaintenanceEventsMonitorList): string {
const lines: string[] = [];
lines.push(`Status: ${maintenance.status}`);
const start = formatRfc822(maintenance.start_date_time);
const end = maintenance.end_date_time != null ? formatRfc822(maintenance.end_date_time) : "open-ended";
lines.push(`Scheduled: ${start}${end}`);
if (maintenance.is_global === "YES") {
lines.push("Affected: All monitors");
} else if (maintenance.monitors.length > 0) {
const names = maintenance.monitors.map((m) => m.monitor_name).join(", ");
lines.push(`Affected: ${names}`);
}
if (maintenance.description) {
lines.push("");
lines.push(maintenance.description);
}
return lines.join("\n");
}
+1 -1
View File
@@ -47,7 +47,7 @@ const getRetentionPolicy = async (): Promise<DataRetentionPolicy> => {
const runDailyCleanup = async (): Promise<DailyCleanupResult> => {
const policy = await getRetentionPolicy();
const retentionDays = Math.max(1, Math.floor(policy.retentionDays || defaultPolicy.retentionDays));
console.log(`Data retention policy: enabled=${policy.enabled}, retentionDays=${retentionDays}`);
if (!policy.enabled) {
return {
skipped: true,
+1 -6
View File
@@ -64,18 +64,13 @@ class ApiCall {
console.log(e);
}
}
const followRedirects =
this.monitor.type_data.follow_redirects ?? true;
const maxRedirects =
this.monitor.type_data.max_redirects ?? 5;
const options: AxiosRequestConfig = {
method: method,
headers: axiosHeaders,
timeout: timeout,
transformResponse: (r: string) => r,
maxRedirects: followRedirects ? maxRedirects : 0,
maxRedirects: 5,
validateStatus: () => true,
maxContentLength: Infinity,
maxBodyLength: Infinity,
@@ -1,107 +0,0 @@
import GC from "../../global-constants.js";
import db from "../db/db.js";
export type Side = "UP" | "DOWN" | null;
/** Binary side: UP is healthy; DOWN/DEGRADED are unhealthy; everything else (NO_DATA) is neutral. */
export function sideOf(status: string | null | undefined): Side {
if (status === GC.UP) return "UP";
if (status === GC.DOWN || status === GC.DEGRADED) return "DOWN";
return null;
}
export interface ResolveInput {
monitor_tag: string;
ts: number;
rawStatus: string;
threshold: number; // >= 2 to damp; 1 behaves as off (any opposite observation confirms instantly)
}
export interface ResolveResult {
/** Effective status to commit for this minute. */
status: string;
/**
* True while the displayed status is the held confirmed side (pending confirmation) rather
* than the observed side. The caller keeps the observed latency and error text (tagging the
* row) — it does not blank them.
*/
pendingHold: boolean;
}
/** Minimal data access this resolver needs; defaults to the db singleton, injectable for tests. */
export interface ConfirmationDeps {
getRecentSamplesForConfirmation: typeof db.getRecentSamplesForConfirmation;
getLastObservedStatus: typeof db.getLastObservedStatus;
backfillConfirmedStatus: typeof db.backfillConfirmedStatus;
}
// Overlay sample types that freeze the count (must match OVERLAY_TYPES in the monitoring repository).
const OVERLAY_TYPES: string[] = [GC.INCIDENT, GC.MAINTENANCE];
// Extra lookback rows beyond the threshold for the pending-run scan: headroom for interleaved
// overlay rows. NO_DATA observations are excluded by the query, and the anchor is fetched
// separately, so the buffer need not scale with NO_DATA density or overlay-window length.
const LOOKBACK_BUFFER = 10;
/**
* Resolve the status to commit for one scheduled-check observation under Confirmation
* Threshold damping (issue #712).
*
* IMPORTANT ordering contract: this MUST be called BEFORE the current row at `ts` is
* persisted, and only when no incident/maintenance overlay is active for `ts` (the caller
* gates that — overlays freeze the count). It anchors on the most recent stored observation
* (timestamp < ts).
*
* Neutral (`NO_DATA`) observations are excluded from the count (neither advance nor reset).
* Overlay rows act as a hard boundary: the pending run never crosses one, so monitoring
* resumes with a fresh count after an incident/maintenance window.
*/
export async function resolveConfirmedStatus(
input: ResolveInput,
deps: ConfirmationDeps = db,
): Promise<ResolveResult> {
const { monitor_tag, ts, rawStatus, threshold } = input;
const observedSide = sideOf(rawStatus);
// Neutral observation (NO_DATA): pass through untouched — written honestly as grey.
if (observedSide === null) {
return { status: rawStatus, pendingHold: false };
}
// Anchor = the side currently shown = the most recent real observation's committed status.
// Fetched with a dedicated query (not from the windowed scan) so a long incident/maintenance
// window can never push the anchor out of range and bypass damping.
const confirmedStatus = await deps.getLastObservedStatus(monitor_tag, ts);
const confirmedSide = sideOf(confirmedStatus);
// Cold start / no usable anchor / same side: commit immediately.
if (confirmedStatus === null || confirmedSide === null || observedSide === confirmedSide) {
return { status: rawStatus, pendingHold: false };
}
// Opposite side: count the trailing pending run (incl. current = 1), stopping at an overlay
// boundary (freeze) or at a confirmed-side observation. NO_DATA rows are excluded by the query.
const recent = await deps.getRecentSamplesForConfirmation(monitor_tag, ts, threshold + LOOKBACK_BUFFER);
let pendingRun = 1;
const pendingTimestamps: number[] = [];
for (const row of recent) {
if (row.type !== null && OVERLAY_TYPES.includes(row.type)) break; // freeze boundary
const rawSide = sideOf(row.raw_status);
if (rawSide === null) continue; // NO_DATA: neutral (excluded by the query; this is a defensive guard)
if (rawSide === observedSide && sideOf(row.status) === confirmedSide) {
pendingRun++;
pendingTimestamps.push(row.timestamp);
} else {
break; // hit a confirmed-side observation (the anchor)
}
}
if (pendingRun >= threshold) {
// Unhealthy confirm passes the count so the backfill can write a per-row severity note
// ("Down"/"Degraded confirmed after N…"); recovery passes null (clears the held error text).
await deps.backfillConfirmedStatus(monitor_tag, pendingTimestamps, observedSide === "DOWN" ? threshold : null);
return { status: rawStatus, pendingHold: false };
}
// Still pending: hold the confirmed side.
return { status: confirmedStatus, pendingHold: true };
}
+7 -15
View File
@@ -32,18 +32,10 @@ class DnsCall {
let matchType = this.monitor.type_data.matchType;
let values = this.monitor.type_data.values;
const configuredNameServer = this.monitor.type_data.nameServer?.trim() || undefined;
const transport = this.monitor.type_data.transport ?? "UDP";
const transportLabel = transport === "TLS" ? "DNS-over-TLS" : "DNS";
const queryStartTime = performance.now();
try {
let dnsRes = await dnsResolver.getRecord(host, recordType, {
transport,
nameserverOverride: configuredNameServer,
tlsPort: this.monitor.type_data.tlsPort,
tlsServername: this.monitor.type_data.tlsServername,
allowSelfSignedCert: this.monitor.type_data.allowSelfSignedCert,
});
let dnsRes = await dnsResolver.getRecord(host, recordType, configuredNameServer);
let latency = Math.round(performance.now() - queryStartTime);
if (dnsRes[recordType] === undefined) {
@@ -51,7 +43,7 @@ class DnsCall {
status: GC.DOWN,
latency: latency,
type: GC.REALTIME,
error_message: `No ${transportLabel} ${recordType} response found for ${host}`,
error_message: `No DNS ${recordType} response found for ${host}`,
};
}
let data = dnsRes[recordType];
@@ -63,7 +55,7 @@ class DnsCall {
status: GC.DOWN,
latency: latency,
type: GC.REALTIME,
error_message: `No ${transportLabel} ${recordType} records returned for ${host}`,
error_message: `No DNS ${recordType} records returned for ${host}`,
};
}
@@ -74,7 +66,7 @@ class DnsCall {
status: GC.DOWN,
latency: latency,
type: GC.REALTIME,
error_message: `${transportLabel} ${recordType} mismatch for ${host}. Missing: ${missingValues.join(", ")}`,
error_message: `DNS ${recordType} mismatch for ${host}. Missing: ${missingValues.join(", ")}`,
};
}
return {
@@ -96,7 +88,7 @@ class DnsCall {
status: GC.DOWN,
latency: latency,
type: GC.REALTIME,
error_message: `${transportLabel} ${recordType} mismatch for ${host}. Got: ${dnsData.join(", ")}`,
error_message: `DNS ${recordType} mismatch for ${host}. Got: ${dnsData.join(", ")}`,
};
}
} catch (error) {
@@ -106,14 +98,14 @@ class DnsCall {
status: GC.DOWN,
latency,
type: GC.REALTIME,
error_message: `${transportLabel} query failed for ${host} (${recordType}): ${message}`,
error_message: `DNS query failed for ${host} (${recordType}): ${message}`,
};
}
return {
status: GC.DOWN,
latency: Math.round(performance.now() - queryStartTime),
type: GC.REALTIME,
error_message: `${transportLabel} ${recordType} check did not return a definitive result for ${host}`,
error_message: `DNS ${recordType} check did not return a definitive result for ${host}`,
};
}
}
+2 -11
View File
@@ -61,7 +61,7 @@ class GrpcCall {
}
async execute(): Promise<MonitoringResult> {
const { host, port, service, tls, insecure, timeout } = this.monitor.type_data;
const { host, port, service, tls, timeout } = this.monitor.type_data;
const timeoutMs = timeout || 10000;
const target = `${host}:${port}`;
@@ -72,16 +72,7 @@ class GrpcCall {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const healthService = (proto.grpc as any).health.v1.Health;
const credentials =
tls && insecure
? grpc.credentials.createSsl(null, null, null, {
rejectUnauthorized: false,
checkServerIdentity: () => undefined,
})
: tls
? grpc.credentials.createSsl()
: grpc.credentials.createInsecure();
const credentials = tls ? grpc.credentials.createSsl() : grpc.credentials.createInsecure();
const client = new healthService(target, credentials);
const deadline = new Date(Date.now() + timeoutMs);
+2 -20
View File
@@ -1,6 +1,5 @@
import { GetLastKnownStatus } from "../controllers/monitorsController.js";
import DNSResolver from "../dns.js";
import type { NoneMonitor, MonitoringResult } from "../types/monitor.js";
import GC from "../../global-constants.js";
class NoneCall {
monitor: NoneMonitor;
@@ -9,24 +8,7 @@ class NoneCall {
this.monitor = monitor;
}
async execute(): Promise<MonitoringResult | null> {
let overrideWithLastKnownStatus = this.monitor.type_data.overrideWithLastKnownStatus;
if (!!overrideWithLastKnownStatus) {
//get the last known status
let lastKnownStatus = await GetLastKnownStatus(this.monitor.tag);
if (
!!lastKnownStatus &&
!!lastKnownStatus.status &&
!!lastKnownStatus.type &&
lastKnownStatus.type === GC.MANUAL
) {
return {
status: lastKnownStatus.status,
latency: lastKnownStatus.latency || 0,
type: lastKnownStatus.type,
};
}
}
async execute(): Promise<null> {
return null;
}
}
-6
View File
@@ -10,7 +10,6 @@ export interface MonitoringData {
latency: number | null;
type: string | null;
error_message?: string | null;
raw_status?: string | null;
}
export interface MonitoringDataInsert {
@@ -20,7 +19,6 @@ export interface MonitoringDataInsert {
latency: number;
type: string;
error_message?: string | null;
raw_status?: string | null;
}
export interface AggregatedMonitoringData {
@@ -80,7 +78,6 @@ export interface MonitorRecord {
external_url?: string | null;
day_degraded_minimum_count?: number | null;
day_down_minimum_count?: number | null;
confirmation_threshold?: number | null;
include_degraded_in_downtime?: string;
is_hidden: string;
monitor_settings_json: string | null;
@@ -138,7 +135,6 @@ export interface MonitorRecordTyped {
type_data: Record<string, unknown> | null;
day_degraded_minimum_count?: number | null;
day_down_minimum_count?: number | null;
confirmation_threshold?: number | null;
include_degraded_in_downtime?: string;
is_hidden: string;
monitor_settings_json: MonitorSettings | null;
@@ -162,7 +158,6 @@ export interface MonitorRecordInsert {
type_data?: string | null;
day_degraded_minimum_count?: number | null;
day_down_minimum_count?: number | null;
confirmation_threshold?: number | null;
include_degraded_in_downtime?: string;
is_hidden?: string;
monitor_settings_json?: string | null;
@@ -647,7 +642,6 @@ export interface MaintenanceEventsMonitorList {
description: string | null;
start_date_time: number; // Unix timestamp - when the first occurrence starts
end_date_time: number; // Unix timestamp - when the first occurrence ends
is_global: YesNoType; // "YES" when the maintenance affects all monitors (no per-monitor rows)
monitors: MaintenanceMonitorImpact[];
created_at: Date;
updated_at: Date;
+1 -11
View File
@@ -8,16 +8,13 @@ export interface MonitoringResult {
latency: number;
type: string;
error_message?: string;
raw_status?: string;
}
export interface MonitoringResultTS {
[timestamp: number]: MonitoringResult;
}
export interface NoneMonitorTypeData {
overrideWithLastKnownStatus: boolean;
}
export interface NoneMonitorTypeData {}
export interface ApiMonitorTypeData {
url: string;
body?: string;
@@ -26,8 +23,6 @@ export interface ApiMonitorTypeData {
timeout?: number;
eval?: string;
allowSelfSignedCert?: boolean;
follow_redirects?: boolean;
max_redirects?: number;
}
export interface DnsMonitorTypeData {
@@ -36,10 +31,6 @@ export interface DnsMonitorTypeData {
lookupRecord: string;
matchType: "ALL" | "ANY";
values: string[];
transport?: "UDP" | "TLS";
tlsPort?: number;
tlsServername?: string;
allowSelfSignedCert?: boolean;
}
export type { PingHost, PingMonitorTypeData };
@@ -92,7 +83,6 @@ export interface GrpcMonitorTypeData {
port: number;
service?: string;
tls?: boolean;
insecure?: boolean;
timeout?: number;
}
+2 -18
View File
@@ -92,7 +92,6 @@ export interface MonitorResponse {
type_data: MonitorTypeData | null;
include_degraded_in_downtime: string;
is_hidden: string;
confirmation_threshold?: number | null;
monitor_settings_json: MonitorSettings | null;
created_at: string;
updated_at: string;
@@ -119,9 +118,7 @@ export interface CreateMonitorRequest {
type_data?: MonitorTypeData | null;
include_degraded_in_downtime?: string;
is_hidden?: string;
confirmation_threshold?: number | null;
monitor_settings_json?: MonitorSettings | null;
external_url?: string | null;
}
export interface CreateMonitorResponse {
@@ -140,19 +137,13 @@ export interface UpdateMonitorRequest {
type_data?: MonitorTypeData | null;
include_degraded_in_downtime?: string;
is_hidden?: string;
confirmation_threshold?: number | null;
monitor_settings_json?: MonitorSettings | null;
external_url?: string | null;
}
export interface UpdateMonitorResponse {
monitor: MonitorRecordTyped;
}
export interface DeleteMonitorResponse {
message: string;
}
// Monitoring Data API types
export interface MonitoringDataPoint {
monitor_tag: string;
@@ -378,15 +369,8 @@ export interface GetMaintenanceEventResponse {
}
export interface UpdateMaintenanceEventRequest {
/** Window edit mode: both times required. Cannot be combined with `status`. */
start_date_time?: number;
end_date_time?: number;
/**
* Transition mode: COMPLETED (from ONGOING) or CANCELLED (from SCHEDULED/READY/ONGOING).
* Cannot be combined with time fields. Transitioning an ONGOING event moves its
* end_date_time to the moment of the transition.
*/
status?: "COMPLETED" | "CANCELLED";
start_date_time: number;
end_date_time: number;
}
export interface UpdateMaintenanceEventResponse {
-9
View File
@@ -1,9 +0,0 @@
export interface NotificationEvent {
eventURL: string;
eventTitle: string;
eventDate: string;
eventType: string;
eventStartDateTime: number;
eventEndDateTime: number | null;
eventStatus: string;
}
-2
View File
@@ -89,7 +89,6 @@ export interface SiteSubscriptionsSettings {
export interface SiteSubMenuOptions {
showShareBadgeMonitor: boolean;
showShareEmbedMonitor: boolean;
showRssFeed: boolean;
}
export interface DataRetentionPolicy {
@@ -98,7 +97,6 @@ export interface DataRetentionPolicy {
}
export interface EventDisplaySettings {
showInlineEvents: boolean;
incidents: {
enabled: boolean;
ongoing: {
+2 -5
View File
@@ -10,15 +10,12 @@
import KenerNav from "$lib/components/KenerNav.svelte";
</script>
<ModeWatcher defaultMode={data.defaultSiteTheme as 'light' | 'dark' | 'system'} />
<ModeWatcher />
<Toaster />
<svelte:head>
<meta name="robots" content="noindex, nofollow" />
<link
rel="icon"
href={data.favicon ? clientResolver(resolve, data.favicon) : data.favicon}
/>
<link rel="icon" href={data.favicon} />
{#if data.font?.cssSrc}
<link rel="stylesheet" href={data.font.cssSrc} />
{/if}
@@ -11,8 +11,6 @@ import type {
} from "$lib/types/api";
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
import { UpdateMaintenanceEventStatus } from "$lib/server/controllers/maintenanceController";
import GC from "$lib/global-constants";
import serverResolver from "$lib/server/resolver";
function formatDateToISO(date: Date | string): string {
@@ -118,46 +116,7 @@ export const PATCH: RequestHandler = async ({ locals, params, request }) => {
return json(errorResponse, { status: 400 });
}
// Transition mode: `status` alone, mutually exclusive with window edits
if (body.status !== undefined) {
if (body.start_date_time !== undefined || body.end_date_time !== undefined) {
const errorResponse: BadRequestResponse = {
error: {
code: "BAD_REQUEST",
message: "Cannot update status and start/end times in the same request",
},
};
return json(errorResponse, { status: 400 });
}
if (body.status !== GC.COMPLETED && body.status !== GC.CANCELLED) {
const errorResponse: BadRequestResponse = {
error: {
code: "BAD_REQUEST",
message: `status must be ${GC.COMPLETED} or ${GC.CANCELLED}`,
},
};
return json(errorResponse, { status: 400 });
}
try {
const updatedEvent = await UpdateMaintenanceEventStatus(eventId, body.status);
const response: UpdateMaintenanceEventResponse = {
event: await buildEventResponse(updatedEvent),
};
return json(response);
} catch (err) {
const errorResponse: BadRequestResponse = {
error: {
code: "BAD_REQUEST",
message: err instanceof Error ? err.message : "Failed to update event status",
},
};
return json(errorResponse, { status: 400 });
}
}
// Window edit mode: both times required
// Validate required fields - both are required for event update
if (body.start_date_time === undefined || body.start_date_time === null) {
const errorResponse: BadRequestResponse = {
error: {
+13 -15
View File
@@ -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,17 +101,17 @@ export const POST: RequestHandler = async ({ request }) => {
return json(errorResponse, { status: 400 });
}
// Validate confirmation_threshold
let confirmationThreshold = 1;
if (body.confirmation_threshold !== undefined && body.confirmation_threshold !== null) {
const ct = Number(body.confirmation_threshold);
if (!Number.isInteger(ct) || ct < 1 || ct > 60) {
const errorResponse: BadRequestResponse = {
error: { code: "BAD_REQUEST", message: "confirmation_threshold must be an integer between 1 and 60" },
};
return json(errorResponse, { status: 400 });
}
confirmationThreshold = ct;
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
@@ -121,16 +121,14 @@ 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",
type_data: body.type_data ? JSON.stringify(body.type_data) : null,
include_degraded_in_downtime: body.include_degraded_in_downtime ?? "NO",
is_hidden: body.is_hidden ?? "NO",
confirmation_threshold: confirmationThreshold,
monitor_settings_json: body.monitor_settings_json ? JSON.stringify(body.monitor_settings_json) : null,
external_url: body.external_url ?? null,
};
await db.insertMonitor(monitorData);
@@ -1,12 +1,11 @@
import { json, type RequestHandler } from "@sveltejs/kit";
import db from "$lib/server/db/db";
import { GetMonitorsParsed, DeleteMonitorCompletelyUsingTag } from "$lib/server/controllers/monitorsController";
import { GetMonitorsParsed, NormalizeDefaultStatus } from "$lib/server/controllers/monitorsController";
import type {
GetMonitorResponse,
MonitorResponse,
UpdateMonitorRequest,
UpdateMonitorResponse,
DeleteMonitorResponse,
BadRequestResponse,
} from "$lib/types/api";
@@ -78,25 +77,25 @@ 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;
updateData.is_hidden = body.is_hidden !== undefined ? body.is_hidden : existingMonitor.is_hidden;
updateData.external_url = body.external_url !== undefined ? body.external_url : existingMonitor.external_url;
if (body.confirmation_threshold === null) {
// Explicit null resets the grace period to the default (1 = off); undefined keeps the existing value.
updateData.confirmation_threshold = 1;
} else if (body.confirmation_threshold !== undefined) {
const ct = Number(body.confirmation_threshold);
if (!Number.isInteger(ct) || ct < 1 || ct > 60) {
const errorResponse: BadRequestResponse = {
error: { code: "BAD_REQUEST", message: "confirmation_threshold must be an integer between 1 and 60" },
};
return json(errorResponse, { status: 400 });
}
updateData.confirmation_threshold = ct;
} else {
updateData.confirmation_threshold = existingMonitor.confirmation_threshold ?? 1;
// 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
if (body.type_data !== undefined) {
if (body.type_data === null) {
@@ -161,20 +160,3 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
return json(response);
};
export const DELETE: RequestHandler = async ({ locals }) => {
// Monitor is validated by middleware and available in locals
const monitor = locals.monitor!;
// Removes the monitor and everything keyed to its tag: monitoring data,
// incident/maintenance/page links, alerts, alert configs, group
// memberships (with weight rebalancing), and caches. The scheduler drops
// the orphaned BullMQ job on its next reconcile.
await DeleteMonitorCompletelyUsingTag(monitor.tag);
const response: DeleteMonitorResponse = {
message: `Monitor '${monitor.tag}' deleted successfully`,
};
return json(response);
};
-20
View File
@@ -81,10 +81,6 @@
"title": "Overview",
"content": "v4/monitors/overview"
},
{
"title": "Grace Period",
"content": "v4/monitors/grace-period"
},
{
"title": "API Monitors",
"content": "v4/monitors/api"
@@ -213,10 +209,6 @@
"title": "User Subscriptions",
"content": "v4/subscriptions"
},
{
"title": "RSS Feed",
"content": "v4/rss-feed"
},
{
"title": "User Management",
"content": "v4/user-management"
@@ -311,18 +303,6 @@
"group": "v4.x",
"collapsible": false,
"pages": [
{
"title": "v4.1.2",
"content": "v4/changelogs/v4.1.2"
},
{
"title": "v4.1.1",
"content": "v4/changelogs/v4.1.1"
},
{
"title": "v4.1.0",
"content": "v4/changelogs/v4.1.0"
},
{
"title": "v4.0.23",
"content": "v4/changelogs/v4.0.23"
@@ -3,8 +3,6 @@
import DocsSidebar from "../DocsSidebar.svelte";
import DocsNavbar from "../DocsNavbar.svelte";
import type { Snippet } from "svelte";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
interface Props {
data: DocsLayoutData;
@@ -26,14 +24,7 @@
<svelte:head>
<!-- favicon -->
<link
rel="icon"
href={
data.config.favicon
? clientResolver(resolve, data.config.favicon)
: data.config.favicon
}
/>
<link rel="icon" href={data.config.favicon} />
</svelte:head>
<div class="bg-background text-foreground min-h-screen">
+3 -11
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import type { DocsConfig, DocsPage } from "$lib/types/docs";
import { base, resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
import { base } from "$app/paths";
import ArrowRight from "@lucide/svelte/icons/arrow-right";
import Github from "@lucide/svelte/icons/github";
import Moon from "@lucide/svelte/icons/moon";
@@ -220,14 +219,7 @@
name="keywords"
content="open source status page, docker status page, self-hosted status page, uptime monitor, incident management, status page tool, free status page, kener, status page docker compose, open source uptime monitoring"
/>
<link
rel="icon"
href={
data.config.favicon
? clientResolver(resolve, data.config.favicon)
: data.config.favicon
}
/>
<link rel="icon" href={data.config.favicon} />
<link rel="canonical" href="https://kener.ing/docs" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://kener.ing/docs" />
@@ -436,7 +428,7 @@
</span>
</a>
{:else}
<a href={getHref(button.href)} class="cta-pill-secondary" rel="external">
<a href={getHref(button.href)} class="cta-pill-secondary">
<span class="text-sm font-medium tracking-tight">{button.title}</span>
</a>
{/if}
@@ -0,0 +1,281 @@
---
title: Monitors API
description: Create, read, update, and delete monitors via the REST API
---
Create, read, update, and delete monitors via the API.
## List Monitors
Get all monitors with their current status.
```http
GET /api/monitors
```
### Parameters
| Parameter | Type | Description |
| --------- | ------- | ------------------------------------ |
| `page` | integer | Page number (default: 1) |
| `limit` | integer | Items per page (default: 20) |
| `status` | string | Filter by status: up, down, degraded |
| `type` | string | Filter by type: API, PING, TCP, etc. |
### Example Request
```bash
curl -X GET "https://your-kener.com/api/monitors?status=up&limit=10" \
-H "Authorization: Bearer YOUR_API_KEY"
```
### Example Response
```json
{
"success": true,
"data": {
"monitors": [
{
"id": "mon_abc123",
"name": "API Server",
"type": "API",
"url": "https://api.example.com/health",
"status": "up",
"uptime": 99.95,
"lastChecked": "2024-01-15T10:30:00Z"
}
],
"pagination": {
"page": 1,
"limit": 10,
"total": 25
}
}
}
```
## Get Monitor
Get details for a specific monitor.
```http
GET /api/monitors/:id
```
### Example Request
```bash
curl -X GET https://your-kener.com/api/monitors/mon_abc123 \
-H "Authorization: Bearer YOUR_API_KEY"
```
### Example Response
```json
{
"success": true,
"data": {
"id": "mon_abc123",
"name": "API Server",
"type": "API",
"url": "https://api.example.com/health",
"method": "GET",
"expectedStatusCode": 200,
"interval": 60,
"timeout": 10000,
"status": "up",
"uptime": {
"day": 100,
"week": 99.95,
"month": 99.87
},
"responseTime": {
"current": 145,
"average": 152
},
"lastChecked": "2024-01-15T10:30:00Z",
"createdAt": "2024-01-01T00:00:00Z"
}
}
```
## Create Monitor
Create a new monitor.
```http
POST /api/monitors
```
### Request Body
```json
{
"name": "Production API",
"type": "API",
"url": "https://api.example.com/health",
"method": "GET",
"expectedStatusCode": 200,
"interval": 60,
"timeout": 10000,
"headers": {
"Authorization": "Bearer token"
}
}
```
### Monitor Types
#### API Monitor
```json
{
"name": "API Health",
"type": "API",
"url": "https://api.example.com",
"method": "GET",
"expectedStatusCode": 200,
"headers": {},
"body": null
}
```
#### Ping Monitor
```json
{
"name": "Server Ping",
"type": "PING",
"host": "server.example.com"
}
```
#### TCP Monitor
```json
{
"name": "Database Port",
"type": "TCP",
"host": "db.example.com",
"port": 5432
}
```
#### DNS Monitor
```json
{
"name": "DNS Check",
"type": "DNS",
"host": "example.com",
"recordType": "A"
}
```
### Example Request
```bash
curl -X POST https://your-kener.com/api/monitors \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Production API",
"type": "API",
"url": "https://api.example.com/health",
"interval": 60
}'
```
### Example Response
```json
{
"success": true,
"data": {
"id": "mon_xyz789",
"name": "Production API",
"type": "API",
"status": "pending",
"createdAt": "2024-01-15T10:30:00Z"
},
"message": "Monitor created successfully"
}
```
## Update Monitor
Update an existing monitor.
```http
PUT /api/monitors/:id
```
### Example Request
```bash
curl -X PUT https://your-kener.com/api/monitors/mon_abc123 \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Updated API Name",
"interval": 120
}'
```
## Delete Monitor
Delete a monitor.
```http
DELETE /api/monitors/:id
```
### Example Request
```bash
curl -X DELETE https://your-kener.com/api/monitors/mon_abc123 \
-H "Authorization: Bearer YOUR_API_KEY"
```
## Monitor History
Get historical data for a monitor.
```http
GET /api/monitors/:id/history
```
### Parameters
| Parameter | Type | Description |
| ------------ | ------ | ---------------------------------- |
| `start` | string | Start date (ISO 8601) |
| `end` | string | End date (ISO 8601) |
| `resolution` | string | Data resolution: minute, hour, day |
### Example Request
```bash
curl -X GET "https://your-kener.com/api/monitors/mon_abc123/history?resolution=hour" \
-H "Authorization: Bearer YOUR_API_KEY"
```
## Pause/Resume Monitor
```http
POST /api/monitors/:id/pause
POST /api/monitors/:id/resume
```
### Example
```bash
# Pause
curl -X POST https://your-kener.com/api/monitors/mon_abc123/pause \
-H "Authorization: Bearer YOUR_API_KEY"
# Resume
curl -X POST https://your-kener.com/api/monitors/mon_abc123/resume \
-H "Authorization: Bearer YOUR_API_KEY"
```
@@ -1,96 +0,0 @@
---
title: v4.1.0 Changelog
description: See what's new in Kener v4.1.0, including new features, improvements, and bug fixes
---
## New features {#new-features}
### Confirmation Threshold (grace period) {#confirmation-threshold}
Monitors can now require **N consecutive checks** before a status change is committed, so transient blips no longer dent uptime % or the day bars.
- Set a per-monitor **Grace period** (160, default **1 = off**) in **Monitor → General Settings**, or via `confirmation_threshold` on the v4 monitor create/update API.
- Damping happens once in the scheduled-check write path. A new `monitoring_data.raw_status` column records what each check actually observed, while `status` holds the confirmed side.
- On the Nth consecutive opposite observation the status flips and the pending window is **retroactively backfilled** — a confirmed outage reads as down from its first failing check, and recovery is symmetric.
- Everything downstream — uptime %, day bars, badges, alert evaluation, group scoring — inherits the damped `status` automatically. Alerts compose by `max(grace period, failure threshold)`.
See [Grace Period](/docs/v4/monitors/grace-period) for full details.
### Manual maintenance event transitions {#maintenance-manual-transitions}
Individual maintenance events can now be **manually completed or cancelled** from the maintenance management UI, letting the recorded timeline reflect what actually happened.
- New **Complete** and **Cancel** actions move an event to a terminal `COMPLETED` or `CANCELLED` status and adjust `end_date_time` accordingly.
- Terminal statuses are final — a completed or cancelled event can't be edited further.
- The v4 API supports status transitions alongside window edits, with the two enforced as mutually exclusive.
- Subscribers are notified of the transition.
See [Maintenance Events](/docs/v4/maintenances/events).
### Last known status for "None" monitors {#none-last-known-status}
The **None** monitor type can now optionally reuse the **last known status** (status, latency, and source) instead of reporting no data when no value is pushed. Enable it with the new checkbox in the monitor configuration UI.
### Page settings {#page-settings}
Status pages gained per-page presentation controls, configurable in the manage UI and over the v4 pages API:
- **Per-page status history** — separate day counts for **mobile** (default 30) and **desktop** (default 90), each bounded to 1365.
- **Monitor layout styles** — choose between `default-list`, `default-grid`, `compact-list`, and `compact-grid`.
- **Meta / social overrides** — per-page title, description, and preview image.
Page settings accept deep-merge `PATCH` payloads over the API, and invalid payloads are now rejected with validation errors.
### Inline monitor toggles {#inline-monitor-toggles}
The **Manage → Monitors** list now lets you flip a monitor's **active/inactive** state and **visibility** inline, without opening the full edit page for a one-bit change.
### API: address the home page with `~home` {#api-home-token}
The home page is stored with an empty path and previously couldn't be addressed over the REST API. `GET`/`PATCH`/`DELETE /api/v4/pages/~home` now targets it, and list and single-page responses render its `page_path` as `~home` so what you read is exactly what you can address. `PATCH` accepts every field except the (fixed) home page path, and `DELETE` is rejected with a 400. Unmatched `/api/` routes now return JSON 404s instead of falling through to an HTML page.
### API: absolute `url` on incident and maintenance responses {#api-absolute-url}
v4 API responses now carry an absolute `url` field so consumers link to the right page instead of concatenating ids onto paths:
| Response | `url` |
| :---------------------------------- | :------------------------------------------- |
| Incident | `<site>/incidents/<id>` |
| Maintenance (list/get/create/update) | `<site>/maintenances/<id>?type=maintenance` |
| Maintenance event | `<site>/maintenances/<event_id>` |
The URL is built from the configured Site URL plus base path.
### API: delete a monitor by tag {#api-delete-monitor}
A new **`DELETE /api/v4/monitors/{monitor_tag}`** endpoint removes a monitor through the same path the manage UI uses. Monitor deletion now also explicitly cleans up alert-config rows (v2 alerts and the trigger/monitor junctions), fixing orphaned rows left behind on SQLite — which never enforced the `ON DELETE CASCADE` foreign keys. Shared alert configs survive until their last monitor is detached.
## Improvements {#improvements}
### Rich maintenance descriptions on list views {#maintenance-html-descriptions}
Maintenance descriptions now render as **sanitized HTML** on list views — the home page, custom pages, monitor pages, events pages, and embeds — matching the detail page. HTML formatting and line breaks display correctly instead of showing literal tags, with sanitization kept since these render to anonymous visitors.
### Searchable monitor picker for groups {#group-monitor-search}
The group monitor picker now has a **search bar**, making it practical to find and add monitors on instances with hundreds of them instead of scrolling a flat list.
### Database connection pool tuning and health checks {#db-pool-tuning}
The database layer now tunes connection-pool sizing, clamps the pool bounds to safe values, guards the Redis probe, and hardens the error page. The configured database type is logged at startup for clearer diagnostics.
### Broader alert evaluation {#alert-evaluation}
Consecutive status and latency checks now count **all alert-visible sample types**, improving alert reliability, and manually submitted data triggers a best-effort alert re-evaluation after updates.
### Localization {#localization}
- Added a **Ukrainian** translation.
- Fixed syntax errors in the **French** locale strings.
- Updated the default v4 **alert body** description, which still referenced pre-v4 template variables.
## Bug fixes {#bug-fixes}
- **Overall status now follows worst-wins ordering** (`DOWN > DEGRADED > MAINTENANCE > UP`) consistently across components, so an active maintenance no longer masks a real outage in the aggregated status.
- **Social preview images use absolute URLs**`og:image` and `twitter:image` meta tags now prepend the Site URL, fixing previews that failed because of relative paths.
- **Fixed overlapping header sections** on the public page by adding a single frosted backdrop behind the fixed nav and the sticky theme bar, so page content no longer shows through while scrolling.
@@ -1,51 +0,0 @@
---
title: v4.1.1 Changelog
description: See what's new in Kener v4.1.1, including new features, improvements, and bug fixes
---
## New features {#new-features}
### Display events inline {#display-events-inline}
A new **Display Events Inline** toggle in **Manage → Site Configurations** controls how active events surface on the public site.
- **On** — ongoing incidents, ongoing maintenances, and upcoming maintenances render as inline sections directly on the home, custom, and monitor pages, alongside a dedicated events list.
- **Off** (default) — events stay in the notifications popover in the top bar.
The two surfaces are mutually exclusive: when inline events are enabled the notifications popover is hidden so events aren't shown twice.
### Public RSS feeds {#rss-feeds}
Kener now publishes a public **RSS 2.0** feed of incidents and scheduled maintenance, so users can subscribe without email.
- Routes: `GET /rss.xml` (default page), `GET /{page_path}/rss.xml` (named page), and `GET /monitors/{monitor_tag}/rss.xml` (single monitor).
- The public layout emits a `<link rel="alternate" type="application/rss+xml">` in `<head>`, scoped to the current view, so browsers and readers auto-discover the right feed.
- An RSS icon button appears next to the Subscribe / share controls, gated on a new **`showRssFeed`** option (default on) under **Manage → Site Configurations → Monitor Sub Menu Options**.
- Coverage mirrors the events page — past (last 90 days) and upcoming maintenance windows — and site-wide maintenances show as `Affected: All monitors`. Responses are cached for 5 minutes.
## Improvements {#improvements}
### Path-based heartbeat URLs {#heartbeat-url-format}
Heartbeat monitors now use a path-separated endpoint, `/ext/heartbeat/{tag}/{secret}`, instead of the old `{tag}:{secret}` form. Legacy colon-style URLs are automatically rewritten, so existing heartbeats keep working without changes. See [Heartbeat Monitor](/docs/v4/monitors/heartbeat).
### "Started" column on incidents {#incident-started-column}
The **Manage → Incidents** table gained a **Started** column showing when each incident began, and date formatting across the incidents UI is now standardized for consistent display in cells and tooltips.
### Isolated web and worker database pools {#db-pool-isolation}
Background jobs can no longer starve page loads of database connections. The connection pool is split by execution context (routed via `AsyncLocalStorage`):
- **Web pool**`DATABASE_POOL_MAX` (default 10) — serves HTTP requests.
- **Worker pool**`DATABASE_WORKER_POOL_MAX` (default 5, new) — serves schedulers and BullMQ workers.
This fixes `KnexTimeoutError: Timeout acquiring a connection` errors seen when monitor bursts overlapped page loads. See [Environment Variables](/docs/v4/setup/environment-variables).
### Faster monitor-bars aggregation {#monitor-bars-index}
Added a covering index `(monitor_tag, timestamp, status, latency)` on `monitoring_data` so the daily status aggregation behind the status-page bars is served entirely from the index. This removes per-row table lookups and cuts multi-second queries on large instances (~1M+ rows) to near-instant.
## Bug fixes {#bug-fixes}
- **Redis writes recover after replica failover.** Clients now reconnect on `READONLY` errors, so after a primary failover writes resume without restarting Kener. Point `REDIS_URL` at the writer/primary endpoint when your provider exposes separate reader and writer URLs.
@@ -1,41 +0,0 @@
---
title: v4.1.2 Changelog
description: See what's new in Kener v4.1.2, including new features, improvements, and bug fixes
---
## New features {#new-features}
### DNS-over-TLS for DNS monitors {#dns-over-tls}
DNS monitors can now query resolvers over an encrypted connection. A new **`transport`** option (`UDP`, default, or `TLS`) enables DNS-over-TLS (RFC 7858) with supporting fields:
- **`tlsPort`** (default `853`) — the DoT port on the resolver.
- **`tlsServername`** — TLS SNI hostname, needed when pointing at an IP-based resolver like `1.1.1.1`.
- **`allowSelfSignedCert`** — skip TLS verification for private resolvers.
UDP lookups with a blank `nameServer` also got smarter: Kener now walks the zone's authoritative nameservers before falling back to `8.8.8.8`. See [DNS Monitor](/docs/v4/monitors/dns).
### Redirect control for API monitors {#api-follow-redirects}
API monitors gained a **Follow Redirects** toggle (default on) and a **Max Redirects** limit (default 5, up to 20). Turn the toggle off to evaluate the redirect response itself — its `3xx` status code and headers — instead of the destination, useful for monitoring that a redirect is in place or catching unexpected ones.
## Improvements {#improvements}
### Self-signed TLS for gRPC monitors {#grpc-insecure-tls}
gRPC monitors with TLS enabled can now set **`insecure`** to skip certificate verification — the equivalent of `grpcurl -insecure` — for servers with self-signed certificates. See [gRPC Monitor](/docs/v4/monitors/grpc).
### `external_url` in the monitors API {#external-url-api}
The monitors API now accepts `external_url` in `POST` and `PATCH /api/v4/monitors` requests and returns it in monitor responses, so API-managed monitors can set their public link without a dashboard visit.
### New and updated translations {#translations}
Two new locales: **Traditional Chinese (Hong Kong)** `zh-HK` and **Traditional Chinese (Macau)** `zh-MO`. Simplified Chinese, Traditional Chinese (Taiwan), and Vietnamese translations were also refreshed.
## Bug fixes {#bug-fixes}
- **Incident comments notify subscribers.** Incidents created or updated from the dashboard or API never reached email subscribers — only alert-driven incidents did. Posting a comment on an incident now notifies "incidents" subscribers regardless of where the incident came from, and alert-driven incidents notify exactly once through the same path. Notification subjects now carry the incident state (e.g. `INVESTIGATING`).
- **Site theme respected on first visit.** The default color scheme configured in site settings is now applied to first-time visitors instead of only after they toggle the theme.
- **Favicon works with `KENER_BASE_PATH`.** The favicon now resolves correctly when Kener is served from a sub-path, and pages no longer break when no favicon is configured.
- **Charts re-render on id changes.** Chart container ids are now reactive, fixing stale chart styling when a chart's id changes after mount.
@@ -196,7 +196,7 @@ status.yourdomain.com {
## Next Steps
- Set up [Monitors](/docs/v4/monitors)
- Configure [Incidents](/docs/v4/incidents)
- Set up [Monitors](/docs/monitors)
- Configure [Incidents](/docs/incidents)
- Configure [Sharing Monitors](/docs/v4/sharing)
- Explore the [API Reference](/docs/spec/v4/)
- Explore the [API Reference](/docs/api-reference)
@@ -148,62 +148,45 @@ This provides complete context about:
When the alert resolves, a detailed closure update is added:
```markdown
The alert has been resolved, Total duration: 47 minutes
#### Alert Details
| Setting | Value |
title: Auto-Generated Incidents
description: Quick reference for incident creation via alerting
| :-------------------- | :---------- |
| **Monitor Name** | Payment API |
| **Incident Status** | RESOLVED |
| **Monitor Tag** | payment-api |
| **Alert Type** | STATUS |
| **Alert Value** | DOWN |
| **Severity** | CRITICAL |
| **Failure Threshold** | 3 |
| **Success Threshold** | 5 |
```
| **Monitor Name** | Payment API |
Kener can auto-create incidents from alert configurations.
| **Monitor Tag** | payment-api |
## How it works {#how-it-works}
| **Alert Value** | DOWN |
1. Alert triggers.
2. Alert has **Create Incident = YES**.
3. Incident is created and monitor is attached.
4. When alert resolves, incident is resolved automatically.
| **Failure Threshold** | 3 |
## Where to configure {#where-to-configure}
This includes:
Use **Manage → Alerts → Alert Configurations**.
- Total incident duration
- Alert configuration
## Notes {#notes}
- Resolution confirmation
- Success threshold that was met
- Auto-generated incidents are ideal for critical, user-facing alerts.
- Tune thresholds to avoid noisy incident creation.
## Configuration Requirements {#configuration-requirements}
## See also {#see-also}
To enable auto-generated incidents:
### 1. Create Alert Configuration {#create-alert-config}
Navigate to **Manage > Alerts > Create Alert**
**Configure:**
- Monitor to watch
- Alert type (STATUS, LATENCY, UPTIME)
- Alert value (threshold)
- Failure threshold
- Success threshold
- Severity (CRITICAL or WARNING)
**Enable Incident Creation:**
- Set **Create Incident** to **YES**
See [Alert Configurations](/docs/v4/alerting/alert-configurations) for complete details.
### 2. Configure Triggers (Optional) {#configure-triggers}
While triggers are optional for incident creation, they enable notifications:
**Create Triggers:**
- Discord
- Slack
- Email
- [Alert Configurations](/docs/v4/alerting/alert-configurations)
- [Triggers](/docs/v4/alerting/triggers)
- [Creating and Managing Incidents](/docs/v4/incidents/creating-managing)
- Webhook
**Attach to Alert:**
@@ -211,7 +194,7 @@ While triggers are optional for incident creation, they enable notifications:
- Select triggers when creating/editing alert
- Triggers fire on both TRIGGERED and RESOLVED events
See [Triggers](/docs/v4/alerting/triggers) for setup.
See [Triggers](/docs/alerting/triggers) for setup.
## Manual vs Auto-Generated {#manual-vs-auto}
@@ -306,13 +289,11 @@ A monitor can have multiple alert configurations:
**Example:**
```
Monitor: api-gateway
Alert 1: STATUS - DOWN (failure: 1)
Alert 2: LATENCY - 1000ms (failure: 5)
Alert 3: UPTIME - 99.9% (failure: 10)
```
**Each Alert:**
@@ -349,7 +330,7 @@ When incident is auto-resolved:
- Uses configured templates
- Variable: `is_resolved = true`
See [Triggers](/docs/v4/alerting/triggers) and [Templates](/docs/v4/alerting/templates) for customization.
See [Triggers](/docs/alerting/triggers) and [Templates](/docs/alerting/templates) for customization.
### Subscriber Notifications {#subscriber-notifications}
@@ -446,7 +427,7 @@ Every alert trigger creates an alert event log:
3. Trigger credentials valid
4. Check trigger logs for errors
See [Troubleshooting Triggers](/docs/v4/alerting/triggers#troubleshooting-triggers) for detailed diagnosing.
See [Troubleshooting Triggers](/docs/alerting/triggers#troubleshooting-triggers) for detailed diagnosing.
## Best Practices {#best-practices}
@@ -484,13 +465,11 @@ See [Troubleshooting Triggers](/docs/v4/alerting/triggers#troubleshooting-trigge
**Workflow:**
```
1. Alert triggers → Auto-creates incident
2. You investigate → Add IDENTIFIED update
3. You deploy fix → Add MONITORING update
4. Alert resolves → Auto-adds RESOLVED update
5. You add post-mortem → Add final manual update
```
### Threshold Tuning {#threshold-tuning}
@@ -514,7 +493,7 @@ See [Troubleshooting Triggers](/docs/v4/alerting/triggers#troubleshooting-trigge
## Next Steps {#next-steps}
- [Alert Configurations](/docs/v4/alerting/alert-configurations) - Set up alerts that create incidents
- [Triggers](/docs/v4/alerting/triggers) - Configure notification channels
- [Incident Updates](/docs/v4/incidents/updates) - Add manual updates to auto-generated incidents
- [Incident Overview](/docs/v4/incidents/overview) - Understand incident basics
- [Alert Configurations](/docs/alerting/alert-configurations) - Set up alerts that create incidents
- [Triggers](/docs/alerting/triggers) - Configure notification channels
- [Incident Updates](/docs/incidents/updates) - Add manual updates to auto-generated incidents
- [Incident Overview](/docs/incidents/overview) - Understand incident basics
@@ -60,69 +60,32 @@ The incident state after this update. This is **crucial** because:
- State progression drives the incident lifecycle
**Available States:**
title: Incident Updates
description: Quick reference for posting timeline updates on incidents
- **INVESTIGATING** - Initial investigation
- **IDENTIFIED** - Root cause found
- **MONITORING** - Fix applied, watching for stability
- **RESOLVED** - Issue fully resolved
Incident updates are timeline entries used to communicate progress and move incident state.
**Important:** When you set state to **RESOLVED**, Kener automatically sets the incident's `end_date_time` to the update's timestamp.
## Quick reference {#quick-reference}
### Timestamp (Required) {#timestamp}
When posting an update, choose one state:
When this update was made.
- `INVESTIGATING`
- `IDENTIFIED`
- `MONITORING`
- `RESOLVED`
**Default:** Current date and time
Setting `RESOLVED` closes the incident and sets end time.
**Can Be Modified:**
- Useful for backdating updates
- Aligning timeline with actual events
- Recording updates made after the fact
Use concise, user-facing text and include only meaningful changes.
**Display:** Shown in your local timezone but stored as UTC
## See also {#see-also}
## Creating Updates {#creating-updates}
### During Incident Creation {#during-creation}
When creating a new incident, you can optionally provide an **Initial Update**:
1. Fill out the incident details
2. Add text in the "Initial Update" field
3. Uses Markdown formatting
4. Automatically created with state "INVESTIGATING"
5. Timestamp matches incident start time
### Adding Updates to Existing Incidents {#adding-updates}
1. Navigate to the incident detail page
2. Click **Add Update** button
3. Enter your message (Markdown supported)
4. Select the new state
5. Optionally adjust the timestamp
6. Click **Add Update**
**What Happens:**
- Update is added to the incident timeline
- Incident state changes to the update's state
- If state is RESOLVED, end_date_time is set
- Update appears immediately on status page
- Subscribers receive notifications (if configured)
## Editing Updates {#editing-updates}
You can edit any existing update:
1. Find the update in the incident timeline
2. Click the **Edit** (pencil icon) button
3. Modify the message
4. Change the state if needed
5. Adjust the timestamp if needed
6. Click **Save**
**Important State Changes:**
- [Creating and Managing Incidents](/docs/v4/incidents/creating-managing)
- [Impact on Monitoring](/docs/v4/incidents/impact-on-monitoring)
**Important State Changes:**
**Moving to RESOLVED:**
@@ -438,6 +401,6 @@ See [Subscription documentation](/docs/v4/subscriptions) for setup.
## Next Steps {#next-steps}
- [Incident Impact on Monitoring](/docs/v4/incidents/impact-on-monitoring) - How incident state affects monitor status
- [Creating and Managing Incidents](/docs/v4/incidents/creating-managing) - Back to incident management basics
- [Auto-Generated Incidents](/docs/v4/incidents/auto-generated) - How alerts create and update incidents automatically
- [Incident Impact on Monitoring](/docs/incidents/impact-on-monitoring) - How incident state affects monitor status
- [Creating and Managing Incidents](/docs/incidents/creating-managing) - Back to incident management basics
- [Auto-Generated Incidents](/docs/incidents/auto-generated) - How alerts create and update incidents automatically
@@ -491,7 +491,7 @@ Add buffer time to your estimates:
- Ensure BYDAY is set for weekly frequency
- Check interval is positive number
- Verify RRULE syntax (see [RRULE Patterns](/docs/v4/maintenances/rrule-patterns))
- Verify RRULE syntax (see [RRULE Patterns](/docs/maintenances/rrule-patterns))
**Problem:** Events not generated for recurring maintenance
@@ -511,6 +511,6 @@ Add buffer time to your estimates:
## Next Steps {#next-steps}
- [Maintenance Events](/docs/v4/maintenances/events) - Learn about event lifecycle and automatic transitions
- [Maintenance Impact on Monitoring](/docs/v4/maintenances/impact-on-monitoring) - How maintenances affect monitor status display
- [RRULE Patterns](/docs/v4/maintenances/rrule-patterns) - Advanced scheduling patterns and examples
- [Maintenance Events](/docs/maintenances/events) - Learn about event lifecycle and automatic transitions
- [Maintenance Impact on Monitoring](/docs/maintenances/impact-on-monitoring) - How maintenances affect monitor status display
- [RRULE Patterns](/docs/maintenances/rrule-patterns) - Advanced scheduling patterns and examples
@@ -179,66 +179,63 @@ Action: Override monitor statuses
```
READY → ONGOING
Condition: current_time >= start_time AND current_time < end_time
Executed by: Status update scheduler (runs every minute)
title: Maintenance Events
description: How maintenance events are generated and shown to users
```
#### COMPLETED {#completed-state}
A maintenance event is one occurrence of a maintenance window.
**When:** Current time is past end time, or an admin completes an ONGOING event early
## Event generation {#event-generation}
**Meaning:**
### One-time maintenance {#one-time-maintenance}
- Maintenance has finished
- Creates one event.
- Triggered when maintenance is created.
- Monitor statuses restored to realtime values
- Historical record
**Displayed As:** "Completed"
### Recurring maintenance {#recurring-maintenance}
**Notification:** "Maintenance Completed" notification sent
- Creates upcoming events from RRULE.
- Scheduler refreshes upcoming occurrences.
- Duplicate event start times are skipped.
**Example:**
## Event statuses {#event-statuses}
```
- `SCHEDULED`
- `READY`
- `ONGOING`
- `COMPLETED`
- `CANCELLED`
Status transitions are time-based and automatic.
Current Time: May 15, 4:05 PM
Event End: May 15, 4:00 PM
## User-visible behavior {#user-visible-behavior}
Status: COMPLETED (finished 5 minutes ago)
Action: Restore monitor statuses
```
- Ongoing events affect monitor display according to impact settings.
- Upcoming and past visibility depends on site/page event display settings.
```
## Manual actions {#manual-actions}
**Automatic Transition:**
- You can cancel/delete events from maintenance management screens.
- Edit the parent maintenance to regenerate future schedule behavior.
```
ONGOING → COMPLETED
## Related guides {#related-guides}
Condition: current_time >= end_time
Executed by: Status update scheduler (runs every minute)
```
**Manual Completion:**
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing)
- [Impact on Monitoring](/docs/v4/maintenances/impact-on-monitoring)
- [RRULE Patterns](/docs/v4/maintenances/rrule-patterns)
**Displayed As:** "Cancelled"
An ONGOING event can be completed early from the maintenance edit page or via the API (see [Completing an Event Early](#completing-events)). Its end time is moved to the moment it was completed, so the recorded window reflects what actually happened.
**Notification:** No automatic notification
#### CANCELLED {#cancelled-state}
**When:** Manually cancelled by an admin (allowed for SCHEDULED, READY, and ONGOING events)
**Meaning:**
- The occurrence was called off — before or during its window
- Monitor statuses are not (or no longer) overridden
- Kept in history, explicitly marked as cancelled vs completed
**Time Handling:**
- Cancelled before start: the planned window is kept unchanged
- Cancelled while ONGOING: the end time is moved to the moment of cancellation
**Displayed As:** "Cancelled"
**Notification:** "Maintenance Cancelled" notification sent (controlled by the same "ended" notification setting as completion)
> [!NOTE]
> COMPLETED and CANCELLED are terminal — an event cannot be moved out of them. For recurring maintenances, a cancelled occurrence is never regenerated, so cancelling is how you skip one occurrence while keeping the schedule.
**Manual Action:** User clicks delete/cancel on event
## Automatic Status Transitions {#automatic-transitions}
@@ -326,20 +323,9 @@ When: At start time
Subject: Maintenance Completed
Body: "{title} has been completed"
Monitors: List of affected monitors with impacts
When: At end time, or immediately when completed early
When: At end time
```
**Cancelled (manual cancellation):**
```
Subject: Maintenance Cancelled
Body: "{title} has been cancelled"
Monitors: List of affected monitors with impacts
When: Immediately when an admin cancels the event
```
Completed and Cancelled notifications share the same "ended" notification setting.
### Notification Channels {#notification-channels}
Notifications are sent based on your subscription configuration:
@@ -404,35 +390,22 @@ Users see events on the public status page:
## Managing Events Manually {#managing-events-manually}
### Completing an Event Early {#completing-events}
If the maintenance work finishes before the scheduled end:
1. Find the ONGOING event on the maintenance edit page
2. Click **Complete**
3. Confirm
The event moves to COMPLETED, its end time is set to the current time, monitor statuses return to realtime values, and the completion notification is sent.
Via the API: `PATCH /api/v4/maintenances/{maintenance_id}/events/{event_id}` with body `{"status": "COMPLETED"}`.
### Cancelling Events {#cancelling-events}
From the maintenance edit page:
1. Find the event in the list (SCHEDULED, READY, or ONGOING)
2. Click **Cancel**
3. Confirm
Via the API: `PATCH /api/v4/maintenances/{maintenance_id}/events/{event_id}` with body `{"status": "CANCELLED"}`.
1. Find the event in the list
2. Click the trash icon
3. Confirm cancellation
4. Event status changes to CANCELLED
**When to Cancel:**
- Maintenance no longer needed
- Maintenance was aborted partway through
- Skipping one occurrence of a recurring maintenance
- Rescheduling to different time
- Discovered conflict
**Note:** Cancelled events remain in history but are not executed. A cancelled occurrence of a recurring maintenance is never regenerated.
**Note:** Cancelled events remain in history but are not executed.
### Deleting Events {#deleting-events}
@@ -452,9 +425,9 @@ Events can be permanently deleted:
**Best Practice:** Cancel rather than delete to preserve history.
### Editing Event Times {#cannot-edit-events}
### Cannot Edit Events {#cannot-edit-events}
Individual event times cannot be edited from the dashboard. To change event timing or duration:
Individual events cannot be edited directly. To change event timing or duration:
1. Edit the parent maintenance
2. Update start time, RRULE, or duration
@@ -464,9 +437,7 @@ Individual event times cannot be edited from the dashboard. To change event timi
- Future SCHEDULED events deleted
- New events generated with updated settings
- ONGOING/COMPLETED/CANCELLED events preserved
API consumers can edit a single event's window directly: `PATCH /api/v4/maintenances/{maintenance_id}/events/{event_id}` with both `start_date_time` and `end_date_time`. A request cannot combine time fields with a `status` transition.
- ONGOING/COMPLETED events preserved
## Event Retention {#event-retention}
@@ -501,7 +472,7 @@ During ONGOING events:
- Realtime monitoring continues in background
- Status page shows maintenance impact
See [Maintenance Impact on Monitoring](/docs/v4/maintenances/impact-on-monitoring) for details.
See [Maintenance Impact on Monitoring](/docs/maintenances/impact-on-monitoring) for details.
## Troubleshooting {#troubleshooting}
@@ -540,6 +511,6 @@ See [Maintenance Impact on Monitoring](/docs/v4/maintenances/impact-on-monitorin
## Next Steps {#next-steps}
- [Maintenance Impact on Monitoring](/docs/v4/maintenances/impact-on-monitoring) - How events affect monitor status display
- [RRULE Patterns](/docs/v4/maintenances/rrule-patterns) - Advanced scheduling patterns for recurring maintenances
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing) - Learn how to create and edit maintenances
- [Maintenance Impact on Monitoring](/docs/maintenances/impact-on-monitoring) - How events affect monitor status display
- [RRULE Patterns](/docs/maintenances/rrule-patterns) - Advanced scheduling patterns for recurring maintenances
- [Creating and Managing Maintenances](/docs/maintenances/creating-managing) - Learn how to create and edit maintenances
@@ -66,79 +66,63 @@ Displayed Status: MAINTENANCE
**Example:**
```
Monitor: Database
Realtime Status: UP (checks passing now)
Incident: OPEN
Monitor Impact: DOWN
Displayed Status: DOWN
```
title: Impact on Monitoring
description: How maintenance events affect displayed monitor status
**Rationale:** Incident status takes precedence over current checks during active issues.
**Note:** Maintenance status takes precedence over incident status. If both exist, maintenance wins.
During an **ONGOING** maintenance event, maintenance impact can override monitor status shown to users.
#### 3. Realtime Monitoring Data {#realtime-priority}
## Status precedence {#status-precedence}
**When:** No maintenance or incident affecting this monitor
Kener resolves status in this order (later overrides earlier):
**Display:** Show latest monitoring check result (UP, DOWN, DEGRADED)
`default status → realtime monitor result → incident impact → maintenance impact`
**Example:**
So maintenance has the highest effective priority when active.
## Impact values {#impact-values}
```
Monitor: Web Server
Latest Check: DOWN (connection timeout)
No Maintenance: ✓
No Incident: ✓
Set per monitor in a maintenance:
Displayed Status: DOWN
```
- `MAINTENANCE` (recommended)
- `DOWN`
- `DEGRADED`
- `UP`
#### 4. Default Monitor Status (Lowest Priority) {#default-priority}
Choose the value that matches expected user impact during the window.
**When:** No monitoring data exists yet
## Event lifecycle behavior {#event-lifecycle-behavior}
**Display:** Monitor's configured default status
- `SCHEDULED` / `READY`: no override yet
- `ONGOING`: override active
- `COMPLETED` / `CANCELLED`: override removed
**Example:**
## Realtime monitoring still runs {#realtime-monitoring-still-runs}
```
Monitor: New Service
No Checks Run: (just created)
Even during maintenance, checks continue and data is recorded.
Default Status: UP
Maintenance changes **displayed/effective** status, not monitor execution.
Displayed Status: UP
```
## Monitor Impact Levels {#impact-levels}
## Practical guidance {#practical-guidance}
When configuring a maintenance, you specify the impact for each affected monitor:
- Prefer `MAINTENANCE` for planned work communication.
- Use `DOWN` only when service is expected to be unavailable.
- Avoid overlapping maintenances on the same monitor.
### MAINTENANCE {#maintenance-impact}
## Related guides {#related-guides}
**Visual:** Orange/yellow, wrench icon
**Meaning:** Service is under planned maintenance
**When to Use:**
- General maintenance work
- Service available but under maintenance
- Default/recommended choice
**User Interpretation:** "Service may be affected due to planned work"
**Example:**
```yaml
Monitor: API Server
Impact: MAINTENANCE
During Event: Shows orange "Under Maintenance"
```
### DOWN {#down-impact}
- [Maintenances Overview](/docs/v4/maintenances/overview)
- [Maintenance Events](/docs/v4/maintenances/events)
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing)
**Visual:** Red, X icon
@@ -530,6 +514,6 @@ Description: "Database will be completely offline during this window"
## Next Steps {#next-steps}
- [Maintenance Events](/docs/v4/maintenances/events) - Learn about event lifecycle and status transitions
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing) - How to configure monitor impacts
- [RRULE Patterns](/docs/v4/maintenances/rrule-patterns) - Advanced scheduling patterns
- [Maintenance Events](/docs/maintenances/events) - Learn about event lifecycle and status transitions
- [Creating and Managing Maintenances](/docs/maintenances/creating-managing) - How to configure monitor impacts
- [RRULE Patterns](/docs/maintenances/rrule-patterns) - Advanced scheduling patterns

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