mirror of
https://github.com/rajnandan1/kener.git
synced 2026-06-23 04:10:22 +00:00
Compare commits
128 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6764b470a9 | |||
| 9fc16187ca | |||
| 32c36bfc52 | |||
| 9ad4ca50fb | |||
| 2aa83b01c2 | |||
| a25d823db8 | |||
| c43b4ef863 | |||
| a9e06ae9f7 | |||
| a925791671 | |||
| ba1d0079de | |||
| 3503e73f56 | |||
| 5021e01018 | |||
| 95c341fc35 | |||
| e69fcdfd71 | |||
| acf11459ed | |||
| 9ad315c3c7 | |||
| caf7427b04 | |||
| 8833b7e410 | |||
| e43186d121 | |||
| 4536bceef6 | |||
| 734b062626 | |||
| f257cdc2c4 | |||
| 1362d06b20 | |||
| 663ce52c4e | |||
| 3f4df5faa7 | |||
| 2501875406 | |||
| 10586108c5 | |||
| 87c69201ab | |||
| a3ec81af20 | |||
| 61acf53c10 | |||
| 8bbafe4c8a | |||
| 4589568405 | |||
| e8fb4126a8 | |||
| e9d3281067 | |||
| 7439632eff | |||
| f0362fd919 | |||
| e61873164b | |||
| 6d7b56a0ac | |||
| 0940c8d01e | |||
| 17e3fa6d77 | |||
| a363079695 | |||
| 87fc3081df | |||
| 350e291db0 | |||
| 9a545dbf48 | |||
| ab527ff7d8 | |||
| 850ebae11a | |||
| a6948f087c | |||
| aaa7c2a46d | |||
| 8edf92ea02 | |||
| e5e7e44471 | |||
| f5ab338e2b | |||
| 5012ff1421 | |||
| 122ca71b8e | |||
| e27ab6ff7d | |||
| c301aaab90 | |||
| 4ed40a0b08 | |||
| 1ac0f2259f | |||
| 544bdc9dcb | |||
| a843ac2926 | |||
| ccceeb38bd | |||
| b01560c29b | |||
| 80c5e298d7 | |||
| 3d1335bf40 | |||
| 25c893ad86 | |||
| a9f0437d17 | |||
| 604210568b | |||
| 41f5296227 | |||
| 8c1a97d844 | |||
| ed1a70d75b | |||
| e63a2f6311 | |||
| 951ab06f7e | |||
| af4684a90f | |||
| da6eaee3ab | |||
| f264115ab8 | |||
| 15b78dab66 | |||
| 54277ece9a | |||
| 54f056ad34 | |||
| 8d2808c291 | |||
| bd638ccf24 | |||
| c2945485e2 | |||
| a57c92fc0e | |||
| 8e7bc47b14 | |||
| cd26c46493 | |||
| 508b08f8f3 | |||
| 638393efac | |||
| 5a54d69d87 | |||
| 7e5ea5fda1 | |||
| 175cf605c6 | |||
| 6a9bfffbd4 | |||
| 7b120911b4 | |||
| 35817bc20a | |||
| a8fbac1b69 | |||
| cfc99e2f14 | |||
| 31ba10f434 | |||
| 1750e2a341 | |||
| a12df92b94 | |||
| 5d86084138 | |||
| 6e585f4608 | |||
| f4d01c7c37 | |||
| 546118a725 | |||
| e53a577174 | |||
| 45c0e9a1e6 | |||
| 7050f780a3 | |||
| cb93089dcc | |||
| fcd05e1d68 | |||
| db9d7807e0 | |||
| b7e0756c54 | |||
| b920d2f9bc | |||
| 560c87219b | |||
| 94e24eec04 | |||
| 15680a58aa | |||
| 59f0eaef27 | |||
| 8362a73058 | |||
| 52f8c50f50 | |||
| 60868d55ca | |||
| f7e657ee95 | |||
| 63e5ec2886 | |||
| 2aef97c1ed | |||
| 51b2da97e0 | |||
| 50bddcd9a3 | |||
| bd36533b05 | |||
| 17500a0b43 | |||
| 0050cd810b | |||
| db6cb6cf7d | |||
| babeeb75b2 | |||
| e0187605e7 | |||
| e2861f1e59 | |||
| 01aa4d9984 |
+6
-1
@@ -34,4 +34,9 @@ temp.js
|
||||
.DS_Store
|
||||
knip-output.txt
|
||||
check-output.txt
|
||||
translation-report.json
|
||||
translation-report.json
|
||||
|
||||
# AI workflow docs (not version-controlled)
|
||||
CONTEXT.md
|
||||
docs/adr/
|
||||
docs/superpowers/
|
||||
@@ -121,3 +121,17 @@ Read `.claude/skills/` for specialized instructions on:
|
||||
- **svelte-code-writer** - Svelte component creation/editing
|
||||
- **documentation-writer** - Editing docs in `src/routes/(docs)/docs/content/`
|
||||
- **tailwindcss** - Tailwind CSS v4 patterns
|
||||
|
||||
## Agent skills
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Issues and PRDs are tracked in GitHub Issues for `rajnandan1/kener`. See `docs/agents/issue-tracker.md`.
|
||||
|
||||
### Triage labels
|
||||
|
||||
Triage uses the default mattpocock/skills label vocabulary. See `docs/agents/triage-labels.md`.
|
||||
|
||||
### Domain docs
|
||||
|
||||
This repo uses a single-context domain-doc layout. See `docs/agents/domain.md`.
|
||||
|
||||
@@ -163,6 +163,7 @@ COPY --chown=node:node --from=builder /app/seeds ./seeds
|
||||
COPY --chown=node:node --from=builder /app/src/lib/server/db/seedSiteData.ts ./src/lib/server/db/seedSiteData.ts
|
||||
COPY --chown=node:node --from=builder /app/src/lib/server/db/seedMonitorData.ts ./src/lib/server/db/seedMonitorData.ts
|
||||
COPY --chown=node:node --from=builder /app/src/lib/server/db/seedPagesData.ts ./src/lib/server/db/seedPagesData.ts
|
||||
COPY --chown=node:node --from=builder /app/src/lib/allPerms.ts ./src/lib/allPerms.ts
|
||||
COPY --chown=node:node --from=builder /app/src/lib/server/templates/general ./src/lib/server/templates/general
|
||||
|
||||
# Locale JSON files (read at runtime by server-side i18n)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Domain Docs
|
||||
|
||||
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
|
||||
|
||||
This repo is configured as a **single-context** repo.
|
||||
|
||||
## Before exploring, read these
|
||||
|
||||
- **`CONTEXT.md`** at the repo root.
|
||||
- **`docs/adr/`** — read ADRs that touch the area you're about to work in.
|
||||
|
||||
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved.
|
||||
|
||||
## File structure
|
||||
|
||||
Single-context repo:
|
||||
|
||||
```text
|
||||
/
|
||||
├── CONTEXT.md
|
||||
├── docs/adr/
|
||||
│ ├── 0001-event-sourced-orders.md
|
||||
│ └── 0002-postgres-for-write-model.md
|
||||
└── src/
|
||||
```
|
||||
|
||||
## Use the glossary's vocabulary
|
||||
|
||||
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
|
||||
|
||||
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`).
|
||||
|
||||
## Flag ADR conflicts
|
||||
|
||||
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
|
||||
|
||||
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
|
||||
@@ -0,0 +1,22 @@
|
||||
# Issue tracker: GitHub
|
||||
|
||||
Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
|
||||
- **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
|
||||
- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
|
||||
- **Comment on an issue**: `gh issue comment <number> --body "..."`
|
||||
- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
|
||||
- **Close**: `gh issue close <number> --comment "..."`
|
||||
|
||||
Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone.
|
||||
|
||||
## When a skill says "publish to the issue tracker"
|
||||
|
||||
Create a GitHub issue.
|
||||
|
||||
## When a skill says "fetch the relevant ticket"
|
||||
|
||||
Run `gh issue view <number> --comments`.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Triage Labels
|
||||
|
||||
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
|
||||
|
||||
| Label in mattpocock/skills | Label in our tracker | Meaning |
|
||||
| -------------------------- | -------------------- | ---------------------------------------- |
|
||||
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
|
||||
| `needs-info` | `needs-info` | Waiting on reporter for more information |
|
||||
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
|
||||
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
||||
| `wontfix` | `wontfix` | Will not be actioned |
|
||||
|
||||
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
|
||||
|
||||
Edit the right-hand column to match whatever vocabulary you actually use.
|
||||
+74
-3
@@ -7,13 +7,63 @@ const databaseURLParts = databaseURL.split("://");
|
||||
const databaseType = databaseURLParts[0];
|
||||
const databasePath = databaseURLParts[1];
|
||||
|
||||
const intFromEnv = (name: string, fallback: number): number => {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined) return fallback;
|
||||
const parsed = parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
};
|
||||
|
||||
// TCP keepalive on pooled connections, on by default. Cloud networks (Railway,
|
||||
// Docker Swarm overlays, k8s) silently drop idle TCP connections; without
|
||||
// keepalive the pool keeps handing out dead sockets after an idle period or a
|
||||
// database restart. See docs .../setup/database-setup.md.
|
||||
const keepAliveEnabled = process.env.DATABASE_KEEPALIVE !== "false";
|
||||
|
||||
interface PoolConfig {
|
||||
min: number;
|
||||
max: number;
|
||||
idleTimeoutMillis: number;
|
||||
createTimeoutMillis: number;
|
||||
}
|
||||
|
||||
// Two pools share one process (Postgres/MySQL only): the WEB pool serves
|
||||
// SvelteKit requests; the WORKER pool serves background jobs (BullMQ workers +
|
||||
// schedulers, routed via src/lib/server/db/poolContext.ts). Isolating them
|
||||
// stops a burst of background jobs from exhausting the connections that serve
|
||||
// page loads. Budget across both pools: replicas * (web + worker) must stay
|
||||
// under the database's max_connections. SQLite has no real pool and reuses a
|
||||
// single connection, so the split does not apply there.
|
||||
//
|
||||
// Pool defaults deviate from knex's on purpose:
|
||||
// - min 0: knex's min 2 connections are never reaped, so they are exactly the
|
||||
// ones that go stale and wedge the app until a manual restart
|
||||
// - 15s acquire/create timeouts: fail fast instead of hanging requests for
|
||||
// knex's default 60s during a database outage
|
||||
// Tarn requires max >= 1 and min <= max; clamp so a bad env value can not
|
||||
// produce a pool that fails every acquire
|
||||
const idleTimeoutMillis = intFromEnv("DATABASE_IDLE_TIMEOUT_MS", 30000);
|
||||
const createTimeoutMillis = intFromEnv("DATABASE_CREATE_TIMEOUT_MS", 15000);
|
||||
const poolMin = intFromEnv("DATABASE_POOL_MIN", 0);
|
||||
const buildPool = (max: number): PoolConfig => ({
|
||||
min: Math.min(poolMin, max),
|
||||
max,
|
||||
idleTimeoutMillis,
|
||||
createTimeoutMillis,
|
||||
});
|
||||
const webPool = buildPool(Math.max(1, intFromEnv("DATABASE_POOL_MAX", 10)));
|
||||
const workerPool = buildPool(Math.max(1, intFromEnv("DATABASE_WORKER_POOL_MAX", 5)));
|
||||
const acquireConnectionTimeout = intFromEnv("DATABASE_ACQUIRE_TIMEOUT_MS", 15000);
|
||||
|
||||
interface KnexConfig {
|
||||
migrations: { directory: string };
|
||||
seeds: { directory: string };
|
||||
databaseType: string;
|
||||
client?: string;
|
||||
connection?: string | { filename: string };
|
||||
connection?: string | { filename: string } | Record<string, unknown>;
|
||||
useNullAsDefault?: boolean;
|
||||
pool?: PoolConfig;
|
||||
acquireConnectionTimeout?: number;
|
||||
}
|
||||
|
||||
const knexOb: KnexConfig = {
|
||||
@@ -25,6 +75,13 @@ const knexOb: KnexConfig = {
|
||||
},
|
||||
databaseType,
|
||||
};
|
||||
|
||||
// Worker pool config for Postgres/MySQL — same connection as the web config,
|
||||
// but with the worker pool. Stays null for SQLite (single shared connection),
|
||||
// in which case the app reuses the web instance for background work too.
|
||||
let workerKnexOb: KnexConfig | null = null;
|
||||
|
||||
console.log(`Configuring database with type ${databaseType}`);
|
||||
if (databaseType === "sqlite") {
|
||||
knexOb.client = "better-sqlite3";
|
||||
knexOb.connection = {
|
||||
@@ -33,13 +90,27 @@ if (databaseType === "sqlite") {
|
||||
knexOb.useNullAsDefault = true;
|
||||
} else if (databaseType === "postgresql") {
|
||||
knexOb.client = "pg";
|
||||
knexOb.connection = databaseURL;
|
||||
knexOb.connection = {
|
||||
connectionString: databaseURL,
|
||||
keepAlive: keepAliveEnabled,
|
||||
};
|
||||
knexOb.pool = webPool;
|
||||
knexOb.acquireConnectionTimeout = acquireConnectionTimeout;
|
||||
workerKnexOb = { ...knexOb, pool: workerPool };
|
||||
} else if (databaseType === "mysql") {
|
||||
knexOb.client = "mysql2";
|
||||
knexOb.connection = databaseURL;
|
||||
knexOb.connection = {
|
||||
uri: databaseURL,
|
||||
enableKeepAlive: keepAliveEnabled,
|
||||
keepAliveInitialDelay: 10000,
|
||||
};
|
||||
knexOb.pool = webPool;
|
||||
knexOb.acquireConnectionTimeout = acquireConnectionTimeout;
|
||||
workerKnexOb = { ...knexOb, pool: workerPool };
|
||||
} else {
|
||||
console.error("Invalid database type");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export { workerKnexOb };
|
||||
export default knexOb;
|
||||
|
||||
@@ -97,7 +97,51 @@ export async function up(knex: Knex): Promise<void> {
|
||||
const dbClient = knex.client.config.client;
|
||||
|
||||
if (dbClient === "sqlite3" || dbClient === "better-sqlite3") {
|
||||
await knex("monitor_alerts_config").update({ monitor_tag: null });
|
||||
// SQLite cannot ALTER COLUMN, so we rebuild the table with monitor_tag nullable
|
||||
await knex.transaction(async (trx) => {
|
||||
await trx.raw("PRAGMA foreign_keys = OFF");
|
||||
await trx.raw("DROP TABLE IF EXISTS monitor_alerts_config_new");
|
||||
await trx.raw(`
|
||||
CREATE TABLE monitor_alerts_config_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
monitor_tag VARCHAR(255),
|
||||
alert_for VARCHAR(50) NOT NULL,
|
||||
alert_value VARCHAR(255) NOT NULL,
|
||||
failure_threshold INTEGER NOT NULL DEFAULT 1,
|
||||
success_threshold INTEGER NOT NULL DEFAULT 1,
|
||||
alert_description TEXT,
|
||||
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
|
||||
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
|
||||
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
await trx.raw(`
|
||||
INSERT INTO monitor_alerts_config_new
|
||||
(id, monitor_tag, alert_for, alert_value, failure_threshold,
|
||||
success_threshold, alert_description, create_incident,
|
||||
is_active, severity, created_at, updated_at)
|
||||
SELECT
|
||||
id, NULL, alert_for, alert_value, failure_threshold,
|
||||
success_threshold, alert_description, create_incident,
|
||||
is_active, severity, created_at, updated_at
|
||||
FROM monitor_alerts_config
|
||||
`);
|
||||
await trx.raw("DROP TABLE monitor_alerts_config");
|
||||
await trx.raw("ALTER TABLE monitor_alerts_config_new RENAME TO monitor_alerts_config");
|
||||
try {
|
||||
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
|
||||
} catch (_e) {
|
||||
/* index may already exist */
|
||||
}
|
||||
try {
|
||||
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
|
||||
} catch (_e) {
|
||||
/* index may already exist */
|
||||
}
|
||||
await trx.raw("PRAGMA foreign_keys = ON");
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await knex.schema.alterTable("monitor_alerts_config", (table) => {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Migration: Fix SQLite monitor_alerts_config.monitor_tag NOT NULL constraint
|
||||
*
|
||||
* The earlier migration 20260325120000_multi_monitor_alerts nulled out data in
|
||||
* monitor_alerts_config.monitor_tag for SQLite but could not alter the column
|
||||
* constraint (SQLite doesn't support ALTER COLUMN). This migration recreates
|
||||
* the table with monitor_tag as nullable, preserving all data and indexes.
|
||||
*
|
||||
* Only runs on SQLite/better-sqlite3; other databases already had the column
|
||||
* altered in the previous migration.
|
||||
*/
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const dbClient = knex.client.config.client;
|
||||
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
|
||||
return; // Already handled by 20260325120000_multi_monitor_alerts
|
||||
}
|
||||
|
||||
// Check if monitor_tag is already nullable (migration 1 already rebuilt the table)
|
||||
const tableInfo: Array<{ name: string; notnull: number }> = await knex.raw(
|
||||
"PRAGMA table_info(monitor_alerts_config)",
|
||||
);
|
||||
const monitorTagCol = tableInfo.find((col) => col.name === "monitor_tag");
|
||||
if (monitorTagCol && monitorTagCol.notnull === 0) {
|
||||
return; // Column is already nullable, nothing to do
|
||||
}
|
||||
|
||||
// Column is still NOT NULL — rebuild the table to make it nullable
|
||||
try {
|
||||
await knex.transaction(async (trx) => {
|
||||
await trx.raw("PRAGMA foreign_keys = OFF");
|
||||
await trx.raw("DROP TABLE IF EXISTS monitor_alerts_config_new");
|
||||
await trx.raw(`
|
||||
CREATE TABLE monitor_alerts_config_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
monitor_tag VARCHAR(255),
|
||||
alert_for VARCHAR(50) NOT NULL,
|
||||
alert_value VARCHAR(255) NOT NULL,
|
||||
failure_threshold INTEGER NOT NULL DEFAULT 1,
|
||||
success_threshold INTEGER NOT NULL DEFAULT 1,
|
||||
alert_description TEXT,
|
||||
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
|
||||
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
|
||||
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
await trx.raw(`
|
||||
INSERT INTO monitor_alerts_config_new
|
||||
(id, monitor_tag, alert_for, alert_value, failure_threshold,
|
||||
success_threshold, alert_description, create_incident,
|
||||
is_active, severity, created_at, updated_at)
|
||||
SELECT
|
||||
id, NULL, alert_for, alert_value, failure_threshold,
|
||||
success_threshold, alert_description, create_incident,
|
||||
is_active, severity, created_at, updated_at
|
||||
FROM monitor_alerts_config
|
||||
`);
|
||||
|
||||
await trx.raw("DROP TABLE monitor_alerts_config");
|
||||
await trx.raw("ALTER TABLE monitor_alerts_config_new RENAME TO monitor_alerts_config");
|
||||
|
||||
try {
|
||||
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
|
||||
} catch (_e) {
|
||||
/* index may already exist */
|
||||
}
|
||||
try {
|
||||
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
|
||||
} catch (_e) {
|
||||
/* index may already exist */
|
||||
}
|
||||
await trx.raw("PRAGMA foreign_keys = ON");
|
||||
});
|
||||
} catch (e) {
|
||||
await knex.raw("PRAGMA foreign_keys = ON");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const dbClient = knex.client.config.client;
|
||||
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Revert: make monitor_tag NOT NULL again via table rebuild
|
||||
try {
|
||||
await knex.transaction(async (trx) => {
|
||||
await trx.raw("PRAGMA foreign_keys = OFF");
|
||||
await trx.raw(`
|
||||
CREATE TABLE monitor_alerts_config_old (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
monitor_tag VARCHAR(255) NOT NULL,
|
||||
alert_for VARCHAR(50) NOT NULL,
|
||||
alert_value VARCHAR(255) NOT NULL,
|
||||
failure_threshold INTEGER NOT NULL DEFAULT 1,
|
||||
success_threshold INTEGER NOT NULL DEFAULT 1,
|
||||
alert_description TEXT,
|
||||
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
|
||||
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
|
||||
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Only copy rows that have a non-null monitor_tag
|
||||
await trx.raw(`
|
||||
INSERT INTO monitor_alerts_config_old
|
||||
(id, monitor_tag, alert_for, alert_value, failure_threshold,
|
||||
success_threshold, alert_description, create_incident,
|
||||
is_active, severity, created_at, updated_at)
|
||||
SELECT
|
||||
id, monitor_tag, alert_for, alert_value, failure_threshold,
|
||||
success_threshold, alert_description, create_incident,
|
||||
is_active, severity, created_at, updated_at
|
||||
FROM monitor_alerts_config
|
||||
WHERE monitor_tag IS NOT NULL
|
||||
`);
|
||||
|
||||
await trx.raw("DROP TABLE monitor_alerts_config");
|
||||
await trx.raw("ALTER TABLE monitor_alerts_config_old RENAME TO monitor_alerts_config");
|
||||
|
||||
try {
|
||||
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
|
||||
} catch (_e) {
|
||||
/* index may already exist */
|
||||
}
|
||||
try {
|
||||
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
|
||||
} catch (_e) {
|
||||
/* index may already exist */
|
||||
}
|
||||
await trx.raw("PRAGMA foreign_keys = ON");
|
||||
});
|
||||
} catch (e) {
|
||||
await knex.raw("PRAGMA foreign_keys = ON");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// 1. Roles table
|
||||
if (!(await knex.schema.hasTable("roles"))) {
|
||||
await knex.schema.createTable("roles", (table) => {
|
||||
table.string("id", 100).primary();
|
||||
table.text("role_name").notNullable();
|
||||
table.integer("readonly").notNullable().defaultTo(0);
|
||||
table.string("status", 20).notNullable().defaultTo("ACTIVE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Permissions table
|
||||
if (!(await knex.schema.hasTable("permissions"))) {
|
||||
await knex.schema.createTable("permissions", (table) => {
|
||||
table.string("id", 100).primary();
|
||||
table.text("permission_name").notNullable();
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Roles ↔ Permissions junction table
|
||||
if (!(await knex.schema.hasTable("roles_permissions"))) {
|
||||
await knex.schema.createTable("roles_permissions", (table) => {
|
||||
table.string("roles_id", 100).notNullable().references("id").inTable("roles").onDelete("CASCADE");
|
||||
table.string("permissions_id", 100).notNullable().references("id").inTable("permissions").onDelete("CASCADE");
|
||||
table.string("status", 20).notNullable().defaultTo("ACTIVE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
table.primary(["roles_id", "permissions_id"]);
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Users ↔ Roles junction table
|
||||
if (!(await knex.schema.hasTable("users_roles"))) {
|
||||
await knex.schema.createTable("users_roles", (table) => {
|
||||
table.string("roles_id", 100).notNullable().references("id").inTable("roles").onDelete("CASCADE");
|
||||
table.integer("users_id").unsigned().notNullable().references("id").inTable("users").onDelete("CASCADE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
table.primary(["roles_id", "users_id"]);
|
||||
table.index("users_id", "idx_users_roles_users_id");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists("users_roles");
|
||||
await knex.schema.dropTableIfExists("roles_permissions");
|
||||
await knex.schema.dropTableIfExists("permissions");
|
||||
await knex.schema.dropTableIfExists("roles");
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
// Maps the legacy users.role string to the new roles.id value.
|
||||
// The old default was "user"; everything unmapped falls back to "member".
|
||||
const ROLE_MAP: Record<string, string> = {
|
||||
admin: "admin",
|
||||
editor: "editor",
|
||||
member: "member",
|
||||
user: "member",
|
||||
};
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn("users", "role");
|
||||
if (!hasColumn) return;
|
||||
|
||||
// 1. Ensure the three target roles exist so FK inserts succeed.
|
||||
// Seeds will reconcile permissions later; we only need the rows.
|
||||
const rolesToEnsure = [
|
||||
{ id: "admin", role_name: "Administrator" },
|
||||
{ id: "editor", role_name: "Editor" },
|
||||
{ id: "member", role_name: "Member" },
|
||||
];
|
||||
for (const role of rolesToEnsure) {
|
||||
const exists = await knex("roles").where("id", role.id).first();
|
||||
if (!exists) {
|
||||
await knex("roles").insert({
|
||||
id: role.id,
|
||||
role_name: role.role_name,
|
||||
readonly: 1,
|
||||
status: "ACTIVE",
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Read users.role into memory BEFORE dropping the column.
|
||||
// On SQLite, dropColumn recreates the table (create → copy → drop → rename),
|
||||
// which can discard DML inserts to tables with FKs pointing at users.
|
||||
const users: Array<{ id: number; role: string }> = await knex("users").select("id", "role");
|
||||
|
||||
// 3. Drop the column first.
|
||||
await knex.schema.alterTable("users", (table) => {
|
||||
table.dropColumn("role");
|
||||
});
|
||||
|
||||
// 4. Now populate users_roles from the in-memory snapshot.
|
||||
for (const user of users) {
|
||||
const newRoleId = ROLE_MAP[user.role] ?? "member";
|
||||
|
||||
const alreadyAssigned = await knex("users_roles").where({ roles_id: newRoleId, users_id: user.id }).first();
|
||||
|
||||
if (!alreadyAssigned) {
|
||||
await knex("users_roles").insert({
|
||||
roles_id: newRoleId,
|
||||
users_id: user.id,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse map: pick the highest-precedence role when backfilling.
|
||||
const REVERSE_ROLE_PRECEDENCE: string[] = ["admin", "editor", "member"];
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn("users", "role");
|
||||
if (!hasColumn) {
|
||||
await knex.schema.alterTable("users", (table) => {
|
||||
table.string("role").defaultTo("member");
|
||||
});
|
||||
}
|
||||
|
||||
// Backfill users.role from users_roles using deterministic precedence
|
||||
const assignments: Array<{ users_id: number; roles_id: string }> = await knex("users_roles").select(
|
||||
"users_id",
|
||||
"roles_id",
|
||||
);
|
||||
|
||||
// Group roles by user
|
||||
const userRolesMap = new Map<number, string[]>();
|
||||
for (const row of assignments) {
|
||||
const list = userRolesMap.get(row.users_id) || [];
|
||||
list.push(row.roles_id);
|
||||
userRolesMap.set(row.users_id, list);
|
||||
}
|
||||
|
||||
// Pick highest-precedence role for each user
|
||||
for (const [userId, roleIds] of userRolesMap) {
|
||||
const bestRole = REVERSE_ROLE_PRECEDENCE.find((r) => roleIds.includes(r)) || roleIds[0] || "member";
|
||||
await knex("users").where("id", userId).update({ role: bestRole });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasColumn("monitors", "confirmation_threshold"))) {
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.integer("confirmation_threshold").unsigned().notNullable().defaultTo(1);
|
||||
});
|
||||
}
|
||||
if (!(await knex.schema.hasColumn("monitoring_data", "raw_status"))) {
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.text("raw_status").nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasColumn("monitors", "confirmation_threshold")) {
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.dropColumn("confirmation_threshold");
|
||||
});
|
||||
}
|
||||
if (await knex.schema.hasColumn("monitoring_data", "raw_status")) {
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.dropColumn("raw_status");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// Covering index for the grouped status-count aggregation used by the
|
||||
// monitor-bars dashboard endpoint. The query filters by
|
||||
// (monitor_tag, timestamp range) and reads status + latency for every row.
|
||||
// Including status and latency in the index lets the database satisfy the
|
||||
// query from the index alone, avoiding a heap lookup per matched row.
|
||||
try {
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.index(
|
||||
["monitor_tag", "timestamp", "status", "latency"],
|
||||
"idx_monitoring_data_monitor_tag_timestamp_status_latency",
|
||||
);
|
||||
});
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.dropIndex(
|
||||
["monitor_tag", "timestamp", "status", "latency"],
|
||||
"idx_monitoring_data_monitor_tag_timestamp_status_latency",
|
||||
);
|
||||
});
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "kener",
|
||||
"version": "4.0.21",
|
||||
"version": "4.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kener",
|
||||
"version": "4.0.21",
|
||||
"version": "4.1.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kener",
|
||||
"version": "4.0.21",
|
||||
"version": "4.1.1",
|
||||
"type": "module",
|
||||
"private": false,
|
||||
"license": "MIT",
|
||||
|
||||
+110
-70
@@ -6,6 +6,7 @@ import Startup from "../src/lib/server/startup.ts";
|
||||
import shutdownSchedulers from "../src/lib/server/schedulers/shutdown.ts";
|
||||
import shutdownQueues from "../src/lib/server/queues/shutdown.ts";
|
||||
import dbInstance from "../src/lib/server/db/db.ts";
|
||||
import { redisConnection } from "../src/lib/server/redisConnector.ts";
|
||||
import knex from "knex";
|
||||
import knexOb from "../knexfile.js";
|
||||
|
||||
@@ -13,89 +14,128 @@ const PORT = process.env.PORT || 3000;
|
||||
const base = process.env.KENER_BASE_PATH || "";
|
||||
|
||||
async function start() {
|
||||
// Dynamic import so BODY_SIZE_LIMIT from .env is available
|
||||
// before the handler reads it at module top-level
|
||||
const { handler } = await import("../build/handler.js");
|
||||
// Dynamic import so BODY_SIZE_LIMIT from .env is available
|
||||
// before the handler reads it at module top-level
|
||||
const { handler } = await import("../build/handler.js");
|
||||
|
||||
const app: any = express();
|
||||
const db = knex(knexOb);
|
||||
const app: any = express();
|
||||
const db = knex(knexOb);
|
||||
|
||||
app.get(base + "/healthcheck", (req: any, res: any) => {
|
||||
res.end("ok");
|
||||
});
|
||||
// Caps a health probe at 2s so a wedged dependency can not hang the
|
||||
// endpoint. A probe is healthy unless it throws, times out, or resolves false.
|
||||
const probe = async (check: () => Promise<unknown>): Promise<boolean> => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
check(),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("health probe timeout")), 2000);
|
||||
}),
|
||||
]);
|
||||
return result !== false;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
app.use(handler);
|
||||
// Reports component health. Always 200 so healthcheck-driven restarters do
|
||||
// not bounce the app while a dependency is down (a restart can not fix a
|
||||
// dead database); pass ?strict=1 to get 503 when any component is down.
|
||||
app.get(base + "/healthcheck", async (req: any, res: any) => {
|
||||
const [dbOk, redisOk] = await Promise.all([
|
||||
probe(() => dbInstance.ping()),
|
||||
// Guard on status before PING: the shared ioredis client has
|
||||
// maxRetriesPerRequest null, so commands sent while disconnected would
|
||||
// queue forever and accumulate across healthcheck polls
|
||||
probe(async () => {
|
||||
const redis = redisConnection();
|
||||
if (redis.status !== "ready") return false;
|
||||
return await redis.ping();
|
||||
}),
|
||||
]);
|
||||
const healthy = dbOk && redisOk;
|
||||
const strict = req.query.strict === "1";
|
||||
res.status(strict && !healthy ? 503 : 200).json({
|
||||
status: healthy ? "ok" : "degraded",
|
||||
db: dbOk,
|
||||
redis: redisOk,
|
||||
});
|
||||
});
|
||||
|
||||
//migrations
|
||||
async function runMigrations() {
|
||||
try {
|
||||
// Rename old .js migration entries to .ts in the knex_migrations table
|
||||
// so Knex can find the renamed files on disk
|
||||
const hasTable = await db.schema.hasTable("knex_migrations");
|
||||
if (hasTable) {
|
||||
const oldJsMigrations = await db("knex_migrations").where("name", "like", "%.js");
|
||||
for (const row of oldJsMigrations) {
|
||||
const newName = row.name.replace(/\.js$/, ".ts");
|
||||
await db("knex_migrations").where("id", row.id).update({ name: newName });
|
||||
console.log(`Renamed migration record: ${row.name} -> ${newName}`);
|
||||
}
|
||||
}
|
||||
app.use(handler);
|
||||
|
||||
console.log("Running migrations...");
|
||||
await db.migrate.latest(); // Runs migrations to the latest state
|
||||
console.log("Migrations completed successfully!");
|
||||
} catch (err) {
|
||||
console.error("Error running migrations:", err);
|
||||
}
|
||||
}
|
||||
//migrations
|
||||
async function runMigrations() {
|
||||
try {
|
||||
// Rename old .js migration entries to .ts in the knex_migrations table
|
||||
// so Knex can find the renamed files on disk
|
||||
const hasTable = await db.schema.hasTable("knex_migrations");
|
||||
if (hasTable) {
|
||||
const oldJsMigrations = await db("knex_migrations").where("name", "like", "%.js");
|
||||
for (const row of oldJsMigrations) {
|
||||
const newName = row.name.replace(/\.js$/, ".ts");
|
||||
await db("knex_migrations").where("id", row.id).update({ name: newName });
|
||||
console.log(`Renamed migration record: ${row.name} -> ${newName}`);
|
||||
}
|
||||
}
|
||||
|
||||
//seed
|
||||
async function runSeed() {
|
||||
try {
|
||||
console.log("Running seed...");
|
||||
await db.seed.run(); // Runs seed to the latest state
|
||||
console.log("Seed completed successfully!");
|
||||
} catch (err) {
|
||||
console.error("Error running seed:", err);
|
||||
}
|
||||
}
|
||||
console.log("Running migrations...");
|
||||
await db.migrate.latest(); // Runs migrations to the latest state
|
||||
console.log("Migrations completed successfully!");
|
||||
} catch (err) {
|
||||
console.error("Error running migrations:", err);
|
||||
}
|
||||
}
|
||||
|
||||
app.listen(PORT, async () => {
|
||||
await runMigrations();
|
||||
await runSeed();
|
||||
await db.destroy();
|
||||
Startup();
|
||||
console.log("Kener is running on port " + PORT + "!");
|
||||
});
|
||||
//seed
|
||||
async function runSeed() {
|
||||
try {
|
||||
console.log("Running seed...");
|
||||
await db.seed.run(); // Runs seed to the latest state
|
||||
console.log("Seed completed successfully!");
|
||||
} catch (err) {
|
||||
console.error("Error running seed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown handler
|
||||
async function gracefulShutdown(signal: string) {
|
||||
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
|
||||
app.listen(PORT, async () => {
|
||||
await runMigrations();
|
||||
await runSeed();
|
||||
await db.destroy();
|
||||
Startup();
|
||||
console.log("Kener is running on port " + PORT + "!");
|
||||
});
|
||||
|
||||
try {
|
||||
console.log("Shutting down schedulers...");
|
||||
await shutdownSchedulers();
|
||||
console.log("Schedulers shut down successfully.");
|
||||
// Graceful shutdown handler
|
||||
async function gracefulShutdown(signal: string) {
|
||||
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
|
||||
|
||||
console.log("Shutting down queues...");
|
||||
await shutdownQueues();
|
||||
console.log("Queues shut down successfully.");
|
||||
try {
|
||||
console.log("Shutting down schedulers...");
|
||||
await shutdownSchedulers();
|
||||
console.log("Schedulers shut down successfully.");
|
||||
|
||||
console.log("Closing database connection...");
|
||||
await dbInstance.close();
|
||||
console.log("Database connection closed successfully.");
|
||||
console.log("Shutting down queues...");
|
||||
await shutdownQueues();
|
||||
console.log("Queues shut down successfully.");
|
||||
|
||||
console.log("Graceful shutdown completed.");
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error("Error during graceful shutdown:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log("Closing database connection...");
|
||||
await dbInstance.close();
|
||||
console.log("Database connection closed successfully.");
|
||||
|
||||
// Handle termination signals
|
||||
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
||||
console.log("Graceful shutdown completed.");
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error("Error during graceful shutdown:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle termination signals
|
||||
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
||||
}
|
||||
|
||||
start();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Knex } from "knex";
|
||||
import { permissions } from "../src/lib/allPerms.ts";
|
||||
|
||||
export async function seed(knex: Knex): Promise<void> {
|
||||
const permissionIds = new Set(permissions.map((p) => p.id));
|
||||
|
||||
// Get all existing permissions
|
||||
const existing: Array<{ id: string }> = await knex("permissions").select("id");
|
||||
const existingIds = new Set(existing.map((e) => e.id));
|
||||
|
||||
// Insert missing permissions
|
||||
for (const perm of permissions) {
|
||||
if (!existingIds.has(perm.id)) {
|
||||
await knex("permissions").insert({
|
||||
id: perm.id,
|
||||
permission_name: perm.permission_name,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Delete permissions that are no longer in the seed list
|
||||
const toDelete = existing.filter((e) => !permissionIds.has(e.id)).map((e) => e.id);
|
||||
if (toDelete.length > 0) {
|
||||
await knex("permissions").whereIn("id", toDelete).del();
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import type { Knex } from "knex";
|
||||
import { permissions } from "../src/lib/allPerms.ts";
|
||||
|
||||
/**
|
||||
* Seeds the three readonly roles (admin, editor, member),
|
||||
* assigns permissions to each role in roles_permissions,
|
||||
* and migrates existing users.role → users_roles.
|
||||
*
|
||||
* Permission mapping derived from src/routes/(manage)/manage/api/+server.ts:
|
||||
*
|
||||
* admin → all permissions
|
||||
* editor → all except api_keys.delete (AdminCan-only)
|
||||
* member → all .read permissions only
|
||||
*/
|
||||
|
||||
const readonlyRoles = [
|
||||
{ id: "admin", role_name: "Administrator" },
|
||||
{ id: "editor", role_name: "Editor" },
|
||||
{ id: "member", role_name: "Member" },
|
||||
];
|
||||
|
||||
const allPermissionIds = permissions.map((p) => p.id);
|
||||
const readPermissionIds = allPermissionIds.filter((id) => id.endsWith(".read"));
|
||||
|
||||
const rolePermissions: Record<string, string[]> = {
|
||||
admin: allPermissionIds,
|
||||
editor: allPermissionIds.filter((id) => id !== "api_keys.delete"),
|
||||
member: readPermissionIds,
|
||||
};
|
||||
|
||||
export async function seed(knex: Knex): Promise<void> {
|
||||
// 1. Ensure readonly roles exist
|
||||
for (const role of readonlyRoles) {
|
||||
const existing = await knex("roles").where("id", role.id).first();
|
||||
if (!existing) {
|
||||
await knex("roles").insert({
|
||||
id: role.id,
|
||||
role_name: role.role_name,
|
||||
readonly: 1,
|
||||
status: "ACTIVE",
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Seed roles_permissions for readonly roles
|
||||
// Only insert permissions that actually exist in the permissions table
|
||||
// to avoid FK constraint errors if permissions seed hasn't run yet.
|
||||
const existingPermRows: Array<{ id: string }> = await knex("permissions").select("id");
|
||||
const existingPermIds = new Set(existingPermRows.map((p) => p.id));
|
||||
|
||||
for (const [roleId, permissionIds] of Object.entries(rolePermissions)) {
|
||||
const validPermissionIds = permissionIds.filter((id) => existingPermIds.has(id));
|
||||
|
||||
const existingPerms: Array<{ permissions_id: string }> = await knex("roles_permissions")
|
||||
.where("roles_id", roleId)
|
||||
.select("permissions_id");
|
||||
const existingSet = new Set(existingPerms.map((e) => e.permissions_id));
|
||||
|
||||
// Insert missing permissions
|
||||
for (const permId of validPermissionIds) {
|
||||
if (!existingSet.has(permId)) {
|
||||
await knex("roles_permissions").insert({
|
||||
roles_id: roleId,
|
||||
permissions_id: permId,
|
||||
status: "ACTIVE",
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove permissions no longer assigned to this role
|
||||
const desiredSet = new Set(validPermissionIds);
|
||||
const toRemove = existingPerms.filter((e) => !desiredSet.has(e.permissions_id)).map((e) => e.permissions_id);
|
||||
if (toRemove.length > 0) {
|
||||
await knex("roles_permissions").where("roles_id", roleId).whereIn("permissions_id", toRemove).del();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Migrate existing users: read users.role → insert into users_roles
|
||||
const hasRoleColumn = await knex.schema.hasColumn("users", "role");
|
||||
if (hasRoleColumn) {
|
||||
const users: Array<{ id: number; role: string }> = await knex("users").select("id", "role");
|
||||
|
||||
for (const user of users) {
|
||||
if (!user.role) continue;
|
||||
|
||||
// Only migrate if a matching role exists
|
||||
const roleExists = await knex("roles").where("id", user.role).first();
|
||||
if (!roleExists) continue;
|
||||
|
||||
// Skip if already assigned
|
||||
const existing = await knex("users_roles").where({ roles_id: user.role, users_id: user.id }).first();
|
||||
if (!existing) {
|
||||
await knex("users_roles").insert({
|
||||
roles_id: user.role,
|
||||
users_id: user.id,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta http-equiv="refresh" content="30" />
|
||||
<title>%sveltekit.status% — Status page temporarily unavailable</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #ffffff;
|
||||
--fg: #09090b;
|
||||
--muted: #71717a;
|
||||
--border: #e4e4e7;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #09090b;
|
||||
--fg: #fafafa;
|
||||
--muted: #a1a1aa;
|
||||
--border: #27272a;
|
||||
}
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
}
|
||||
.card {
|
||||
max-width: 28rem;
|
||||
margin: 1rem;
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
p {
|
||||
color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.code {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>This status page is temporarily unavailable</h1>
|
||||
<p>We are having trouble serving this page right now. It usually resolves on its own.</p>
|
||||
<p>This page will retry automatically in 30 seconds.</p>
|
||||
<div class="code">%sveltekit.status%</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+17
-1
@@ -4,6 +4,7 @@ import { VerifyAPIKey } from "$lib/server/controllers/apiController";
|
||||
import db from "$lib/server/db/db";
|
||||
import type { UnauthorizedResponse, NotFoundResponse } from "$lib/types/api";
|
||||
import { GetMonitorsParsed } from "$lib/server/controllers/monitorsController";
|
||||
import GC from "$lib/global-constants";
|
||||
|
||||
const API_PATH_PREFIX = "/api/";
|
||||
|
||||
@@ -119,6 +120,18 @@ const apiAuthHandle: Handle = async ({ event, resolve }) => {
|
||||
return json(errorResponse, { status: 401 });
|
||||
}
|
||||
|
||||
// API consumers must always get JSON; without this, an /api/ path with no
|
||||
// matching route falls through to SvelteKit's HTML error page
|
||||
if (event.route.id === null) {
|
||||
const errorResponse: NotFoundResponse = {
|
||||
error: {
|
||||
code: "NOT_FOUND",
|
||||
message: `No API route matches '${pathname}'`,
|
||||
},
|
||||
};
|
||||
return json(errorResponse, { status: 404 });
|
||||
}
|
||||
|
||||
// Validate monitor tag exists for /api/(vX/)?monitors/:monitor_tag/* routes
|
||||
const monitorTag = extractMonitorTag(pathname);
|
||||
if (monitorTag) {
|
||||
@@ -173,7 +186,10 @@ const apiAuthHandle: Handle = async ({ event, resolve }) => {
|
||||
// Validate page_path exists for /api/(vX/)?pages/:page_path/* routes
|
||||
const pagePath = extractPagePath(pathname);
|
||||
if (pagePath) {
|
||||
const page = await db.getPageByPath(pagePath);
|
||||
// The home page has an empty page_path, unreachable as a URL segment;
|
||||
// the ~home token addresses it instead
|
||||
const lookupPath = pagePath === GC.HOME_PAGE_TOKEN ? "" : pagePath;
|
||||
const page = await db.getPageByPath(lookupPath);
|
||||
if (!page) {
|
||||
const errorResponse: NotFoundResponse = {
|
||||
error: {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Reroute } from "@sveltejs/kit";
|
||||
|
||||
// Back-compat for issue #759: heartbeat URLs used to be `/ext/heartbeat/<tag>:<secret>`,
|
||||
// one path segment joined by a colon. A `:` is illegal in Windows file paths, so the
|
||||
// route is now `/ext/heartbeat/<tag>/<secret>` (two segments). Legacy colon-form URLs
|
||||
// live forever in external cron jobs / uptime pingers, so rewrite them internally to
|
||||
// the new path. Returns a 200 (no redirect) — heartbeat clients often don't follow 3xx.
|
||||
//
|
||||
// `reroute` is a *universal* hook: it MUST be in src/hooks.ts. A `reroute` exported from
|
||||
// src/hooks.server.ts is silently ignored by SvelteKit. Keep this file free of
|
||||
// server-only imports — it is bundled for the client too. Must stay pure/side-effect-free.
|
||||
//
|
||||
// The transform is in-place (no path reconstruction), so any KENER_BASE_PATH prefix is
|
||||
// preserved automatically. `[^/:]+` matches the validated tag charset; only the first
|
||||
// colon after `/ext/heartbeat/<tag>` is rewritten.
|
||||
const LEGACY_HEARTBEAT = /(\/ext\/heartbeat\/[^/:]+):/;
|
||||
|
||||
export const reroute: Reroute = ({ url }) => {
|
||||
if (LEGACY_HEARTBEAT.test(url.pathname)) {
|
||||
return url.pathname.replace(LEGACY_HEARTBEAT, "$1/");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Permissions derived from src/routes/(manage)/manage/api/+server.ts actions.
|
||||
* Grouped by domain with read/write granularity.
|
||||
*
|
||||
* Mapping from actions → permissions:
|
||||
*
|
||||
* monitors.read → getMonitors, getMonitoringDataPaginated
|
||||
* monitors.write → storeMonitorData, updateMonitoringData, deleteMonitor, deleteMonitorData, cloneMonitor, testMonitor
|
||||
*
|
||||
* incidents.read → getIncidents, getIncident, getComments
|
||||
* incidents.write → createIncident, updateIncident, deleteIncident, addMonitor, removeMonitor, addComment, deleteComment, updateComment
|
||||
*
|
||||
* maintenances.read → getMaintenances, getMaintenance, getMaintenanceEvents, getMaintenanceEvent, getMaintenanceMonitors
|
||||
* maintenances.write → createMaintenance, updateMaintenance, deleteMaintenance, createMaintenanceEvent, updateMaintenanceEvent, updateMaintenanceEventStatus, deleteMaintenanceEvent, addMonitorToMaintenance, removeMonitorFromMaintenance, updateMaintenanceMonitorImpact
|
||||
*
|
||||
* pages.read → getPages
|
||||
* pages.write → createPage, updatePage, deletePage, addMonitorToPage, removeMonitorFromPage, reorderPageMonitors
|
||||
*
|
||||
* triggers.read → getTriggers
|
||||
* triggers.write → createUpdateTrigger, updateMonitorTriggers, deleteTrigger, testTrigger
|
||||
*
|
||||
* alerts.read → getMonitorAlertConfig, getMonitorAlertConfigById, getMonitorAlertConfigsByMonitorTag, getAlertConfigsPaginated, getAllAlertsPaginated
|
||||
* alerts.write → createMonitorAlertConfig, updateMonitorAlertConfig, deleteMonitorAlertConfig, toggleMonitorAlertConfigStatus, deleteMonitorAlertV2, updateMonitorAlertV2Status
|
||||
*
|
||||
* api_keys.read → getAPIKeys
|
||||
* api_keys.write → createNewApiKey, updateApiKeyStatus
|
||||
* api_keys.delete → deleteApiKey (admin-only today)
|
||||
*
|
||||
* users.read → getUsers
|
||||
* users.write → manualUpdate, createNewUser, resendInvitation, sendVerificationEmail
|
||||
*
|
||||
* settings.read → getAllSiteData, getSiteDataByKey, getSubscriptionsConfig
|
||||
* settings.write → storeSiteData, updateSubscriptionsConfig
|
||||
*
|
||||
* subscribers.read → getSubscribersByMethod, getSubscriberWithSubscriptions, getSubscriberCountsByMethod, getAdminSubscribers
|
||||
* subscribers.write → deleteUserSubscription, updateUserSubscriptionStatus, adminUpdateSubscriptionStatus, adminDeleteSubscriber, adminAddSubscriber
|
||||
*
|
||||
* email_templates.read → getGeneralEmailTemplates, getGeneralEmailTemplateById
|
||||
* email_templates.write → updateGeneralEmailTemplate
|
||||
*
|
||||
* images.write → uploadImage, deleteImage
|
||||
*/
|
||||
export const permissions: Array<{ id: string; permission_name: string }> = [
|
||||
// Monitors
|
||||
{ id: "monitors.read", permission_name: "View monitors and monitoring data" },
|
||||
{ id: "monitors.write", permission_name: "Create, update, delete, and clone monitors" },
|
||||
|
||||
// Incidents
|
||||
{ id: "incidents.read", permission_name: "View incidents and comments" },
|
||||
{ id: "incidents.write", permission_name: "Create, update, and delete incidents and comments" },
|
||||
|
||||
// Maintenances
|
||||
{ id: "maintenances.read", permission_name: "View maintenances and events" },
|
||||
{ id: "maintenances.write", permission_name: "Create, update, and delete maintenances and events" },
|
||||
|
||||
// Pages
|
||||
{ id: "pages.read", permission_name: "View pages" },
|
||||
{ id: "pages.write", permission_name: "Create, update, and delete pages" },
|
||||
|
||||
// Triggers
|
||||
{ id: "triggers.read", permission_name: "View triggers" },
|
||||
{ id: "triggers.write", permission_name: "Create, update, delete, and test triggers" },
|
||||
|
||||
// Alerts
|
||||
{ id: "alerts.read", permission_name: "View alert configurations and alert history" },
|
||||
{ id: "alerts.write", permission_name: "Create, update, and delete alert configurations" },
|
||||
|
||||
// API Keys
|
||||
{ id: "api_keys.read", permission_name: "View API keys" },
|
||||
{ id: "api_keys.write", permission_name: "Create and update API keys" },
|
||||
{ id: "api_keys.delete", permission_name: "Delete API keys" },
|
||||
|
||||
// Users
|
||||
{ id: "users.read", permission_name: "View users" },
|
||||
{ id: "users.write", permission_name: "Manage users, invitations, and verification" },
|
||||
|
||||
// Settings (site data + subscriptions config)
|
||||
{ id: "settings.read", permission_name: "View site settings and subscriptions config" },
|
||||
{ id: "settings.write", permission_name: "Update site settings and subscriptions config" },
|
||||
|
||||
// Subscribers
|
||||
{ id: "subscribers.read", permission_name: "View subscribers" },
|
||||
{ id: "subscribers.write", permission_name: "Manage subscribers and subscriptions" },
|
||||
|
||||
// Email Templates
|
||||
{ id: "email_templates.read", permission_name: "View email templates" },
|
||||
{ id: "email_templates.write", permission_name: "Update email templates" },
|
||||
|
||||
// Images
|
||||
{ id: "images.write", permission_name: "Upload and delete images" },
|
||||
|
||||
// Roles
|
||||
{ id: "roles.read", permission_name: "View roles, permissions, and user assignments" },
|
||||
{ id: "roles.write", permission_name: "Create, update, and delete roles" },
|
||||
{ id: "roles.assign_permissions", permission_name: "Add and remove permissions from roles" },
|
||||
{ id: "roles.assign_users", permission_name: "Add and remove users to and from roles" },
|
||||
];
|
||||
|
||||
export const ACTION_PERMISSION_MAP: Record<string, string | null> = {
|
||||
// Self-actions — no permission needed beyond being logged in
|
||||
updateUser: null,
|
||||
updatePassword: null,
|
||||
sendVerificationEmail: null, // controller has its own self-vs-other check
|
||||
|
||||
// Settings
|
||||
getAllSiteData: "settings.read",
|
||||
getSiteDataByKey: "settings.read",
|
||||
getSubscriptionsConfig: "settings.read",
|
||||
storeSiteData: "settings.write",
|
||||
updateSubscriptionsConfig: "settings.write",
|
||||
|
||||
// Users
|
||||
getUsers: "users.read",
|
||||
manualUpdate: "users.write",
|
||||
createNewUser: "users.write",
|
||||
resendInvitation: "users.write",
|
||||
|
||||
// Monitors
|
||||
getMonitors: "monitors.read",
|
||||
getMonitoringDataPaginated: "monitors.read",
|
||||
storeMonitorData: "monitors.write",
|
||||
updateMonitoringData: "monitors.write",
|
||||
deleteMonitor: "monitors.write",
|
||||
deleteMonitorData: "monitors.write",
|
||||
cloneMonitor: "monitors.write",
|
||||
testMonitor: "monitors.write",
|
||||
|
||||
// Incidents
|
||||
getIncidents: "incidents.read",
|
||||
getIncident: "incidents.read",
|
||||
getComments: "incidents.read",
|
||||
createIncident: "incidents.write",
|
||||
updateIncident: "incidents.write",
|
||||
deleteIncident: "incidents.write",
|
||||
addMonitor: "incidents.write",
|
||||
removeMonitor: "incidents.write",
|
||||
addComment: "incidents.write",
|
||||
deleteComment: "incidents.write",
|
||||
updateComment: "incidents.write",
|
||||
|
||||
// Maintenances
|
||||
getMaintenances: "maintenances.read",
|
||||
getMaintenance: "maintenances.read",
|
||||
getMaintenanceEvents: "maintenances.read",
|
||||
getMaintenanceEvent: "maintenances.read",
|
||||
getMaintenanceMonitors: "maintenances.read",
|
||||
createMaintenance: "maintenances.write",
|
||||
updateMaintenance: "maintenances.write",
|
||||
deleteMaintenance: "maintenances.write",
|
||||
createMaintenanceEvent: "maintenances.write",
|
||||
updateMaintenanceEvent: "maintenances.write",
|
||||
updateMaintenanceEventStatus: "maintenances.write",
|
||||
deleteMaintenanceEvent: "maintenances.write",
|
||||
addMonitorToMaintenance: "maintenances.write",
|
||||
removeMonitorFromMaintenance: "maintenances.write",
|
||||
updateMaintenanceMonitorImpact: "maintenances.write",
|
||||
|
||||
// Pages
|
||||
getPages: "pages.read",
|
||||
createPage: "pages.write",
|
||||
updatePage: "pages.write",
|
||||
deletePage: "pages.write",
|
||||
addMonitorToPage: "pages.write",
|
||||
removeMonitorFromPage: "pages.write",
|
||||
reorderPageMonitors: "pages.write",
|
||||
|
||||
// Triggers
|
||||
getTriggers: "triggers.read",
|
||||
createUpdateTrigger: "triggers.write",
|
||||
updateMonitorTriggers: "triggers.write",
|
||||
deleteTrigger: "triggers.write",
|
||||
testTrigger: "triggers.write",
|
||||
|
||||
// Alerts
|
||||
getAllAlertsPaginated: "alerts.read",
|
||||
getMonitorAlertConfig: "alerts.read",
|
||||
getMonitorAlertConfigById: "alerts.read",
|
||||
getMonitorAlertConfigsByMonitorTag: "alerts.read",
|
||||
getAlertConfigsPaginated: "alerts.read",
|
||||
createMonitorAlertConfig: "alerts.write",
|
||||
updateMonitorAlertConfig: "alerts.write",
|
||||
deleteMonitorAlertConfig: "alerts.write",
|
||||
toggleMonitorAlertConfigStatus: "alerts.write",
|
||||
deleteMonitorAlertV2: "alerts.write",
|
||||
updateMonitorAlertV2Status: "alerts.write",
|
||||
|
||||
// API Keys
|
||||
getAPIKeys: "api_keys.read",
|
||||
createNewApiKey: "api_keys.write",
|
||||
updateApiKeyStatus: "api_keys.write",
|
||||
deleteApiKey: "api_keys.delete",
|
||||
|
||||
// Subscribers
|
||||
getSubscribersByMethod: "subscribers.read",
|
||||
getSubscriberWithSubscriptions: "subscribers.read",
|
||||
getSubscriberCountsByMethod: "subscribers.read",
|
||||
getAdminSubscribers: "subscribers.read",
|
||||
deleteUserSubscription: "subscribers.write",
|
||||
updateUserSubscriptionStatus: "subscribers.write",
|
||||
adminUpdateSubscriptionStatus: "subscribers.write",
|
||||
adminDeleteSubscriber: "subscribers.write",
|
||||
adminAddSubscriber: "subscribers.write",
|
||||
|
||||
// Email Templates
|
||||
getGeneralEmailTemplates: "email_templates.read",
|
||||
getGeneralEmailTemplateById: "email_templates.read",
|
||||
updateGeneralEmailTemplate: "email_templates.write",
|
||||
|
||||
// Images
|
||||
uploadImage: "images.write",
|
||||
deleteImage: "images.write",
|
||||
|
||||
// Roles
|
||||
getRoles: "roles.read",
|
||||
getAllPermissions: "roles.read",
|
||||
getRolePermissions: "roles.read",
|
||||
getRoleUsers: "roles.read",
|
||||
createRole: "roles.write",
|
||||
updateRole: "roles.write",
|
||||
deleteRole: "roles.write",
|
||||
updateRolePermissions: "roles.assign_permissions",
|
||||
addUserToRole: "roles.assign_users",
|
||||
removeUserFromRole: "roles.assign_users",
|
||||
};
|
||||
|
||||
export const ROUTE_PERMISSION_MAP: Record<string, string | null> = {
|
||||
// Monitors
|
||||
"/(manage)/manage/app/monitors": "monitors.read",
|
||||
"/(manage)/manage/app/monitors/[tag]": "monitors.read",
|
||||
"/(manage)/manage/app/monitoring-data": "monitors.read",
|
||||
|
||||
// Incidents
|
||||
"/(manage)/manage/app/incidents": "incidents.read",
|
||||
"/(manage)/manage/app/incidents/[incident_id]": "incidents.read",
|
||||
|
||||
// Maintenances
|
||||
"/(manage)/manage/app/maintenances": "maintenances.read",
|
||||
"/(manage)/manage/app/maintenances/[id]": "maintenances.read",
|
||||
|
||||
// Pages
|
||||
"/(manage)/manage/app/pages": "pages.read",
|
||||
"/(manage)/manage/app/pages/[page_id]": "pages.read",
|
||||
|
||||
// Triggers
|
||||
"/(manage)/manage/app/triggers": "triggers.read",
|
||||
"/(manage)/manage/app/triggers/[trigger_id]": "triggers.read",
|
||||
|
||||
// Alerts
|
||||
"/(manage)/manage/app/alerts": "alerts.read",
|
||||
"/(manage)/manage/app/alerts/[alert_config_id]": "alerts.read",
|
||||
"/(manage)/manage/app/alerts/logs/[alert_config_id]": "alerts.read",
|
||||
|
||||
// API Keys
|
||||
"/(manage)/manage/app/api-keys": "api_keys.read",
|
||||
|
||||
// Users
|
||||
"/(manage)/manage/app/users": "users.read",
|
||||
|
||||
// Settings
|
||||
"/(manage)/manage/app/site-configurations": "settings.read",
|
||||
"/(manage)/manage/app/customizations": "settings.read",
|
||||
"/(manage)/manage/app/internationalization": "settings.read",
|
||||
"/(manage)/manage/app/analytics-providers": "settings.read",
|
||||
"/(manage)/manage/app/badges": "settings.read",
|
||||
"/(manage)/manage/app/embed": "settings.read",
|
||||
|
||||
// Subscribers
|
||||
"/(manage)/manage/app/subscriptions": "subscribers.read",
|
||||
|
||||
// Email Templates
|
||||
"/(manage)/manage/app/templates": "email_templates.read",
|
||||
|
||||
// Roles
|
||||
"/(manage)/manage/app/roles": "roles.read",
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import type { NotificationEvent } from "$lib/types/notifications.js";
|
||||
|
||||
interface NotificationsResponse {
|
||||
notifications?: NotificationEvent[];
|
||||
}
|
||||
|
||||
export async function requestNotifications(monitorTags: string[] = []): Promise<NotificationEvent[]> {
|
||||
const query = monitorTags.length > 0 ? `?tags=${encodeURIComponent(monitorTags.join(","))}` : "";
|
||||
const response = await fetch(clientResolver(resolve, "/dashboard-apis/notifications") + query);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch notifications");
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as NotificationsResponse;
|
||||
return payload.notifications || [];
|
||||
}
|
||||
@@ -31,3 +31,39 @@ export default function urlResolve(resolve: ResolveFn, path: string, params?: Re
|
||||
}
|
||||
return resolve(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a path to an absolute URL by prefixing the site URL.
|
||||
* Required for meta tags like og:image and twitter:image that need absolute URLs.
|
||||
* @param resolve - The resolve function from $app/paths
|
||||
* @param siteUrl - The site URL (e.g., "https://status.example.com")
|
||||
* @param path - The route path or absolute URL
|
||||
* @param params - Optional parameters for dynamic route segments
|
||||
* @returns An absolute URL, or the resolved relative URL if siteUrl is empty
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* absoluteResolve(resolve, "https://status.example.com", "/uploads/preview.png")
|
||||
* // => "https://status.example.com/uploads/preview.png"
|
||||
* ```
|
||||
*/
|
||||
export function absoluteResolve(
|
||||
resolve: ResolveFn,
|
||||
siteUrl: string,
|
||||
path: string,
|
||||
params?: Record<string, string>
|
||||
): string {
|
||||
// Normalize relative paths like "./assets/..." to "/assets/..." so the
|
||||
// final URL doesn't contain "/./" segments (crawlers don't normalize these)
|
||||
const normalizedPath = path.startsWith("./") ? path.slice(1) : path;
|
||||
const resolved = urlResolve(resolve, normalizedPath, params);
|
||||
// Already absolute, return as-is
|
||||
if (resolved.startsWith("http://") || resolved.startsWith("https://")) {
|
||||
return resolved;
|
||||
}
|
||||
if (!siteUrl) {
|
||||
return resolved;
|
||||
}
|
||||
const trimmedSiteUrl = siteUrl.replace(/\/+$/, "");
|
||||
return trimmedSiteUrl + (resolved.startsWith("/") ? resolved : "/" + resolved);
|
||||
}
|
||||
|
||||
+42
-34
@@ -1,5 +1,5 @@
|
||||
import type { TimestampStatusCount } from "$lib/server/types/db";
|
||||
import { PAGE_STATUS_MESSAGES } from "$lib/global-constants";
|
||||
import GC, { PAGE_STATUS_MESSAGES, type StatusType } from "$lib/global-constants";
|
||||
|
||||
function ParseLatency(latencyMs: number): string {
|
||||
if (!!!latencyMs) {
|
||||
@@ -316,46 +316,53 @@ interface GameItem {
|
||||
function GetGameFromId(list: GameItem[], id: string): GameItem | undefined {
|
||||
return list.find((game: GameItem) => game.id === id);
|
||||
}
|
||||
type StatusCounts = Pick<TimestampStatusCount, "countOfUp" | "countOfDown" | "countOfDegraded" | "countOfMaintenance">;
|
||||
|
||||
// Canonical Overall Status collapse: the worst state wins, and maintenance
|
||||
// never masks an active problem. See docs/adr/0007-problem-first-overall-status.md.
|
||||
function CollapseStatusCounts(counts: StatusCounts): StatusType {
|
||||
const total = counts.countOfUp + counts.countOfDown + counts.countOfDegraded + counts.countOfMaintenance;
|
||||
if (total === 0) return GC.NO_DATA;
|
||||
if (counts.countOfDown > 0) return GC.DOWN;
|
||||
if (counts.countOfDegraded > 0) return GC.DEGRADED;
|
||||
if (counts.countOfMaintenance > 0) return GC.MAINTENANCE;
|
||||
return GC.UP;
|
||||
}
|
||||
|
||||
function GetStatusSummary(item: TimestampStatusCount): string {
|
||||
const total = item.countOfUp + item.countOfDown + item.countOfDegraded + item.countOfMaintenance;
|
||||
if (total === 0) return PAGE_STATUS_MESSAGES.NO_DATA;
|
||||
|
||||
const maintenancePercent = (item.countOfMaintenance / total) * 100;
|
||||
const downPercent = (item.countOfDown / total) * 100;
|
||||
const degradedPercent = (item.countOfDegraded / total) * 100;
|
||||
|
||||
if (maintenancePercent > 0) {
|
||||
return PAGE_STATUS_MESSAGES.UNDER_MAINTENANCE;
|
||||
} else if (downPercent >= 75) {
|
||||
return PAGE_STATUS_MESSAGES.MAJOR_OUTAGE;
|
||||
} else if (downPercent >= 50) {
|
||||
return PAGE_STATUS_MESSAGES.PARTIAL_OUTAGE;
|
||||
} else if (item.countOfDown > 0) {
|
||||
return PAGE_STATUS_MESSAGES.PARTIAL_OUTAGE;
|
||||
} else if (degradedPercent >= 75) {
|
||||
return PAGE_STATUS_MESSAGES.DEGRADED_PERFORMANCE;
|
||||
} else if (degradedPercent >= 50) {
|
||||
return PAGE_STATUS_MESSAGES.PARTIAL_DEGRADED;
|
||||
} else if (item.countOfDegraded > 0) {
|
||||
return PAGE_STATUS_MESSAGES.PARTIAL_DEGRADED;
|
||||
} else if (item.countOfUp === total) {
|
||||
return PAGE_STATUS_MESSAGES.ALL_OPERATIONAL;
|
||||
switch (CollapseStatusCounts(item)) {
|
||||
case GC.DOWN:
|
||||
return (item.countOfDown / total) * 100 >= 75
|
||||
? PAGE_STATUS_MESSAGES.MAJOR_OUTAGE
|
||||
: PAGE_STATUS_MESSAGES.PARTIAL_OUTAGE;
|
||||
case GC.DEGRADED:
|
||||
return (item.countOfDegraded / total) * 100 >= 75
|
||||
? PAGE_STATUS_MESSAGES.DEGRADED_PERFORMANCE
|
||||
: PAGE_STATUS_MESSAGES.PARTIAL_DEGRADED;
|
||||
case GC.MAINTENANCE:
|
||||
return PAGE_STATUS_MESSAGES.UNDER_MAINTENANCE;
|
||||
case GC.UP:
|
||||
return PAGE_STATUS_MESSAGES.ALL_OPERATIONAL;
|
||||
default:
|
||||
return PAGE_STATUS_MESSAGES.NO_DATA;
|
||||
}
|
||||
|
||||
return PAGE_STATUS_MESSAGES.NO_DATA;
|
||||
}
|
||||
|
||||
function GetStatusColor(item: TimestampStatusCount): string {
|
||||
const total = item.countOfUp + item.countOfDown + item.countOfDegraded + item.countOfMaintenance;
|
||||
if (total === 0) return "text-muted-foreground";
|
||||
|
||||
const maintenancePercent = (item.countOfMaintenance / total) * 100;
|
||||
const downPercent = (item.countOfDown / total) * 100;
|
||||
|
||||
if (maintenancePercent > 0) return "text-maintenance";
|
||||
if (downPercent > 0) return "text-down";
|
||||
if (item.countOfDegraded > 0) return "text-degraded";
|
||||
return "text-up";
|
||||
switch (CollapseStatusCounts(item)) {
|
||||
case GC.DOWN:
|
||||
return "text-down";
|
||||
case GC.DEGRADED:
|
||||
return "text-degraded";
|
||||
case GC.MAINTENANCE:
|
||||
return "text-maintenance";
|
||||
case GC.UP:
|
||||
return "text-up";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
function GetStatusBgColor(item: TimestampStatusCount): string {
|
||||
@@ -378,6 +385,7 @@ export {
|
||||
IsValidNameServer,
|
||||
IsValidURL,
|
||||
IsValidPort,
|
||||
CollapseStatusCounts,
|
||||
GetStatusSummary,
|
||||
GetStatusColor,
|
||||
GetStatusBgColor,
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import { GetInitials } from "$lib/clientTools.js";
|
||||
import type { MaintenanceEventsMonitorList } from "$lib/server/types/db";
|
||||
import { SveltePurify } from "@humanspeak/svelte-purify";
|
||||
import mdToHTML from "$lib/marked";
|
||||
import { page } from "$app/state";
|
||||
|
||||
interface Props {
|
||||
@@ -42,9 +44,11 @@
|
||||
</div>
|
||||
|
||||
{#if maintenance.description}
|
||||
<p class="text-muted-foreground mt-1 text-sm">
|
||||
{maintenance.description}
|
||||
</p>
|
||||
<div
|
||||
class="prose prose-sm dark:prose-invert text-muted-foreground mt-1 max-w-none min-w-0 overflow-x-auto text-sm wrap-break-word"
|
||||
>
|
||||
<SveltePurify html={mdToHTML(maintenance.description)} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if maintenance.monitors && maintenance.monitors.length > 0 && !hideMonitors}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import * as Command from "$lib/components/ui/command/index.js";
|
||||
import * as Popover from "$lib/components/ui/popover/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import type { MonitorRecord } from "$lib/server/types/db.js";
|
||||
import CheckIcon from "@lucide/svelte/icons/check";
|
||||
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
|
||||
import ListPlusIcon from "@lucide/svelte/icons/list-plus";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import { resolve } from "$app/paths";
|
||||
|
||||
let {
|
||||
monitors = [],
|
||||
selectedTags = [],
|
||||
onToggle,
|
||||
onAddMany,
|
||||
placeholder = "Search monitors to add..."
|
||||
}: {
|
||||
monitors: MonitorRecord[];
|
||||
selectedTags: string[];
|
||||
onToggle: (tag: string) => void;
|
||||
onAddMany?: (tags: string[]) => void;
|
||||
placeholder?: string;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let search = $state("");
|
||||
|
||||
// Own filtering (shouldFilter={false}) so "Add all matching" counts stay
|
||||
// consistent with what the list shows. Case-insensitive over name + tag.
|
||||
const filteredMonitors = $derived.by(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
if (!query) return monitors;
|
||||
return monitors.filter((m) => m.name.toLowerCase().includes(query) || m.tag.toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
const unselectedMatches = $derived(filteredMonitors.filter((m) => !selectedTags.includes(m.tag)));
|
||||
const showAddAll = $derived(!!search.trim() && unselectedMatches.length > 0 && !!onAddMany);
|
||||
|
||||
function addAllMatching() {
|
||||
onAddMany?.(unselectedMatches.map((m) => m.tag));
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
class="w-full justify-between font-normal"
|
||||
>
|
||||
<span class="text-muted-foreground">{placeholder}</span>
|
||||
<ChevronsUpDownIcon class="text-muted-foreground size-4 shrink-0" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-[var(--bits-popover-trigger-width)] p-0" align="start">
|
||||
<Command.Root shouldFilter={false}>
|
||||
<Command.Input {placeholder} bind:value={search} />
|
||||
<Command.List class="max-h-64">
|
||||
<Command.Empty>No monitors found.</Command.Empty>
|
||||
<Command.Group>
|
||||
{#each filteredMonitors as monitor (monitor.tag)}
|
||||
{@const selected = selectedTags.includes(monitor.tag)}
|
||||
<Command.Item value={monitor.tag} onSelect={() => onToggle(monitor.tag)}>
|
||||
<CheckIcon class="size-4 {selected ? 'opacity-100' : 'opacity-0'}" />
|
||||
{#if monitor.image}
|
||||
<img
|
||||
src={clientResolver(resolve, monitor.image)}
|
||||
alt={monitor.name}
|
||||
class="size-5 rounded object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="bg-muted flex size-5 items-center justify-center rounded text-[10px] font-medium">
|
||||
{monitor.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="truncate">{monitor.name}</span>
|
||||
<span class="text-muted-foreground ml-auto truncate text-xs">{monitor.tag}</span>
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.Group>
|
||||
{#if showAddAll}
|
||||
<Command.Separator />
|
||||
<Command.Group>
|
||||
<Command.Item value="__add-all-matching__" onSelect={addAllMatching}>
|
||||
<ListPlusIcon class="size-4" />
|
||||
Add all {unselectedMatches.length} matching
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
{/if}
|
||||
</Command.List>
|
||||
</Command.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from "$app/paths";
|
||||
import { page } from "$app/state";
|
||||
import { requestNotifications } from "$lib/client/notifications-client.js";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { formatDate, formatDuration } from "$lib/stores/datetime";
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import type { NotificationEvent } from "$lib/types/notifications.js";
|
||||
import Calendar from "@lucide/svelte/icons/calendar-1";
|
||||
import { format } from "date-fns";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface Props {
|
||||
monitorTags?: string[];
|
||||
eventsPath?: string;
|
||||
notifications?: NotificationEvent[];
|
||||
loading?: boolean;
|
||||
fetchOnMount?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
monitorTags = [],
|
||||
eventsPath = "",
|
||||
notifications = $bindable<NotificationEvent[]>([]),
|
||||
loading = $bindable(false),
|
||||
fetchOnMount = true
|
||||
}: Props = $props();
|
||||
|
||||
const defaultEventsPath = $derived(`/events/${format(new Date(), "MMMM-yyyy")}`);
|
||||
|
||||
const resolvedEventsPath = $derived.by(() => {
|
||||
const finalEventsPath = eventsPath || defaultEventsPath;
|
||||
if (page.data?.globalPageVisibilitySettings?.forceExclusivity) {
|
||||
const currentPagePath = page.params?.page_path?.trim();
|
||||
return currentPagePath ? `/${currentPagePath}${finalEventsPath}` : finalEventsPath;
|
||||
}
|
||||
return finalEventsPath;
|
||||
});
|
||||
|
||||
async function fetchNotifications() {
|
||||
loading = true;
|
||||
try {
|
||||
notifications = await requestNotifications(monitorTags);
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (fetchOnMount) {
|
||||
void fetchNotifications();
|
||||
}
|
||||
});
|
||||
|
||||
function getEventId(eventURL: string) {
|
||||
return eventURL.split("/").filter(Boolean).at(-1) || "";
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet notificationItem(item: NotificationEvent)}
|
||||
<div class="my-0.5 flex items-center justify-between gap-2 text-xs">
|
||||
<span class="text-muted-foreground text-[11px] uppercase">{$t(item.eventType)}</span>
|
||||
<span class="text-{item.eventStatus.toLowerCase()}">{$t(item.eventStatus)}</span>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="line-clamp-2 text-sm">{item.eventTitle}</p>
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span>{$formatDate(item.eventDate, page.data.dateAndTimeFormat.datePlusTime)}</span>
|
||||
|
||||
<span>•</span>
|
||||
<span>{$formatDuration(item.eventStartDateTime, item.eventEndDateTime, $t("Ongoing"))}</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h4 class="text-sm font-semibold">{$t("Events")}</h4>
|
||||
<Button
|
||||
variant="outline"
|
||||
href={clientResolver(resolve, resolvedEventsPath)}
|
||||
size="icon-sm"
|
||||
class="rounded-btn"
|
||||
aria-label={$t("Open events page")}
|
||||
title={$t("Open events page")}
|
||||
>
|
||||
<Calendar class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if notifications.length === 0}
|
||||
<div class="text-muted-foreground px-4 py-6 text-center text-sm">
|
||||
{$t("No events to show")}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="scrollbar-hidden max-h-96 overflow-y-auto">
|
||||
{#each notifications as item, i (`${item.eventType}-${item.eventURL}-${item.eventDate}-${i}`)}
|
||||
{@const eventId = getEventId(item.eventURL)}
|
||||
{#if item.eventURL.startsWith("/incidents/")}
|
||||
<a
|
||||
href={resolve("/(kener)/incidents/[incident_id]", { incident_id: eventId })}
|
||||
class="hover:bg-muted/60 block border-b px-4 py-3 last:border-b-0"
|
||||
>
|
||||
{@render notificationItem(item)}
|
||||
</a>
|
||||
{:else}
|
||||
<a
|
||||
href={resolve("/(kener)/maintenances/[maintenance_id]", { maintenance_id: eventId })}
|
||||
class="hover:bg-muted/60 block border-b px-4 py-3 last:border-b-0"
|
||||
>
|
||||
{@render notificationItem(item)}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,22 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from "$app/paths";
|
||||
import { page } from "$app/state";
|
||||
import NotificationsList from "$lib/components/NotificationsList.svelte";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import * as Popover from "$lib/components/ui/popover/index.js";
|
||||
import { Spinner } from "$lib/components/ui/spinner/index.js";
|
||||
import { requestNotifications } from "$lib/client/notifications-client.js";
|
||||
import ICONS from "$lib/icons";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import { formatDate, formatDuration } from "$lib/stores/datetime";
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import type { NotificationEvent } from "$lib/server/controllers/dashboardController.js";
|
||||
import Calendar from "@lucide/svelte/icons/calendar-1";
|
||||
import { format } from "date-fns";
|
||||
import type { NotificationEvent } from "$lib/types/notifications.js";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface Props {
|
||||
monitorTags?: string[];
|
||||
compact?: boolean;
|
||||
eventsPath: string;
|
||||
eventsPath?: string;
|
||||
}
|
||||
|
||||
let { monitorTags = [], compact = true, eventsPath = "" }: Props = $props();
|
||||
@@ -24,26 +20,10 @@
|
||||
let notifications = $state<NotificationEvent[]>([]);
|
||||
let loading = $state(false);
|
||||
|
||||
const defaultEventsPath = $derived(`/events/${format(new Date(), "MMMM-yyyy")}`);
|
||||
|
||||
const resolvedEventsPath = $derived.by(() => {
|
||||
const finalEventsPath = eventsPath || defaultEventsPath;
|
||||
if (page.data?.globalPageVisibilitySettings?.forceExclusivity) {
|
||||
const currentPagePath = page.params?.page_path?.trim();
|
||||
return currentPagePath ? `/${currentPagePath}${finalEventsPath}` : finalEventsPath;
|
||||
}
|
||||
return finalEventsPath;
|
||||
});
|
||||
|
||||
async function fetchNotifications() {
|
||||
loading = true;
|
||||
try {
|
||||
const query = monitorTags.length > 0 ? `?tags=${encodeURIComponent(monitorTags.join(","))}` : "";
|
||||
const response = await fetch(clientResolver(resolve, "/dashboard-apis/notifications") + query);
|
||||
if (response.ok) {
|
||||
const payload = await response.json();
|
||||
notifications = payload.notifications || [];
|
||||
}
|
||||
notifications = await requestNotifications(monitorTags);
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
@@ -52,7 +32,7 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchNotifications();
|
||||
void fetchNotifications();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -87,40 +67,6 @@
|
||||
class="bg-background/30 supports-backdrop-filter:bg-background/20 w-96 rounded-3xl border p-0 shadow-2xl backdrop-blur-2xl"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h4 class="text-sm font-semibold">{$t("Events")}</h4>
|
||||
<Button variant="outline" href={clientResolver(resolve, resolvedEventsPath)} size="icon-sm" class="rounded-btn">
|
||||
<Calendar class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if notifications.length === 0}
|
||||
<div class="text-muted-foreground px-4 py-6 text-sm">
|
||||
{$t("No events to show")}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="scrollbar-hidden max-h-96 overflow-y-auto">
|
||||
{#each notifications as item, i (`${item.eventType}-${item.eventURL}-${item.eventDate}-${i}`)}
|
||||
<a
|
||||
href={clientResolver(resolve, item.eventURL)}
|
||||
class="hover:bg-muted/60 block border-b px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div class="my-0.5 flex items-center justify-between gap-2 text-xs">
|
||||
<span class="text-muted-foreground text-[11px] uppercase">{$t(item.eventType)}</span>
|
||||
<span class="text-{item.eventStatus.toLowerCase()}">{$t(item.eventStatus)}</span>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="line-clamp-2 text-sm">{item.eventTitle}</p>
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span>{$formatDate(item.eventDate, page.data.dateAndTimeFormat.datePlusTime)}</span>
|
||||
|
||||
<span>•</span>
|
||||
<span>{$formatDuration(item.eventStartDateTime, item.eventEndDateTime, $t("Ongoing"))}</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<NotificationsList {monitorTags} {eventsPath} fetchOnMount={false} bind:notifications bind:loading />
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import Sun from "@lucide/svelte/icons/sun";
|
||||
import Moon from "@lucide/svelte/icons/moon";
|
||||
import Share from "@lucide/svelte/icons/share-2";
|
||||
import Rss from "@lucide/svelte/icons/rss";
|
||||
import { format } from "date-fns";
|
||||
import SubscribeMenu from "$lib/components/SubscribeMenu.svelte";
|
||||
import CopyButton from "$lib/components/CopyButton.svelte";
|
||||
@@ -26,14 +27,23 @@
|
||||
interface Props {
|
||||
monitor_tags?: string[];
|
||||
embedMonitorTag?: string;
|
||||
hideNotificationsPopover?: boolean;
|
||||
}
|
||||
|
||||
let { monitor_tags = [], embedMonitorTag = "" }: Props = $props();
|
||||
let { monitor_tags = [], embedMonitorTag = "", hideNotificationsPopover = false }: Props = $props();
|
||||
|
||||
let protocol = $state("");
|
||||
let domain = $state("");
|
||||
let shareLink = $state("");
|
||||
const eventsPath = $derived(`/events/${format(new Date(), "MMMM-yyyy")}`);
|
||||
const showNotificationsPopover = $derived(!hideNotificationsPopover);
|
||||
|
||||
const rssHref = $derived.by(() => {
|
||||
const params = page.params;
|
||||
if (params.monitor_tag) return clientResolver(resolve, `/monitors/${params.monitor_tag}/rss.xml`);
|
||||
if (params.page_path) return clientResolver(resolve, `/${params.page_path}/rss.xml`);
|
||||
return clientResolver(resolve, "/rss.xml");
|
||||
});
|
||||
|
||||
const loginDetails = $derived.by((): { label: string; url: string } | null => {
|
||||
if (!page.data?.loggedInUser) return null;
|
||||
@@ -95,6 +105,24 @@
|
||||
</ButtonGroup.Root>
|
||||
{/if}
|
||||
|
||||
{#if page.data.subMenuOptions?.showRssFeed !== false}
|
||||
<ButtonGroup.Root class="rounded-btn-grp shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
href={rssHref}
|
||||
target="_blank"
|
||||
rel="alternate"
|
||||
aria-label={$t("RSS feed")}
|
||||
title={$t("RSS feed")}
|
||||
class="bg-background/80 dark:bg-background/70 border-foreground/10 cursor-pointer rounded-full border shadow-none backdrop-blur-md"
|
||||
onclick={() => trackEvent("rss_opened", { source: "theme_plus" })}
|
||||
>
|
||||
<Rss />
|
||||
</Button>
|
||||
</ButtonGroup.Root>
|
||||
{/if}
|
||||
|
||||
<ButtonGroup.Root class="rounded-btn-grp shrink-0">
|
||||
<CopyButton
|
||||
variant="outline"
|
||||
@@ -157,7 +185,9 @@
|
||||
<TimezoneSelector />
|
||||
{/if}
|
||||
</ButtonGroup.Root>
|
||||
<NotificationsPopover {eventsPath} monitorTags={monitor_tags} compact={true} />
|
||||
{#if showNotificationsPopover}
|
||||
<NotificationsPopover {eventsPath} monitorTags={monitor_tags} compact={true} />
|
||||
{/if}
|
||||
{#if loginDetails}
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -69,6 +69,18 @@ export default {
|
||||
STATUS: "STATUS",
|
||||
LATENCY: "LATENCY",
|
||||
UPTIME: "UPTIME",
|
||||
// Special path segment addressing the home page in the v4 API; its stored
|
||||
// page_path is an empty string. See docs/adr/0004-home-page-api-token.md.
|
||||
HOME_PAGE_TOKEN: "~home",
|
||||
// Status history window (days of per-day status shown), shared by pages and
|
||||
// monitors, the manage UI, the public pages, and the v4 API
|
||||
DEFAULT_STATUS_HISTORY_DAYS_DESKTOP: 90,
|
||||
DEFAULT_STATUS_HISTORY_DAYS_MOBILE: 30,
|
||||
STATUS_HISTORY_DAYS_MIN: 1,
|
||||
STATUS_HISTORY_DAYS_MAX: 365,
|
||||
// Monitor layout styles available on status pages
|
||||
MONITOR_LAYOUT_STYLES: ["default-list", "default-grid", "compact-list", "compact-grid"],
|
||||
DEFAULT_MONITOR_LAYOUT_STYLE: "default-list",
|
||||
DOCS_URL: "https://kener.ing/docs",
|
||||
MAX_UPLOAD_BYTES: 2 * 1024 * 1024, // 2MB
|
||||
MAX_IMAGE_DIMENSION: 4096,
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
"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",
|
||||
|
||||
+23
-22
@@ -5,12 +5,12 @@
|
||||
"Affected Monitors (%count)": "Betroffene Monitore (%count)",
|
||||
"All Systems Operational": "Alle Systeme betriebsbereit",
|
||||
"Average Latency": "Durchschnittliche Latenz",
|
||||
"Avg Latency": "Durchschnittliche Latenz",
|
||||
"Avg Latency": "Durchschn. Latenz",
|
||||
"Back": "Zurück",
|
||||
"Badges": "Abzeichen",
|
||||
"Badges": "Anzeigen",
|
||||
"CANCELLED": "ABGESAGT",
|
||||
"COMPLETED": "ABGESCHLOSSEN",
|
||||
"Continue": "Weitermachen",
|
||||
"Continue": "Fortsetzen",
|
||||
"Copied": "Kopiert",
|
||||
"Current": "Aktuell",
|
||||
"Dark": "Dunkel",
|
||||
@@ -20,7 +20,7 @@
|
||||
"Degraded": "Beeinträchtigt",
|
||||
"DEGRADED": "BEEINTRÄCHTIGT",
|
||||
"Degraded Performance": "Beeinträchtigte Leistung",
|
||||
"Didn't receive the code? Resend": "Sie haben den Code nicht erhalten? ",
|
||||
"Didn't receive the code? Resend": "Sie haben den Code nicht erhalten? Erneut senden",
|
||||
"Down": "Ausgefallen",
|
||||
"DOWN": "AUSGEFALLEN",
|
||||
"Duration": "Dauer",
|
||||
@@ -29,13 +29,13 @@
|
||||
"Embed this monitor in your website or app": "Betten Sie diesen Monitor in Ihre Website oder App ein",
|
||||
"End Time": "Endzeit",
|
||||
"Enter the verification code sent to your email.": "Geben Sie den Bestätigungscode ein, der an Ihre E-Mail-Adresse gesendet wurde.",
|
||||
"Events": "Veranstaltungen",
|
||||
"Events": "Ereignisse",
|
||||
"Failed to load data": "Daten konnten nicht geladen werden",
|
||||
"Failed to load latency data": "Latenzdaten konnten nicht geladen werden",
|
||||
"Failed to load status data for this day": "Statusdaten für diesen Tag konnten nicht geladen werden",
|
||||
"Failed to send verification code": "Der Bestätigungscode konnte nicht gesendet werden",
|
||||
"Failed to update preference": "Die Präferenz konnte nicht aktualisiert werden",
|
||||
"Get badges for this monitor": "Erhalten Sie Abzeichen für diesen Monitor",
|
||||
"Get badges for this monitor": "Erhalten Sie Statusanzeigen für diesen Monitor",
|
||||
"Get notified about incidents and scheduled maintenance.": "Lassen Sie sich über Vorfälle und geplante Wartungsarbeiten benachrichtigen.",
|
||||
"Get notified about incidents updates": "Lassen Sie sich über Aktualisierungen von Vorfällen benachrichtigen",
|
||||
"Get notified about scheduled maintenance": "Lassen Sie sich über geplante Wartungsarbeiten benachrichtigen",
|
||||
@@ -43,7 +43,7 @@
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Auswirkungen",
|
||||
"incident": "Vorfall",
|
||||
"Incident Updates": "Vorfallaktualisierungen",
|
||||
"Incident Updates": "Vorfallsaktualisierungen",
|
||||
"Incidents": "Vorfälle",
|
||||
"Included Monitors (%count)": "Enthaltene Monitore (%count)",
|
||||
"INVESTIGATING": "WIRD UNTERSUCHT",
|
||||
@@ -64,13 +64,13 @@
|
||||
"Major System Outage": "Schwerwiegender Systemausfall",
|
||||
"Manage Site": "Seite verwalten",
|
||||
"Manage your notification preferences.": "Verwalten Sie Ihre Benachrichtigungseinstellungen.",
|
||||
"Max Latency": "Maximale Latenz",
|
||||
"Max Latency": "Max. Latenz",
|
||||
"Maximum Latency": "Maximale Latenz",
|
||||
"Min Latency": "Min. Latenz",
|
||||
"Minimum Latency": "Min. Latenz",
|
||||
"Minimum Latency": "Minimale Latenz",
|
||||
"Minute-by-minute status data for this day": "Minutenweise Statusdaten für diesen Tag",
|
||||
"MONITORING": "WIRD ÜBERWACHT",
|
||||
"Network error. Please try again.": "Netzwerkfehler. ",
|
||||
"Network error. Please try again.": "Netzwerkfehler. Bitte erneut versuchen.",
|
||||
"No Events in %currentMonth": "Keine Ereignisse in %currentMonth",
|
||||
"No events to show": "Keine Ereignisse zum Anzeigen",
|
||||
"No incidents for this day": "Keine Vorfälle für diesen Tag",
|
||||
@@ -83,7 +83,7 @@
|
||||
"No Status Available": "Kein Status verfügbar",
|
||||
"No upcoming maintenances": "Keine bevorstehenden Wartungsarbeiten",
|
||||
"No Updates": "Keine Aktualisierungen",
|
||||
"No updates yet": "Noch keine Updates",
|
||||
"No updates yet": "Noch keine Aktualisierungen",
|
||||
"Notifications": "Benachrichtigungen",
|
||||
"One-time": "Einmalig",
|
||||
"Ongoing": "Laufend",
|
||||
@@ -100,38 +100,39 @@
|
||||
"READY": "BEREIT",
|
||||
"Recurring": "Wiederkehrend",
|
||||
"RESOLVED": "BEHOBEN",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "GEPLANT",
|
||||
"Scheduled Events (%count)": "Geplante Ereignisse (%count)",
|
||||
"Script": "Skript",
|
||||
"Select Language": "Wählen Sie Sprache aus",
|
||||
"Select latency metric to display": "Latenzmetrik zur Anzeige auswählen",
|
||||
"Select Range": "Wählen Sie Bereich aus",
|
||||
"Select Language": "Sprache auswählen",
|
||||
"Select latency metric to display": "Anzuzeigende Latenzmetrik auswählen",
|
||||
"Select Range": "Bereich auswählen",
|
||||
"Sending...": "Senden...",
|
||||
"Standard": "Standard",
|
||||
"Start Time": "Startzeit",
|
||||
"Status": "Status",
|
||||
"Status Badge": "Statusabzeichen",
|
||||
"Status Badge": "Statusanzeige",
|
||||
"Status Embed": "Status einbetten",
|
||||
"Status history and latency trend": "Statusverlauf und Latenztrend",
|
||||
"Subscribe": "Abonnieren",
|
||||
"Subscribe to Updates": "Benachrichtigungen erhalten",
|
||||
"Subscribe to Updates": "Benachrichtigungen abonnieren",
|
||||
"There are no incidents or maintenances scheduled for this month.": "Für diesen Monat sind keine Vorfälle oder Wartungsarbeiten geplant.",
|
||||
"There are no ongoing incidents or maintenance events.": "Es gibt keine laufenden Vorfälle oder Wartungsereignisse.",
|
||||
"There are no ongoing incidents or maintenance events.": "Es gibt keine laufenden Vorfälle oder Wartungsarbeiten.",
|
||||
"Total Incidents": "Gesamtzahl der Vorfälle",
|
||||
"Total Maintenances": "Gesamtwartungen",
|
||||
"Total Maintenances": "Gesamtzahl der Wartungen",
|
||||
"Under Maintenance": "Unter Wartung",
|
||||
"Unknown impact": "Unbekannte Auswirkung",
|
||||
"UP": "AKTIV",
|
||||
"Upcoming": "Demnächst",
|
||||
"Upcoming": "Anstehend",
|
||||
"Update Incident": "Vorfall aktualisieren",
|
||||
"Update Maintenance": "Wartung aktualisieren",
|
||||
"Updates": "Aktualisierungen",
|
||||
"Updates (%count)": "Aktualisierungen (%count)",
|
||||
"Uptime": "Betriebszeit",
|
||||
"Uptime Badge": "Verfügbarkeitsabzeichen",
|
||||
"Verification failed": "Die Überprüfung ist fehlgeschlagen",
|
||||
"Uptime Badge": "Verfügbarkeitsanzeige",
|
||||
"Verification failed": "Überprüfung fehlgeschlagen",
|
||||
"Verify": "Verifizieren",
|
||||
"Verifying": "Verifizieren",
|
||||
"We sent a 6-digit code to": "Wir haben einen 6-stelligen Code an gesendet"
|
||||
"We sent a 6-digit code to": "Wir haben einen 6-stelligen Code gesendet an"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "KLAR",
|
||||
"Recurring": "Tilbagevendende",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "PLANLAGT",
|
||||
"Scheduled Events (%count)": "Planlagte begivenheder (%count)",
|
||||
"Script": "Manuskript",
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"Notifications": "Notifications",
|
||||
"One-time": "One-time",
|
||||
"Ongoing": "Ongoing",
|
||||
"Open events page": "Open events page",
|
||||
"Operational": "Operational",
|
||||
"Partial Degraded Performance": "Partial Degraded Performance",
|
||||
"Partial System Outage": "Partial System Outage",
|
||||
@@ -100,6 +101,7 @@
|
||||
"READY": "READY",
|
||||
"Recurring": "Recurring",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "SCHEDULED",
|
||||
"Scheduled Events (%count)": "Scheduled Events (%count)",
|
||||
"Script": "Script",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "LISTO",
|
||||
"Recurring": "Recurrente",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "PROGRAMADO",
|
||||
"Scheduled Events (%count)": "Eventos programados (%count)",
|
||||
"Script": "Guion",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "آماده",
|
||||
"Recurring": "دورهای",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "زمانبندی شده",
|
||||
"Scheduled Events (%count)": "رویدادهای زمانبندی شده (%count)",
|
||||
"Script": "اسکریپت",
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
"mappings": {
|
||||
"%latency %metric latency": "%latency %metric latence",
|
||||
"Affected Monitors (%count)": "Moniteurs concernés (%count)",
|
||||
"All Systems Operational": "Tous les systèmes opérationnels",
|
||||
"All Systems Operational": "Tous les systèmes sont opérationnels",
|
||||
"Average Latency": "Latence moyenne",
|
||||
"Avg Latency": "Latence moyenne",
|
||||
"Back": "Dos",
|
||||
"Badges": "Insignes",
|
||||
"Back": "Retour",
|
||||
"Badges": "Badges",
|
||||
"CANCELLED": "ANNULÉ",
|
||||
"COMPLETED": "TERMINÉ",
|
||||
"Continue": "Continuer",
|
||||
@@ -93,13 +93,14 @@
|
||||
"Past": "Passé",
|
||||
"Per-Minute Status": "Statut par minute",
|
||||
"Pinging": "Ping",
|
||||
"Please enter a valid email address": "S'il vous plaît, mettez une adresse email valide",
|
||||
"Please enter a valid email address": "Veuillez renseigner une adresse email valide",
|
||||
"Please enter the 6-digit verification code": "Veuillez saisir le code de vérification à 6 chiffres",
|
||||
"Read less": "Lire moins",
|
||||
"Read more": "En savoir plus",
|
||||
"READY": "PRÊT",
|
||||
"Recurring": "Récurrent",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "PLANIFIÉ",
|
||||
"Scheduled Events (%count)": "Événements planifiés (%count)",
|
||||
"Script": "Scénario",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "तैयार",
|
||||
"Recurring": "आवर्ती",
|
||||
"RESOLVED": "सुलझा हुआ",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "अनुसूचित",
|
||||
"Scheduled Events (%count)": "अनुसूचित कार्यक्रम (%count)",
|
||||
"Script": "स्क्रिप्ट",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "PRONTO",
|
||||
"Recurring": "Ricorrente",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "PROGRAMMATO",
|
||||
"Scheduled Events (%count)": "Eventi programmati (%count)",
|
||||
"Script": "Copione",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "準備完了",
|
||||
"Recurring": "繰り返し",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "予定済み",
|
||||
"Scheduled Events (%count)": "予定イベント (%count)",
|
||||
"Script": "スクリプト",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "준비됨",
|
||||
"Recurring": "반복",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "예정됨",
|
||||
"Scheduled Events (%count)": "예정된 이벤트 (%count)",
|
||||
"Script": "스크립트",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "KLAR",
|
||||
"Recurring": "Gjentakende",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "PLANLAGT",
|
||||
"Scheduled Events (%count)": "Planlagte hendelser (%count)",
|
||||
"Script": "Manus",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "GEREED",
|
||||
"Recurring": "Terugkerend",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "GEPLAND",
|
||||
"Scheduled Events (%count)": "Geplande evenementen (%count)",
|
||||
"Script": "Script",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "GOTOWE",
|
||||
"Recurring": "Cykliczne",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "ZAPLANOWANE",
|
||||
"Scheduled Events (%count)": "Zaplanowane wydarzenia (%count)",
|
||||
"Script": "Scenariusz",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "PRONTO",
|
||||
"Recurring": "Recorrente",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "AGENDADO",
|
||||
"Scheduled Events (%count)": "Eventos agendados (%count)",
|
||||
"Script": "Roteiro",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"READY": "ГОТОВО",
|
||||
"Recurring": "Повторяющийся",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "ЗАПЛАНИРОВАНО",
|
||||
"Scheduled Events (%count)": "Запланированные события (%count)",
|
||||
"Script": "Скрипт",
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
"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",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "HAZIR",
|
||||
"Recurring": "Tekrarlayan",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "PLANLANMIŞ",
|
||||
"Scheduled Events (%count)": "Planlanan etkinlikler (%count)",
|
||||
"Script": "Senaryo",
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"name": "Українська",
|
||||
"code": "uk",
|
||||
"mappings": {
|
||||
"%latency %metric latency": "%latency %metric latency",
|
||||
"Affected Monitors (%count)": "Затронуті монітори (%count)",
|
||||
"All Systems Operational": "Усі системи працюють",
|
||||
"Average Latency": "Середня затримка",
|
||||
"Avg Latency": "Сер. затримка",
|
||||
"Back": "Назад",
|
||||
"Badges": "Бейджі",
|
||||
"CANCELLED": "СКАСОВАНО",
|
||||
"COMPLETED": "ЗАВЕРШЕНО",
|
||||
"Continue": "Продовжити",
|
||||
"Copied": "Скопійовано",
|
||||
"Current": "Поточні",
|
||||
"Dark": "Темна",
|
||||
"Day": "День",
|
||||
"Day Uptime": "Час роботи за день",
|
||||
"Days": "Дні",
|
||||
"Degraded": "Погіршення",
|
||||
"DEGRADED": "ПОГІРШЕННЯ",
|
||||
"Degraded Performance": "Зниження продуктивності",
|
||||
"Didn't receive the code? Resend": "Не отримали код? Надіслати повторно",
|
||||
"Down": "Недоступний",
|
||||
"DOWN": "НЕ ПРАЦЮЄ",
|
||||
"Duration": "Тривалість",
|
||||
"Email address": "Адреса електронної пошти",
|
||||
"Embed Monitor": "Вбудувати монітор",
|
||||
"Embed this monitor in your website or app": "Вбудуйте цей монітор у свій сайт або застосунок",
|
||||
"End Time": "Час завершення",
|
||||
"Enter the verification code sent to your email.": "Введіть код підтвердження, надісланий на вашу пошту.",
|
||||
"Events": "Події",
|
||||
"Failed to load data": "Не вдалося завантажити дані",
|
||||
"Failed to load latency data": "Не вдалося завантажити дані затримки",
|
||||
"Failed to load status data for this day": "Не вдалося завантажити дані статусу за цей день",
|
||||
"Failed to send verification code": "Не вдалося надіслати код підтвердження",
|
||||
"Failed to update preference": "Не вдалося оновити налаштування",
|
||||
"Get badges for this monitor": "Отримати бейджі для цього монітора",
|
||||
"Get notified about incidents and scheduled maintenance.": "Отримуйте сповіщення про інциденти та планове обслуговування",
|
||||
"Get notified about incidents updates": "Отримуйте сповіщення про оновлення інцидентів",
|
||||
"Get notified about scheduled maintenance": "Отримуйте сповіщення про планове обслуговування",
|
||||
"IDENTIFIED": "ВИЗНАЧЕНО",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Вплив",
|
||||
"incident": "інцидент",
|
||||
"Incident Updates": "Оновлення інцидентів",
|
||||
"Incidents": "Інциденти",
|
||||
"Included Monitors (%count)": "Включені монітори (%count)",
|
||||
"INVESTIGATING": "ДОСЛІДЖЕННЯ",
|
||||
"Last Updated": "Останнє оновлення",
|
||||
"Latency": "Затримка",
|
||||
"Latency Embed": "Вбудована затримка",
|
||||
"Latency Over Time": "Затримка з часом",
|
||||
"Latency Trend": "Тренд затримки",
|
||||
"Latest Latency": "Остання затримка",
|
||||
"Latest Status": "Останній статус",
|
||||
"Light": "Світла",
|
||||
"Live Status": "Статус у реальному часі",
|
||||
"Loading your preferences...": "Завантаження налаштувань...",
|
||||
"maintenance": "обслуговування",
|
||||
"MAINTENANCE": "ОБСЛУГОВУВАННЯ",
|
||||
"Maintenance Updates": "Оновлення обслуговування",
|
||||
"Maintenances": "Обслуговування",
|
||||
"Major System Outage": "Критичний збій системи",
|
||||
"Manage Site": "Керування сайтом",
|
||||
"Manage your notification preferences.": "Керуйте налаштуваннями сповіщень",
|
||||
"Max Latency": "Макс. затримка",
|
||||
"Maximum Latency": "Максимальна затримка",
|
||||
"Min Latency": "Мін. затримка",
|
||||
"Minimum Latency": "Мінімальна затримка",
|
||||
"Minute-by-minute status data for this day": "Похвилинні дані статусу за цей день",
|
||||
"MONITORING": "МОНІТОРИНГ",
|
||||
"Network error. Please try again.": "Помилка мережі. Спробуйте ще раз",
|
||||
"No Events in %currentMonth": "Немає подій у %currentMonth",
|
||||
"No events to show": "Немає подій для відображення",
|
||||
"No incidents for this day": "Немає інцидентів за цей день",
|
||||
"No latency data available for this day": "Немає даних про затримку за цей день",
|
||||
"No maintenances for this day": "Немає обслуговування за цей день",
|
||||
"No monitors affected": "Жоден монітор не зачеплений",
|
||||
"No monitors available.": "Немає доступних моніторів",
|
||||
"No ongoing maintenances": "Немає поточного обслуговування",
|
||||
"No past maintenances": "Немає минулого обслуговування",
|
||||
"No Status Available": "Статус недоступний",
|
||||
"No upcoming maintenances": "Немає запланованого обслуговування",
|
||||
"No Updates": "Немає оновлень",
|
||||
"No updates yet": "Оновлень поки немає",
|
||||
"Notifications": "Сповіщення",
|
||||
"One-time": "Одноразове",
|
||||
"Ongoing": "Поточні",
|
||||
"Operational": "Працює",
|
||||
"Partial Degraded Performance": "Часткове зниження продуктивності",
|
||||
"Partial System Outage": "Частковий збій системи",
|
||||
"Past": "Минулі",
|
||||
"Per-Minute Status": "Похвилинний статус",
|
||||
"Pinging": "Перевірка доступності",
|
||||
"Please enter a valid email address": "Будь ласка, введіть дійсну електронну адресу",
|
||||
"Please enter the 6-digit verification code": "Будь ласка, введіть 6-значний код підтвердження",
|
||||
"Read less": "Згорнути",
|
||||
"Read more": "Читати більше",
|
||||
"READY": "ГОТОВО",
|
||||
"Recurring": "Повторюване",
|
||||
"RESOLVED": "ВИРІШЕНО",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "ЗАПЛАНОВАНО",
|
||||
"Scheduled Events (%count)": "Заплановані події (%count)",
|
||||
"Script": "Скрипт",
|
||||
"Select Language": "Оберіть мову",
|
||||
"Select latency metric to display": "Оберіть метрику затримки для відображення",
|
||||
"Select Range": "Оберіть діапазон",
|
||||
"Sending...": "Надсилання...",
|
||||
"Standard": "Стандартний",
|
||||
"Start Time": "Час початку",
|
||||
"Status": "Статус",
|
||||
"Status Badge": "Бейдж статусу",
|
||||
"Status Embed": "Вбудований статус",
|
||||
"Status history and latency trend": "Історія статусів та тренд затримки",
|
||||
"Subscribe": "Підписатися",
|
||||
"Subscribe to Updates": "Підписатися на оновлення",
|
||||
"There are no incidents or maintenances scheduled for this month.": "На цей місяць не заплановано інцидентів або обслуговування",
|
||||
"There are no ongoing incidents or maintenance events.": "Наразі немає активних інцидентів або обслуговування",
|
||||
"Total Incidents": "Загальна кількість інцидентів",
|
||||
"Total Maintenances": "Загальна кількість обслуговувань",
|
||||
"Under Maintenance": "На обслуговуванні",
|
||||
"Unknown impact": "Невідомий вплив",
|
||||
"UP": "ПРАЦЮЄ",
|
||||
"Upcoming": "Майбутні",
|
||||
"Update Incident": "Оновити інцидент",
|
||||
"Update Maintenance": "Оновити обслуговування",
|
||||
"Updates": "Оновлення",
|
||||
"Updates (%count)": "Оновлення (%count)",
|
||||
"Uptime": "Час роботи",
|
||||
"Uptime Badge": "Бейдж часу роботи",
|
||||
"Verification failed": "Перевірка не вдалася",
|
||||
"Verify": "Підтвердити",
|
||||
"Verifying": "Перевірка",
|
||||
"We sent a 6-digit code to": "Ми надіслали 6-значний код на"
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,7 @@
|
||||
"READY": "SẴN SÀNG",
|
||||
"Recurring": "Định kỳ",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "ĐÃ LÊN LỊCH",
|
||||
"Scheduled Events (%count)": "Sự kiện đã lên lịch (%count)",
|
||||
"Script": "Kịch bản",
|
||||
|
||||
+40
-39
@@ -2,65 +2,65 @@
|
||||
"name": "简体中文",
|
||||
"mappings": {
|
||||
"%latency %metric latency": "%latency %metric 延迟",
|
||||
"Affected Monitors (%count)": "受影响的监视器 (%count)",
|
||||
"Affected Monitors (%count)": "受影响的监控项 (%count)",
|
||||
"All Systems Operational": "所有系统运行正常",
|
||||
"Average Latency": "平均延迟",
|
||||
"Avg Latency": "平均延迟",
|
||||
"Back": "后退",
|
||||
"Back": "返回",
|
||||
"Badges": "徽章",
|
||||
"CANCELLED": "已取消",
|
||||
"COMPLETED": "已完成",
|
||||
"Continue": "继续",
|
||||
"Copied": "已复制",
|
||||
"Current": "当前",
|
||||
"Dark": "黑暗的",
|
||||
"Dark": "深色模式",
|
||||
"Day": "天",
|
||||
"Day Uptime": "日间正常运行时间",
|
||||
"Day Uptime": "今日正常运行时长",
|
||||
"Days": "天",
|
||||
"Degraded": "降级",
|
||||
"DEGRADED": "降级",
|
||||
"Degraded": "系统降级",
|
||||
"DEGRADED": "系统降级",
|
||||
"Degraded Performance": "性能下降",
|
||||
"Didn't receive the code? Resend": "没有收到代码?",
|
||||
"Didn't receive the code? Resend": "没有收到代码?重新发送",
|
||||
"Down": "宕机",
|
||||
"DOWN": "故障",
|
||||
"Duration": "期间",
|
||||
"Duration": "持续时间",
|
||||
"Email address": "电子邮件",
|
||||
"Embed Monitor": "嵌入监视器",
|
||||
"Embed this monitor in your website or app": "将此监视器嵌入您的网站或应用程序中",
|
||||
"Embed Monitor": "嵌入监控项",
|
||||
"Embed this monitor in your website or app": "将此监控项嵌入您的网站或应用程序中",
|
||||
"End Time": "结束时间",
|
||||
"Enter the verification code sent to your email.": "输入发送到您的电子邮件的验证码。",
|
||||
"Events": "活动",
|
||||
"Events": "动态",
|
||||
"Failed to load data": "加载数据失败",
|
||||
"Failed to load latency data": "加载延迟数据失败",
|
||||
"Failed to load status data for this day": "无法加载当天的状态数据",
|
||||
"Failed to send verification code": "发送验证码失败",
|
||||
"Failed to update preference": "无法更新偏好设置",
|
||||
"Get badges for this monitor": "获取此显示器的徽章",
|
||||
"Get badges for this monitor": "获取此监控项的徽章",
|
||||
"Get notified about incidents and scheduled maintenance.": "获取有关事件和定期维护的通知。",
|
||||
"Get notified about incidents updates": "获取有关事件更新的通知",
|
||||
"Get notified about scheduled maintenance": "获取有关定期维护的通知",
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"IDENTIFIED": "已确认",
|
||||
"iFrame": "框架",
|
||||
"Impact": "影响",
|
||||
"incident": "事件",
|
||||
"Incident Updates": "事件更新",
|
||||
"Incidents": "事件",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Included Monitors (%count)": "包括的监控项 (%count)",
|
||||
"INVESTIGATING": "调查中",
|
||||
"Last Updated": "最后更新",
|
||||
"Latency": "延迟",
|
||||
"Latency Embed": "延迟嵌入",
|
||||
"Latency Over Time": "随着时间的推移延迟",
|
||||
"Latency Embed": "嵌入延迟",
|
||||
"Latency Over Time": "历史延迟",
|
||||
"Latency Trend": "延迟趋势",
|
||||
"Latest Latency": "最新延迟时间",
|
||||
"Latest Status": "最新状态",
|
||||
"Light": "光",
|
||||
"Light": "浅色模式",
|
||||
"Live Status": "实时状态",
|
||||
"Loading your preferences...": "正在加载您的偏好设置...",
|
||||
"maintenance": "维护",
|
||||
"MAINTENANCE": "维护中",
|
||||
"Maintenance Updates": "维护更新",
|
||||
"Maintenances": "维护保养",
|
||||
"Maintenances": "例行维护",
|
||||
"Major System Outage": "重大系统故障",
|
||||
"Manage Site": "管理站点",
|
||||
"Manage your notification preferences.": "管理您的通知首选项。",
|
||||
@@ -69,38 +69,39 @@
|
||||
"Min Latency": "最短延迟",
|
||||
"Minimum Latency": "最短延迟",
|
||||
"Minute-by-minute status data for this day": "当日每分钟的状态数据",
|
||||
"MONITORING": "MONITORING",
|
||||
"Network error. Please try again.": "网络错误。",
|
||||
"MONITORING": "监视中",
|
||||
"Network error. Please try again.": "网络错误。请稍后再试。",
|
||||
"No Events in %currentMonth": "%currentMonth 没有活动",
|
||||
"No events to show": "没有可显示的事件",
|
||||
"No incidents for this day": "这一天没有发生任何事件",
|
||||
"No latency data available for this day": "当天没有可用的延迟数据",
|
||||
"No maintenances for this day": "今日无维护",
|
||||
"No monitors affected": "没有显示器受到影响",
|
||||
"No maintenances for this day": "这一天无维护",
|
||||
"No monitors affected": "没有监控项受到影响",
|
||||
"No monitors available.": "没有可用的监控项。",
|
||||
"No ongoing maintenances": "无需持续维护",
|
||||
"No ongoing maintenances": "没有需要持续的维护",
|
||||
"No past maintenances": "过去没有维护过",
|
||||
"No Status Available": "无可用状态",
|
||||
"No upcoming maintenances": "没有即将进行的维护",
|
||||
"No Updates": "没有更新",
|
||||
"No updates yet": "还没有更新",
|
||||
"No updates yet": "暂时没有更新",
|
||||
"Notifications": "通知",
|
||||
"One-time": "一度",
|
||||
"One-time": "单次",
|
||||
"Ongoing": "进行中",
|
||||
"Operational": "正常运行",
|
||||
"Partial Degraded Performance": "部分性能下降",
|
||||
"Partial System Outage": "部分系统故障.",
|
||||
"Partial System Outage": "部分系统故障",
|
||||
"Past": "过去的",
|
||||
"Per-Minute Status": "每分钟状态",
|
||||
"Pinging": "pinging",
|
||||
"Pinging": "检测中",
|
||||
"Please enter a valid email address": "请输入有效的电子邮件地址",
|
||||
"Please enter the 6-digit verification code": "请输入6位验证码",
|
||||
"Read less": "少读书",
|
||||
"Read more": "阅读更多",
|
||||
"Read less": "收起",
|
||||
"Read more": "更多",
|
||||
"READY": "就绪",
|
||||
"Recurring": "周期性",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"SCHEDULED": "已安排",
|
||||
"RESOLVED": "已解决",
|
||||
"RSS feed": "RSS 订阅源",
|
||||
"SCHEDULED": "已计划",
|
||||
"Scheduled Events (%count)": "计划事件 (%count)",
|
||||
"Script": "脚本",
|
||||
"Select Language": "选择语言",
|
||||
@@ -109,20 +110,20 @@
|
||||
"Sending...": "正在发送...",
|
||||
"Standard": "标准",
|
||||
"Start Time": "开始时间",
|
||||
"Status": "地位",
|
||||
"Status": "状态",
|
||||
"Status Badge": "状态徽章",
|
||||
"Status Embed": "状态嵌入",
|
||||
"Status history and latency trend": "状态历史和延迟趋势",
|
||||
"Subscribe": "订阅",
|
||||
"Subscribe to Updates": "订阅更新",
|
||||
"There are no incidents or maintenances scheduled for this month.": "本月没有安排任何事故或维护。",
|
||||
"There are no ongoing incidents or maintenance events.": "当前没有正在进行的事件或维护活动。",
|
||||
"Total Incidents": "事故总数",
|
||||
"Total Maintenances": "全面维护",
|
||||
"There are no incidents or maintenances scheduled for this month.": "本月没有任何事故或安排的维护。",
|
||||
"There are no ongoing incidents or maintenance events.": "当前没有正在进行的事件或维护。",
|
||||
"Total Incidents": "事件总数",
|
||||
"Total Maintenances": "维护总数",
|
||||
"Under Maintenance": "维护中",
|
||||
"Unknown impact": "未知影响",
|
||||
"UP": "正常",
|
||||
"Upcoming": "即将推出",
|
||||
"Upcoming": "即将进行",
|
||||
"Update Incident": "更新事件",
|
||||
"Update Maintenance": "更新维护",
|
||||
"Updates": "更新",
|
||||
@@ -130,7 +131,7 @@
|
||||
"Uptime": "正常运行时间",
|
||||
"Uptime Badge": "正常运行时间徽章",
|
||||
"Verification failed": "验证失败",
|
||||
"Verify": "核实",
|
||||
"Verify": "验证",
|
||||
"Verifying": "正在验证",
|
||||
"We sent a 6-digit code to": "我们发送了一个 6 位代码至"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"name": "繁體中文(香港)",
|
||||
"mappings": {
|
||||
"%latency %metric latency": "%latency %metric 延遲",
|
||||
"Affected Monitors (%count)": "受影響的監控項 (%count)",
|
||||
"All Systems Operational": "所有系統運行正常",
|
||||
"Average Latency": "平均延遲",
|
||||
"Avg Latency": "平均延遲",
|
||||
"Back": "返回",
|
||||
"Badges": "徽章",
|
||||
"CANCELLED": "已取消",
|
||||
"COMPLETED": "已完成",
|
||||
"Continue": "繼續",
|
||||
"Copied": "已複製",
|
||||
"Current": "當前",
|
||||
"Dark": "深色模式",
|
||||
"Day": "天",
|
||||
"Day Uptime": "今日正常運行時長",
|
||||
"Days": "天",
|
||||
"Degraded": "系統降級",
|
||||
"DEGRADED": "系統降級",
|
||||
"Degraded Performance": "效能下降",
|
||||
"Didn't receive the code? Resend": "沒有收到驗證碼?重新發送",
|
||||
"Down": "當機",
|
||||
"DOWN": "故障",
|
||||
"Duration": "持續時間",
|
||||
"Email address": "電郵地址",
|
||||
"Embed Monitor": "嵌入監控項",
|
||||
"Embed this monitor in your website or app": "將此監控項嵌入您的網站或應用程式中",
|
||||
"End Time": "結束時間",
|
||||
"Enter the verification code sent to your email.": "輸入發送到您電郵的驗證碼。",
|
||||
"Events": "動態",
|
||||
"Failed to load data": "載入數據失敗",
|
||||
"Failed to load latency data": "載入延遲數據失敗",
|
||||
"Failed to load status data for this day": "無法載入當天的狀態數據",
|
||||
"Failed to send verification code": "發送驗證碼失敗",
|
||||
"Failed to update preference": "無法更新偏好設定",
|
||||
"Get badges for this monitor": "獲取此監控項的徽章",
|
||||
"Get notified about incidents and scheduled maintenance.": "獲取有關事件和定期維護的通知。",
|
||||
"Get notified about incidents updates": "獲取有關事件更新的通知",
|
||||
"Get notified about scheduled maintenance": "獲取有關定期維護的通知",
|
||||
"IDENTIFIED": "已確認",
|
||||
"iFrame": "框架",
|
||||
"Impact": "影響",
|
||||
"incident": "事件",
|
||||
"Incident Updates": "事件更新",
|
||||
"Incidents": "事件",
|
||||
"Included Monitors (%count)": "包括的監控項 (%count)",
|
||||
"INVESTIGATING": "調查中",
|
||||
"Last Updated": "最後更新",
|
||||
"Latency": "延遲",
|
||||
"Latency Embed": "嵌入延遲",
|
||||
"Latency Over Time": "歷史延遲",
|
||||
"Latency Trend": "延遲趨勢",
|
||||
"Latest Latency": "最新延遲時間",
|
||||
"Latest Status": "最新狀態",
|
||||
"Light": "淺色模式",
|
||||
"Live Status": "即時狀態",
|
||||
"Loading your preferences...": "正在載入您的偏好設定...",
|
||||
"maintenance": "維護",
|
||||
"MAINTENANCE": "維護中",
|
||||
"Maintenance Updates": "維護更新",
|
||||
"Maintenances": "例行維護",
|
||||
"Major System Outage": "重大系統故障",
|
||||
"Manage Site": "管理站點",
|
||||
"Manage your notification preferences.": "管理您的通知偏好設定。",
|
||||
"Max Latency": "最大延遲",
|
||||
"Maximum Latency": "最大延遲",
|
||||
"Min Latency": "最短延遲",
|
||||
"Minimum Latency": "最短延遲",
|
||||
"Minute-by-minute status data for this day": "當日每分鐘的狀態數據",
|
||||
"MONITORING": "監察中",
|
||||
"Network error. Please try again.": "網絡錯誤。請稍後再試。",
|
||||
"No Events in %currentMonth": "%currentMonth 沒有活動",
|
||||
"No events to show": "沒有可顯示的事件",
|
||||
"No incidents for this day": "這一天沒有發生任何事件",
|
||||
"No latency data available for this day": "當天沒有可用的延遲數據",
|
||||
"No maintenances for this day": "這一天無維護",
|
||||
"No monitors affected": "沒有監控項受到影響",
|
||||
"No monitors available.": "沒有可用的監控項。",
|
||||
"No ongoing maintenances": "沒有需要持續的維護",
|
||||
"No past maintenances": "過去沒有維護過",
|
||||
"No Status Available": "無可用狀態",
|
||||
"No upcoming maintenances": "沒有即將進行的維護",
|
||||
"No Updates": "沒有更新",
|
||||
"No updates yet": "暫時沒有更新",
|
||||
"Notifications": "通知",
|
||||
"One-time": "單次",
|
||||
"Ongoing": "進行中",
|
||||
"Operational": "正常運行",
|
||||
"Partial Degraded Performance": "部分效能下降",
|
||||
"Partial System Outage": "部分系統故障",
|
||||
"Past": "過去的",
|
||||
"Per-Minute Status": "每分鐘狀態",
|
||||
"Pinging": "檢測中",
|
||||
"Please enter a valid email address": "請輸入有效的電郵地址",
|
||||
"Please enter the 6-digit verification code": "請輸入6位驗證碼",
|
||||
"Read less": "收起",
|
||||
"Read more": "更多",
|
||||
"READY": "就緒",
|
||||
"Recurring": "週期性",
|
||||
"RESOLVED": "已解決",
|
||||
"RSS feed": "RSS 訂閱源",
|
||||
"SCHEDULED": "已計劃",
|
||||
"Scheduled Events (%count)": "計劃事件 (%count)",
|
||||
"Script": "腳本",
|
||||
"Select Language": "選擇語言",
|
||||
"Select latency metric to display": "選擇要顯示的延遲指標",
|
||||
"Select Range": "選擇範圍",
|
||||
"Sending...": "正在發送...",
|
||||
"Standard": "標準",
|
||||
"Start Time": "開始時間",
|
||||
"Status": "狀態",
|
||||
"Status Badge": "狀態徽章",
|
||||
"Status Embed": "狀態嵌入",
|
||||
"Status history and latency trend": "狀態歷史和延遲趨勢",
|
||||
"Subscribe": "訂閱",
|
||||
"Subscribe to Updates": "訂閱更新",
|
||||
"There are no incidents or maintenances scheduled for this month.": "本月沒有任何事故或安排的維護。",
|
||||
"There are no ongoing incidents or maintenance events.": "當前沒有正在進行的事件或維護。",
|
||||
"Total Incidents": "事件總數",
|
||||
"Total Maintenances": "維護總數",
|
||||
"Under Maintenance": "維護中",
|
||||
"Unknown impact": "未知影響",
|
||||
"UP": "正常",
|
||||
"Upcoming": "即將進行",
|
||||
"Update Incident": "更新事件",
|
||||
"Update Maintenance": "更新維護",
|
||||
"Updates": "更新",
|
||||
"Updates (%count)": "更新 (%count)",
|
||||
"Uptime": "正常運行時間",
|
||||
"Uptime Badge": "正常運行時間徽章",
|
||||
"Verification failed": "驗證失敗",
|
||||
"Verify": "驗證",
|
||||
"Verifying": "正在驗證",
|
||||
"We sent a 6-digit code to": "我們已發送一個6位驗證碼至"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"name": "繁體中文(澳門)",
|
||||
"mappings": {
|
||||
"%latency %metric latency": "%latency %metric 延遲",
|
||||
"Affected Monitors (%count)": "受影響的監控項 (%count)",
|
||||
"All Systems Operational": "所有系統運行正常",
|
||||
"Average Latency": "平均延遲",
|
||||
"Avg Latency": "平均延遲",
|
||||
"Back": "返回",
|
||||
"Badges": "徽章",
|
||||
"CANCELLED": "已取消",
|
||||
"COMPLETED": "已完成",
|
||||
"Continue": "繼續",
|
||||
"Copied": "已複製",
|
||||
"Current": "當前",
|
||||
"Dark": "深色模式",
|
||||
"Day": "天",
|
||||
"Day Uptime": "今日正常運行時長",
|
||||
"Days": "天",
|
||||
"Degraded": "系統降級",
|
||||
"DEGRADED": "系統降級",
|
||||
"Degraded Performance": "效能下降",
|
||||
"Didn't receive the code? Resend": "沒有收到驗證碼?重新發送",
|
||||
"Down": "當機",
|
||||
"DOWN": "故障",
|
||||
"Duration": "持續時間",
|
||||
"Email address": "電郵地址",
|
||||
"Embed Monitor": "嵌入監控項",
|
||||
"Embed this monitor in your website or app": "將此監控項嵌入您的網站或應用程式中",
|
||||
"End Time": "結束時間",
|
||||
"Enter the verification code sent to your email.": "輸入發送到您電郵的驗證碼。",
|
||||
"Events": "動態",
|
||||
"Failed to load data": "載入數據失敗",
|
||||
"Failed to load latency data": "載入延遲數據失敗",
|
||||
"Failed to load status data for this day": "無法載入當天的狀態數據",
|
||||
"Failed to send verification code": "發送驗證碼失敗",
|
||||
"Failed to update preference": "無法更新偏好設定",
|
||||
"Get badges for this monitor": "獲取此監控項的徽章",
|
||||
"Get notified about incidents and scheduled maintenance.": "獲取有關事件和定期維護的通知。",
|
||||
"Get notified about incidents updates": "獲取有關事件更新的通知",
|
||||
"Get notified about scheduled maintenance": "獲取有關定期維護的通知",
|
||||
"IDENTIFIED": "已確認",
|
||||
"iFrame": "框架",
|
||||
"Impact": "影響",
|
||||
"incident": "事件",
|
||||
"Incident Updates": "事件更新",
|
||||
"Incidents": "事件",
|
||||
"Included Monitors (%count)": "包括的監控項 (%count)",
|
||||
"INVESTIGATING": "調查中",
|
||||
"Last Updated": "最後更新",
|
||||
"Latency": "延遲",
|
||||
"Latency Embed": "嵌入延遲",
|
||||
"Latency Over Time": "歷史延遲",
|
||||
"Latency Trend": "延遲趨勢",
|
||||
"Latest Latency": "最新延遲時間",
|
||||
"Latest Status": "最新狀態",
|
||||
"Light": "淺色模式",
|
||||
"Live Status": "即時狀態",
|
||||
"Loading your preferences...": "正在載入您的偏好設定...",
|
||||
"maintenance": "維護",
|
||||
"MAINTENANCE": "維護中",
|
||||
"Maintenance Updates": "維護更新",
|
||||
"Maintenances": "例行維護",
|
||||
"Major System Outage": "重大系統故障",
|
||||
"Manage Site": "管理站點",
|
||||
"Manage your notification preferences.": "管理您的通知偏好設定。",
|
||||
"Max Latency": "最大延遲",
|
||||
"Maximum Latency": "最大延遲",
|
||||
"Min Latency": "最短延遲",
|
||||
"Minimum Latency": "最短延遲",
|
||||
"Minute-by-minute status data for this day": "當日每分鐘的狀態數據",
|
||||
"MONITORING": "監察中",
|
||||
"Network error. Please try again.": "網絡錯誤。請稍後再試。",
|
||||
"No Events in %currentMonth": "%currentMonth 沒有活動",
|
||||
"No events to show": "沒有可顯示的事件",
|
||||
"No incidents for this day": "這一天沒有發生任何事件",
|
||||
"No latency data available for this day": "當天沒有可用的延遲數據",
|
||||
"No maintenances for this day": "這一天無維護",
|
||||
"No monitors affected": "沒有監控項受到影響",
|
||||
"No monitors available.": "沒有可用的監控項。",
|
||||
"No ongoing maintenances": "沒有需要持續的維護",
|
||||
"No past maintenances": "過去沒有維護過",
|
||||
"No Status Available": "無可用狀態",
|
||||
"No upcoming maintenances": "沒有即將進行的維護",
|
||||
"No Updates": "沒有更新",
|
||||
"No updates yet": "暫時沒有更新",
|
||||
"Notifications": "通知",
|
||||
"One-time": "單次",
|
||||
"Ongoing": "進行中",
|
||||
"Operational": "正常運行",
|
||||
"Partial Degraded Performance": "部分效能下降",
|
||||
"Partial System Outage": "部分系統故障",
|
||||
"Past": "過去的",
|
||||
"Per-Minute Status": "每分鐘狀態",
|
||||
"Pinging": "檢測中",
|
||||
"Please enter a valid email address": "請輸入有效的電郵地址",
|
||||
"Please enter the 6-digit verification code": "請輸入6位驗證碼",
|
||||
"Read less": "收起",
|
||||
"Read more": "更多",
|
||||
"READY": "就緒",
|
||||
"Recurring": "週期性",
|
||||
"RESOLVED": "已解決",
|
||||
"RSS feed": "RSS 訂閱源",
|
||||
"SCHEDULED": "已計劃",
|
||||
"Scheduled Events (%count)": "計劃事件 (%count)",
|
||||
"Script": "腳本",
|
||||
"Select Language": "選擇語言",
|
||||
"Select latency metric to display": "選擇要顯示的延遲指標",
|
||||
"Select Range": "選擇範圍",
|
||||
"Sending...": "正在發送...",
|
||||
"Standard": "標準",
|
||||
"Start Time": "開始時間",
|
||||
"Status": "狀態",
|
||||
"Status Badge": "狀態徽章",
|
||||
"Status Embed": "狀態嵌入",
|
||||
"Status history and latency trend": "狀態歷史和延遲趨勢",
|
||||
"Subscribe": "訂閱",
|
||||
"Subscribe to Updates": "訂閱更新",
|
||||
"There are no incidents or maintenances scheduled for this month.": "本月沒有任何事故或安排的維護。",
|
||||
"There are no ongoing incidents or maintenance events.": "當前沒有正在進行的事件或維護。",
|
||||
"Total Incidents": "事件總數",
|
||||
"Total Maintenances": "維護總數",
|
||||
"Under Maintenance": "維護中",
|
||||
"Unknown impact": "未知影響",
|
||||
"UP": "正常",
|
||||
"Upcoming": "即將進行",
|
||||
"Update Incident": "更新事件",
|
||||
"Update Maintenance": "更新維護",
|
||||
"Updates": "更新",
|
||||
"Updates (%count)": "更新 (%count)",
|
||||
"Uptime": "正常運行時間",
|
||||
"Uptime Badge": "正常運行時間徽章",
|
||||
"Verification failed": "驗證失敗",
|
||||
"Verify": "驗證",
|
||||
"Verifying": "正在驗證",
|
||||
"We sent a 6-digit code to": "我們已發送一個6位驗證碼至"
|
||||
}
|
||||
}
|
||||
+74
-69
@@ -2,8 +2,8 @@
|
||||
"name": "繁體中文(台灣)",
|
||||
"mappings": {
|
||||
"%latency %metric latency": "%latency %metric 延遲",
|
||||
"Affected Monitors (%count)": "受影響的監控項目 (%count)",
|
||||
"All Systems Operational": "所有系統正常運作",
|
||||
"Affected Monitors (%count)": "受影響的監控項 (%count)",
|
||||
"All Systems Operational": "所有系統運行正常",
|
||||
"Average Latency": "平均延遲",
|
||||
"Avg Latency": "平均延遲",
|
||||
"Back": "返回",
|
||||
@@ -12,122 +12,127 @@
|
||||
"COMPLETED": "已完成",
|
||||
"Continue": "繼續",
|
||||
"Copied": "已複製",
|
||||
"Current": "目前",
|
||||
"Dark": "深色",
|
||||
"Current": "當前",
|
||||
"Dark": "深色模式",
|
||||
"Day": "天",
|
||||
"Day Uptime": "當日正常運作時間",
|
||||
"Day Uptime": "今日正常運行時長",
|
||||
"Days": "天",
|
||||
"DEGRADED": "降級",
|
||||
"Degraded Performance": "效能降低",
|
||||
"Didn't receive the code? Resend": "沒有收到驗證碼?重新傳送",
|
||||
"Degraded": "系統降級",
|
||||
"DEGRADED": "系統降級",
|
||||
"Degraded Performance": "效能下降",
|
||||
"Didn't receive the code? Resend": "沒有收到代碼?重新發送",
|
||||
"Down": "當機",
|
||||
"DOWN": "故障",
|
||||
"Duration": "持續時間",
|
||||
"Edit Monitor": "編輯監控",
|
||||
"Email address": "電子郵件地址",
|
||||
"Embed Monitor": "嵌入監控",
|
||||
"Embed this monitor in your website or app": "將此監控嵌入您的網站或應用程式",
|
||||
"Email address": "電子郵件",
|
||||
"Embed Monitor": "嵌入監控項",
|
||||
"Embed this monitor in your website or app": "將此監控項嵌入您的網站或應用程式中",
|
||||
"End Time": "結束時間",
|
||||
"Enter the verification code sent to your email.": "請輸入傳送至您電子郵件的驗證碼。",
|
||||
"Events": "事件",
|
||||
"Failed to load data": "載入資料失敗",
|
||||
"Failed to load latency data": "載入延遲資料失敗",
|
||||
"Failed to load status data for this day": "無法載入當日的狀態資料",
|
||||
"Failed to send verification code": "傳送驗證碼失敗",
|
||||
"Failed to update preference": "更新偏好設定失敗",
|
||||
"Get badges for this monitor": "取得此監控的徽章",
|
||||
"Get notified about incidents and scheduled maintenance.": "接收事件與排程維護的通知。",
|
||||
"Get notified about incidents updates": "接收事件更新通知",
|
||||
"Get notified about scheduled maintenance": "接收排程維護通知",
|
||||
"Enter the verification code sent to your email.": "輸入發送到您的電子郵件的驗證碼。",
|
||||
"Events": "動態",
|
||||
"Failed to load data": "載入數據失敗",
|
||||
"Failed to load latency data": "載入延遲數據失敗",
|
||||
"Failed to load status data for this day": "無法載入當天的狀態數據",
|
||||
"Failed to send verification code": "發送驗證碼失敗",
|
||||
"Failed to update preference": "無法更新偏好設定",
|
||||
"Get badges for this monitor": "獲取此監控項的徽章",
|
||||
"Get notified about incidents and scheduled maintenance.": "獲取有關事件和定期維護的通知。",
|
||||
"Get notified about incidents updates": "獲取有關事件更新的通知",
|
||||
"Get notified about scheduled maintenance": "獲取有關定期維護的通知",
|
||||
"IDENTIFIED": "已確認",
|
||||
"iFrame": "iFrame",
|
||||
"iFrame": "框架",
|
||||
"Impact": "影響",
|
||||
"incident": "事件",
|
||||
"Incident Updates": "事件更新",
|
||||
"Incidents": "事件",
|
||||
"Included Monitors (%count)": "包含的監控項目 (%count)",
|
||||
"Included Monitors (%count)": "包括的監控項 (%count)",
|
||||
"INVESTIGATING": "調查中",
|
||||
"Last Updated": "最後更新",
|
||||
"Latency": "延遲",
|
||||
"Latency Embed": "延遲嵌入",
|
||||
"Latency Over Time": "延遲趨勢圖",
|
||||
"Latency Embed": "嵌入延遲",
|
||||
"Latency Over Time": "歷史延遲",
|
||||
"Latency Trend": "延遲趨勢",
|
||||
"Latest Latency": "最新延遲",
|
||||
"Latest Latency": "最新延遲時間",
|
||||
"Latest Status": "最新狀態",
|
||||
"Light": "淺色",
|
||||
"Light": "淺色模式",
|
||||
"Live Status": "即時狀態",
|
||||
"Loading your preferences...": "正在載入您的偏好設定……",
|
||||
"Loading your preferences...": "正在載入您的偏好設定...",
|
||||
"maintenance": "維護",
|
||||
"MAINTENANCE": "維護中",
|
||||
"Maintenance Updates": "維護更新",
|
||||
"Maintenances": "維護作業",
|
||||
"Major System Outage": "重大系統中斷",
|
||||
"Manage Site": "管理網站",
|
||||
"Manage your notification preferences.": "管理您的通知偏好設定。",
|
||||
"Maintenances": "例行維護",
|
||||
"Major System Outage": "重大系統故障",
|
||||
"Manage Site": "管理站點",
|
||||
"Manage your notification preferences.": "管理您的通知首選項。",
|
||||
"Max Latency": "最大延遲",
|
||||
"Maximum Latency": "最大延遲",
|
||||
"Min Latency": "最小延遲",
|
||||
"Minimum Latency": "最小延遲",
|
||||
"Minute-by-minute status data for this day": "當日逐分鐘狀態資料",
|
||||
"MONITORING": "監控中",
|
||||
"Network error. Please try again.": "網路錯誤,請重試。",
|
||||
"No Events in %currentMonth": "%currentMonth 沒有事件",
|
||||
"Min Latency": "最短延遲",
|
||||
"Minimum Latency": "最短延遲",
|
||||
"Minute-by-minute status data for this day": "當日每分鐘的狀態數據",
|
||||
"MONITORING": "監視中",
|
||||
"Network error. Please try again.": "網路錯誤。請稍後再試。",
|
||||
"No Events in %currentMonth": "%currentMonth 沒有活動",
|
||||
"No events to show": "沒有可顯示的事件",
|
||||
"No incidents for this day": "當日無事件",
|
||||
"No latency data available for this day": "當日無延遲資料",
|
||||
"No maintenances for this day": "當日無維護作業",
|
||||
"No monitors affected": "沒有受影響的監控項目",
|
||||
"No monitors available.": "沒有可用的監控項目。",
|
||||
"No ongoing maintenances": "沒有進行中的維護作業",
|
||||
"No past maintenances": "沒有過去的維護作業",
|
||||
"No incidents for this day": "這一天沒有發生任何事件",
|
||||
"No latency data available for this day": "當天沒有可用的延遲數據",
|
||||
"No maintenances for this day": "這一天無維護",
|
||||
"No monitors affected": "沒有監控項受到影響",
|
||||
"No monitors available.": "沒有可用的監控項。",
|
||||
"No ongoing maintenances": "沒有需要持續的維護",
|
||||
"No past maintenances": "過去沒有維護過",
|
||||
"No Status Available": "無可用狀態",
|
||||
"No upcoming maintenances": "沒有即將進行的維護作業",
|
||||
"No upcoming maintenances": "沒有即將進行的維護",
|
||||
"No Updates": "沒有更新",
|
||||
"No updates yet": "尚無更新",
|
||||
"No updates yet": "暫時沒有更新",
|
||||
"Notifications": "通知",
|
||||
"One-time": "單次",
|
||||
"Ongoing": "進行中",
|
||||
"Partial Degraded Performance": "部分效能降低",
|
||||
"Partial System Outage": "部分系統中斷",
|
||||
"Past": "過去",
|
||||
"Per-Minute Status": "逐分鐘狀態",
|
||||
"Pinging": "偵測中",
|
||||
"Operational": "正常運行",
|
||||
"Partial Degraded Performance": "部分效能下降",
|
||||
"Partial System Outage": "部分系統故障",
|
||||
"Past": "過去的",
|
||||
"Per-Minute Status": "每分鐘狀態",
|
||||
"Pinging": "檢測中",
|
||||
"Please enter a valid email address": "請輸入有效的電子郵件地址",
|
||||
"Please enter the 6-digit verification code": "請輸入 6 位數驗證碼",
|
||||
"Read less": "收合",
|
||||
"Read more": "展開",
|
||||
"Please enter the 6-digit verification code": "請輸入6位驗證碼",
|
||||
"Read less": "收起",
|
||||
"Read more": "更多",
|
||||
"READY": "就緒",
|
||||
"Recurring": "週期性",
|
||||
"RESOLVED": "已解決",
|
||||
"SCHEDULED": "已排程",
|
||||
"Scheduled Events (%count)": "排程事件 (%count)",
|
||||
"Script": "程式碼",
|
||||
"RSS feed": "RSS 訂閱源",
|
||||
"SCHEDULED": "已計劃",
|
||||
"Scheduled Events (%count)": "計劃事件 (%count)",
|
||||
"Script": "腳本",
|
||||
"Select Language": "選擇語言",
|
||||
"Select latency metric to display": "選擇要顯示的延遲指標",
|
||||
"Select Range": "選擇範圍",
|
||||
"Sending...": "傳送中……",
|
||||
"Sending...": "正在發送...",
|
||||
"Standard": "標準",
|
||||
"Start Time": "開始時間",
|
||||
"Status": "狀態",
|
||||
"Status Badge": "狀態徽章",
|
||||
"Status Embed": "狀態嵌入",
|
||||
"Status history and latency trend": "狀態歷程與延遲趨勢",
|
||||
"Status history and latency trend": "狀態歷史和延遲趨勢",
|
||||
"Subscribe": "訂閱",
|
||||
"Subscribe to Updates": "訂閱更新",
|
||||
"There are no incidents or maintenances scheduled for this month.": "本月沒有排程的事件或維護作業。",
|
||||
"There are no ongoing incidents or maintenance events.": "目前沒有進行中的事件或維護作業。",
|
||||
"There are no incidents or maintenances scheduled for this month.": "本月沒有任何事故或安排的維護。",
|
||||
"There are no ongoing incidents or maintenance events.": "當前沒有正在進行的事件或維護。",
|
||||
"Total Incidents": "事件總數",
|
||||
"Total Maintenances": "維護總數",
|
||||
"Under Maintenance": "維護中",
|
||||
"Unknown impact": "影響不明",
|
||||
"Unknown impact": "未知影響",
|
||||
"UP": "正常",
|
||||
"Upcoming": "即將進行",
|
||||
"Update Incident": "更新事件",
|
||||
"Update Maintenance": "更新維護",
|
||||
"Updates": "更新",
|
||||
"Updates (%count)": "更新 (%count)",
|
||||
"Uptime": "正常運作時間",
|
||||
"Uptime Badge": "正常運作時間徽章",
|
||||
"Uptime": "正常運行時間",
|
||||
"Uptime Badge": "正常運行時間徽章",
|
||||
"Verification failed": "驗證失敗",
|
||||
"Verify": "驗證",
|
||||
"Verifying": "驗證中",
|
||||
"We sent a 6-digit code to": "我們已傳送 6 位數驗證碼至"
|
||||
"Verifying": "正在驗證",
|
||||
"We sent a 6-digit code to": "我們發送了一個 6 位代碼至"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,26 +17,19 @@ import type {
|
||||
import type { GroupMonitorTypeData } from "../types/monitor.js";
|
||||
import GC from "../../global-constants.js";
|
||||
import type { LayoutServerData } from "./layoutController.js";
|
||||
import type { NotificationEvent } from "../../types/notifications.js";
|
||||
|
||||
export type { NotificationEvent };
|
||||
|
||||
// Default page settings
|
||||
const defaultPageSettings: PageSettingsType = {
|
||||
monitor_status_history_days: {
|
||||
desktop: 90,
|
||||
mobile: 30,
|
||||
desktop: GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP,
|
||||
mobile: GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE,
|
||||
},
|
||||
monitor_layout_style: "default-list",
|
||||
monitor_layout_style: GC.DEFAULT_MONITOR_LAYOUT_STYLE,
|
||||
};
|
||||
|
||||
export interface NotificationEvent {
|
||||
eventURL: string;
|
||||
eventTitle: string;
|
||||
eventDate: string;
|
||||
eventType: string;
|
||||
eventStartDateTime: number;
|
||||
eventEndDateTime: number | null;
|
||||
eventStatus: string;
|
||||
}
|
||||
|
||||
export interface NotificationPayload {
|
||||
notifications: NotificationEvent[];
|
||||
}
|
||||
@@ -377,17 +370,18 @@ 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" }),
|
||||
eventSettings.incidents.enabled && eventSettings.incidents.ongoing.show
|
||||
showInlineEvents && eventSettings.incidents.enabled && eventSettings.incidents.ongoing.show
|
||||
? GetOngoingIncidentsForMonitorList(monitorTags)
|
||||
: Promise.resolve([] as IncidentForMonitorListWithComments[]),
|
||||
eventSettings.maintenances.enabled && eventSettings.maintenances.ongoing.show
|
||||
showInlineEvents && eventSettings.maintenances.enabled && eventSettings.maintenances.ongoing.show
|
||||
? GetOngoingMaintenances(monitorTags, nowTs)
|
||||
: Promise.resolve([] as MaintenanceEventsMonitorList[]),
|
||||
eventSettings.maintenances.enabled && eventSettings.maintenances.upcoming.show
|
||||
showInlineEvents && eventSettings.maintenances.enabled && eventSettings.maintenances.upcoming.show
|
||||
? GetUpcomingMaintenanceEventsForMonitorList(
|
||||
monitorTags,
|
||||
eventSettings.maintenances.upcoming.maxCount,
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
GetLoggedInSession,
|
||||
GetLocaleFromCookie,
|
||||
GetUsersCount,
|
||||
HasRequiredEnv,
|
||||
IsEmailSetup,
|
||||
IsSetupComplete,
|
||||
} from "./controller.js";
|
||||
import type { EventDisplaySettings, GlobalPageVisibilitySettings, SiteDateTimeFormat } from "$lib/types/site.js";
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface LayoutServerData {
|
||||
subMenuOptions: {
|
||||
showShareBadgeMonitor: boolean;
|
||||
showShareEmbedMonitor: boolean;
|
||||
showRssFeed: boolean;
|
||||
};
|
||||
isTimezoneEnabled: boolean;
|
||||
isThemeToggleEnabled: boolean;
|
||||
@@ -75,6 +76,43 @@ export interface LayoutServerData {
|
||||
metaSiteDescription?: string;
|
||||
}
|
||||
|
||||
function NormalizeEventDisplaySettings(settings?: Partial<EventDisplaySettings>): EventDisplaySettings {
|
||||
const defaults = structuredClone(seedSiteData.eventDisplaySettings);
|
||||
|
||||
return {
|
||||
showInlineEvents:
|
||||
typeof settings?.showInlineEvents === "boolean" ? settings.showInlineEvents : defaults.showInlineEvents,
|
||||
incidents: {
|
||||
...defaults.incidents,
|
||||
...settings?.incidents,
|
||||
ongoing: {
|
||||
...defaults.incidents.ongoing,
|
||||
...settings?.incidents?.ongoing,
|
||||
},
|
||||
resolved: {
|
||||
...defaults.incidents.resolved,
|
||||
...settings?.incidents?.resolved,
|
||||
},
|
||||
},
|
||||
maintenances: {
|
||||
...defaults.maintenances,
|
||||
...settings?.maintenances,
|
||||
ongoing: {
|
||||
...defaults.maintenances.ongoing,
|
||||
...settings?.maintenances?.ongoing,
|
||||
},
|
||||
past: {
|
||||
...defaults.maintenances.past,
|
||||
...settings?.maintenances?.past,
|
||||
},
|
||||
upcoming: {
|
||||
...defaults.maintenances.upcoming,
|
||||
...settings?.maintenances?.upcoming,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function GetLayoutServerData(cookies: Cookies, request: Request): Promise<LayoutServerData> {
|
||||
const userAgent = request.headers.get("user-agent") ?? "";
|
||||
const md = new MobileDetect(userAgent);
|
||||
@@ -86,7 +124,9 @@ export async function GetLayoutServerData(cookies: Cookies, request: Request): P
|
||||
GetUsersCount(),
|
||||
]);
|
||||
|
||||
const isSetupComplete = await IsSetupComplete();
|
||||
// Same check as IsSetupComplete, but reuses the site data fetched above
|
||||
// instead of querying it a second time on every request
|
||||
const isSetupComplete = HasRequiredEnv() && Object.keys(siteData).length > 0;
|
||||
|
||||
const selectedLang = GetLocaleFromCookie(siteData, cookies);
|
||||
const siteStatusColors = siteData.colors;
|
||||
@@ -136,7 +176,7 @@ export async function GetLayoutServerData(cookies: Cookies, request: Request): P
|
||||
font,
|
||||
canSendEmail,
|
||||
announcement: siteData.announcement,
|
||||
eventDisplaySettings: siteData.eventDisplaySettings || seedSiteData.eventDisplaySettings,
|
||||
eventDisplaySettings: NormalizeEventDisplaySettings(siteData.eventDisplaySettings),
|
||||
socialPreviewImage: siteData.socialPreviewImage,
|
||||
customCSS: siteData.customCSS,
|
||||
globalPageVisibilitySettings: siteData.globalPageVisibilitySettings || seedSiteData.globalPageVisibilitySettings,
|
||||
|
||||
@@ -486,18 +486,75 @@ export const UpdateMaintenanceEvent = async (
|
||||
return await db.updateMaintenanceEvent(id, data);
|
||||
};
|
||||
|
||||
export const UpdateMaintenanceEventStatus = async (id: number, status: string): Promise<number> => {
|
||||
const validStatuses = ["SCHEDULED", "IN_PROGRESS", "COMPLETED", "CANCELLED"];
|
||||
if (!validStatuses.includes(status)) {
|
||||
throw new Error(`Invalid status: ${status}`);
|
||||
/**
|
||||
* Manually transition a maintenance event to a terminal status.
|
||||
* Allowed transitions: ONGOING → COMPLETED, SCHEDULED/READY/ONGOING → CANCELLED.
|
||||
* An event that already started has its end_date_time moved to the moment it was
|
||||
* ended (the record reflects what actually happened); an event that never started
|
||||
* keeps its planned window. See docs/adr/0006-manual-maintenance-event-transitions.md
|
||||
*/
|
||||
export const UpdateMaintenanceEventStatus = async (id: number, status: string): Promise<MaintenanceEventRecord> => {
|
||||
if (status !== GC.COMPLETED && status !== GC.CANCELLED) {
|
||||
throw new Error(`Invalid status: ${status}. Allowed values are ${GC.COMPLETED} and ${GC.CANCELLED}`);
|
||||
}
|
||||
const targetStatus = status as "COMPLETED" | "CANCELLED";
|
||||
|
||||
const existing = await db.getMaintenanceEventById(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Maintenance event with id ${id} does not exist`);
|
||||
}
|
||||
|
||||
return await db.updateMaintenanceEventStatus(id, status);
|
||||
const allowedFrom: string[] = targetStatus === GC.COMPLETED ? [GC.ONGOING] : [GC.SCHEDULED, GC.READY, GC.ONGOING];
|
||||
if (!allowedFrom.includes(existing.status)) {
|
||||
throw new Error(`Cannot transition event from ${existing.status} to ${targetStatus}`);
|
||||
}
|
||||
|
||||
if (existing.status === GC.ONGOING) {
|
||||
// Ended now, but never before its first minute nor after its planned end
|
||||
const endDateTime = Math.min(
|
||||
existing.end_date_time,
|
||||
Math.max(GetMinuteStartNowTimestampUTC(), existing.start_date_time + 60),
|
||||
);
|
||||
await db.updateMaintenanceEvent(id, { status: targetStatus, end_date_time: endDateTime });
|
||||
} else {
|
||||
await db.updateMaintenanceEventStatus(id, targetStatus);
|
||||
}
|
||||
|
||||
const updated = await db.getMaintenanceEventById(id);
|
||||
if (!updated) {
|
||||
throw new Error(`Maintenance event with id ${id} does not exist`);
|
||||
}
|
||||
|
||||
try {
|
||||
const siteData = await GetAllSiteData();
|
||||
const notificationSettings =
|
||||
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
|
||||
if (notificationSettings.event_types.ended) {
|
||||
const siteVars = siteDataToVariables(siteData);
|
||||
const siteUrl = siteVars.site_url;
|
||||
const maintenance = await db.getMaintenanceById(updated.maintenance_id);
|
||||
const monitors = await db.getMonitorsByMaintenanceId(updated.maintenance_id);
|
||||
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
|
||||
const eventDetailed: MaintenanceEventRecordDetailed = {
|
||||
...updated,
|
||||
title: maintenance?.title || "",
|
||||
description: maintenance?.description || null,
|
||||
};
|
||||
const update = maintenanceToVariables(
|
||||
eventDetailed,
|
||||
monitorNames,
|
||||
targetStatus === GC.COMPLETED ? "**has been completed**" : "**has been cancelled**",
|
||||
targetStatus === GC.COMPLETED ? "completed" : "cancelled",
|
||||
targetStatus === GC.COMPLETED ? "Maintenance Completed" : "Maintenance Cancelled",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error sending ${targetStatus} notification for maintenance event ${id}:`, err);
|
||||
}
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
export const DeleteMaintenanceEvent = async (id: number): Promise<number> => {
|
||||
|
||||
@@ -328,7 +328,8 @@ export async function DeleteMonitorAlertConfig(id: number): Promise<boolean> {
|
||||
throw new Error(`Monitor alert config with id '${id}' not found`);
|
||||
}
|
||||
|
||||
// Triggers will be deleted automatically due to CASCADE
|
||||
// The repository deletes trigger/monitor junctions and v2 alerts explicitly;
|
||||
// FK cascades are not enforced on SQLite
|
||||
const deleted = await db.deleteMonitorAlertConfig(id);
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
@@ -21,11 +21,11 @@ import type {
|
||||
import type { MonitorFilter } from "../db/repositories/base.js";
|
||||
import db from "../db/db.js";
|
||||
import type { PaginationInput } from "../../types/common.js";
|
||||
import type { DayWiseStatus, NumberWithChange } from "../../types/monitor.js";
|
||||
import GC, { getBadgeStyle, type BadgeStyle } from "../../global-constants.js";
|
||||
import { makeBadge } from "badge-maker";
|
||||
import { ErrorSvg } from "../../anywhere.js";
|
||||
import { GetLastMonitoringValue, SetLastHeartbeat, DeleteMonitorCaches } from "../cache/setGet.js";
|
||||
import { CollapseStatusCounts } from "../../clientTools.js";
|
||||
import { translate, isLocaleAvailable } from "../i18n.js";
|
||||
import type { HeartbeatMonitor, GroupMonitorTypeData } from "../types/monitor.js";
|
||||
|
||||
@@ -92,6 +92,7 @@ interface MonitoringDataInput {
|
||||
latency?: number;
|
||||
type: string;
|
||||
error_message?: string | null;
|
||||
raw_status?: string | null;
|
||||
}
|
||||
|
||||
interface InterpolatedDataEntry {
|
||||
@@ -112,6 +113,7 @@ export const InsertMonitoringData = async (data: MonitoringDataInput): Promise<M
|
||||
latency: data.latency || 0,
|
||||
type: data.type,
|
||||
error_message: data.error_message,
|
||||
raw_status: data.raw_status,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -264,6 +266,7 @@ export const CloneMonitor = async ({ sourceTag, newTag, newName }: CloneMonitorI
|
||||
type_data: source.type_data,
|
||||
day_degraded_minimum_count: source.day_degraded_minimum_count,
|
||||
day_down_minimum_count: source.day_down_minimum_count,
|
||||
confirmation_threshold: source.confirmation_threshold,
|
||||
include_degraded_in_downtime: source.include_degraded_in_downtime,
|
||||
is_hidden: source.is_hidden,
|
||||
monitor_settings_json: source.monitor_settings_json,
|
||||
@@ -290,7 +293,7 @@ export const GetLatestMonitoringData = async (monitor_tag: string): Promise<Moni
|
||||
};
|
||||
export const GetLatestStatusActiveAll = async (): Promise<{ status: string }> => {
|
||||
//get all the active not hidden monitor tags
|
||||
const monitors = await db.getMonitors({ status: "ACTIVE", is_hidden: "NO" });
|
||||
const monitors = await db.getMonitors({ status: GC.ACTIVE, is_hidden: GC.NO });
|
||||
const monitor_tags = monitors.map((m) => m.tag);
|
||||
|
||||
const latestData: MonitoringData[] = [];
|
||||
@@ -302,19 +305,20 @@ export const GetLatestStatusActiveAll = async (): Promise<{ status: string }> =>
|
||||
}
|
||||
}
|
||||
|
||||
let status: string = GC.NO_DATA;
|
||||
for (let i = 0; i < latestData.length; i++) {
|
||||
//if any status is down then status = down, if any is degraded then status = degraded, down > degraded > up
|
||||
if (latestData[i].status === GC.DOWN) {
|
||||
status = GC.DOWN;
|
||||
} else if (latestData[i].status === GC.DEGRADED && status !== GC.DOWN) {
|
||||
status = GC.DEGRADED;
|
||||
} else if (latestData[i].status === GC.UP && status !== GC.DOWN && status !== GC.DEGRADED) {
|
||||
status = GC.UP;
|
||||
const counts = { countOfUp: 0, countOfDown: 0, countOfDegraded: 0, countOfMaintenance: 0 };
|
||||
for (const data of latestData) {
|
||||
if (data.status === GC.UP) {
|
||||
counts.countOfUp++;
|
||||
} else if (data.status === GC.DOWN) {
|
||||
counts.countOfDown++;
|
||||
} else if (data.status === GC.DEGRADED) {
|
||||
counts.countOfDegraded++;
|
||||
} else if (data.status === GC.MAINTENANCE) {
|
||||
counts.countOfMaintenance++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: status,
|
||||
status: CollapseStatusCounts(counts),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -419,6 +423,7 @@ async function removeTagFromGroupMonitors(tag: string): Promise<void> {
|
||||
type_data: JSON.stringify(typeData),
|
||||
day_degraded_minimum_count: group.day_degraded_minimum_count,
|
||||
day_down_minimum_count: group.day_down_minimum_count,
|
||||
confirmation_threshold: group.confirmation_threshold,
|
||||
include_degraded_in_downtime: group.include_degraded_in_downtime,
|
||||
is_hidden: group.is_hidden,
|
||||
monitor_settings_json:
|
||||
@@ -436,6 +441,7 @@ export const DeleteMonitorCompletelyUsingTag = async (tag: string): Promise<numb
|
||||
await db.deleteMonitorDataByTag(tag);
|
||||
await db.deleteIncidentMonitorsByTag(tag);
|
||||
await db.deleteMonitorAlertsByTag(tag);
|
||||
await db.deleteMonitorAlertConfigsByMonitorTag(tag);
|
||||
await db.deletePageMonitorsByTag(tag);
|
||||
await db.deleteMaintenanceMonitorsByTag(tag);
|
||||
await removeTagFromGroupMonitors(tag);
|
||||
@@ -461,9 +467,6 @@ export const GetAllAlertsPaginated = async (
|
||||
export const GetMonitoringData = async (tag: string, since: number, now: number): Promise<MonitoringData[]> => {
|
||||
return await db.getMonitoringData(tag, since, now);
|
||||
};
|
||||
export const GetMonitoringDataAll = async (tags: string[], since: number, now: number): Promise<MonitoringData[]> => {
|
||||
return await db.getMonitoringDataAll(tags, since, now);
|
||||
};
|
||||
|
||||
export const InsertNewAlert = async (data: MonitorAlertInsert): Promise<MonitorAlert | undefined> => {
|
||||
if (await db.alertExists(data.monitor_tag, data.monitor_status, data.alert_status)) {
|
||||
@@ -547,7 +550,7 @@ export const GetBadge = async (badgeType: BadgeType, params: BadgeParams): Promi
|
||||
lastObj = await GetLatestStatusActiveAll();
|
||||
} else {
|
||||
// Single monitor status
|
||||
const monitors = await GetMonitorsParsed({ tag, status: "ACTIVE", is_hidden: "NO" });
|
||||
const monitors = await GetMonitorsParsed({ tag, status: GC.ACTIVE, is_hidden: GC.NO });
|
||||
if (monitors.length === 0) {
|
||||
return new Response(ErrorSvg, {
|
||||
headers: { "Content-Type": "image/svg+xml" },
|
||||
@@ -635,14 +638,14 @@ export const GetBadge = async (badgeType: BadgeType, params: BadgeParams): Promi
|
||||
const siteData = await db.getSiteDataByKey("siteName");
|
||||
const siteName = siteData?.value as string | undefined;
|
||||
name = siteName || "All Monitors";
|
||||
const goodMonitors = await GetMonitorsParsed({ status: "ACTIVE", is_hidden: "NO" });
|
||||
const goodMonitors = await GetMonitorsParsed({ status: GC.ACTIVE, is_hidden: GC.NO });
|
||||
const activeTags = goodMonitors.map((monitor) => monitor.tag);
|
||||
|
||||
stats = await db.getStatusCountsByInterval(activeTags, since, now - since, 1);
|
||||
uptimeData = UptimeCalculator(stats);
|
||||
} else {
|
||||
// Single monitor badge
|
||||
const monitors = await GetMonitorsParsed({ tag });
|
||||
const monitors = await GetMonitorsParsed({ tag, status: GC.ACTIVE, is_hidden: GC.NO });
|
||||
if (monitors.length === 0) {
|
||||
return new Response(ErrorSvg, {
|
||||
headers: { "Content-Type": "image/svg+xml" },
|
||||
@@ -751,3 +754,6 @@ export const GetStatusCountsByIntervalGroupedByMonitor = async (
|
||||
await setCache(cacheKey, result, 60);
|
||||
return result;
|
||||
};
|
||||
export const GetLastKnownStatus = async (monitor_tag: string): Promise<MonitoringData | undefined> => {
|
||||
return await db.getLastKnownStatus(monitor_tag);
|
||||
};
|
||||
|
||||
@@ -108,6 +108,22 @@ export const GetLocaleFromCookie = (site: SiteDataTransformed, cookies: Cookies)
|
||||
return selectedLang;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the site URL used for building absolute public URLs, without a trailing slash.
|
||||
* Prefers the configured siteURL and falls back to the ORIGIN env var; only absolute
|
||||
* http(s) values are returned. Returns an empty string when neither is usable, in which
|
||||
* case callers degrade to a relative path.
|
||||
*/
|
||||
export const GetSiteURL = async (): Promise<string> => {
|
||||
const siteURL = await GetSiteDataByKey("siteURL");
|
||||
for (const candidate of [siteURL, process.env.ORIGIN]) {
|
||||
if (typeof candidate === "string" && /^https?:\/\//i.test(candidate)) {
|
||||
return candidate.replace(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
export const GetSiteLogoURL = async (siteURL: string, logo: string, base: string): Promise<string> => {
|
||||
if (logo.startsWith("http")) {
|
||||
return logo;
|
||||
@@ -138,14 +154,17 @@ export const GetSiteDataByKey = async (key: string): Promise<unknown> => {
|
||||
return data.value;
|
||||
};
|
||||
|
||||
/** Checks the env vars required for setup, without touching the database. */
|
||||
export const HasRequiredEnv = (): boolean => {
|
||||
return (
|
||||
process.env.KENER_SECRET_KEY !== undefined &&
|
||||
process.env.ORIGIN !== undefined &&
|
||||
process.env.REDIS_URL !== undefined
|
||||
);
|
||||
};
|
||||
|
||||
export const IsSetupComplete = async (): Promise<boolean> => {
|
||||
if (process.env.KENER_SECRET_KEY === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (process.env.ORIGIN === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (process.env.REDIS_URL === undefined) {
|
||||
if (!HasRequiredEnv()) {
|
||||
return false;
|
||||
}
|
||||
let data = await db.getAllSiteData();
|
||||
|
||||
@@ -2,7 +2,7 @@ import db from "../db/db.js";
|
||||
import type { PaginationInput } from "$lib/types/common";
|
||||
import { GenerateToken, HashPassword, ValidatePassword, VerifyToken } from "./commonController.js";
|
||||
import type { Cookies } from "@sveltejs/kit";
|
||||
import type { UserRecordPublic, UserRecordDashboard } from "../types/db.js";
|
||||
import type { UserRecordPublic, UserRecordDashboard, RoleRecord } from "../types/db.js";
|
||||
import { GetAllSiteData } from "./controller.js";
|
||||
import { siteDataToVariables } from "../notification/notification_utils.js";
|
||||
import sendEmail from "../notification/email_notification.js";
|
||||
@@ -16,7 +16,7 @@ export interface UserUpdateInput {
|
||||
|
||||
interface ManualUserUpdateInput {
|
||||
updateType: string;
|
||||
role?: string;
|
||||
role_ids?: string[];
|
||||
is_active?: number;
|
||||
password?: string;
|
||||
passwordPlain?: string;
|
||||
@@ -32,7 +32,7 @@ interface NewUserInput {
|
||||
name: string;
|
||||
password: string;
|
||||
plainPassword: string;
|
||||
role: string;
|
||||
role_ids: string[];
|
||||
}
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
@@ -65,12 +65,18 @@ const validateNameOrThrow = (name: string): string => {
|
||||
return normalizedName;
|
||||
};
|
||||
|
||||
export const GetAllUsersPaginated = async (data: PaginationInput): Promise<UserRecordPublic[]> => {
|
||||
return await db.getUsersPaginated(data.page, data.limit);
|
||||
export const GetAllUsersPaginated = async (
|
||||
data: PaginationInput,
|
||||
filter?: { is_active?: number },
|
||||
): Promise<UserRecordPublic[]> => {
|
||||
return await db.getUsersPaginated(data.page, data.limit, filter);
|
||||
};
|
||||
|
||||
export const GetAllUsersPaginatedDashboard = async (data: PaginationInput): Promise<UserRecordDashboard[]> => {
|
||||
const users = await db.getUsersPaginated(data.page, data.limit);
|
||||
export const GetAllUsersPaginatedDashboard = async (
|
||||
data: PaginationInput,
|
||||
filter?: { is_active?: number },
|
||||
): Promise<UserRecordDashboard[]> => {
|
||||
const users = await db.getUsersPaginated(data.page, data.limit, filter);
|
||||
if (users.length === 0) return [];
|
||||
|
||||
// Batch fetch password statuses for all users
|
||||
@@ -88,8 +94,8 @@ export const GetAllUsers = async () => {
|
||||
return await db.getAllUsers();
|
||||
};
|
||||
|
||||
export const GetUsersCount = async () => {
|
||||
return await db.getUsersCount();
|
||||
export const GetUsersCount = async (filter?: { is_active?: number }) => {
|
||||
return await db.getTotalUsers(filter);
|
||||
};
|
||||
|
||||
export const GetUserPasswordHashById = async (id: number) => {
|
||||
@@ -145,14 +151,20 @@ export const UpdateUserData = async (data: UserUpdateInput): Promise<number> =>
|
||||
}
|
||||
};
|
||||
|
||||
export const CreateNewUser = async (currentUser: { role: string }, data: NewUserInput): Promise<number[]> => {
|
||||
let acceptedRoles = ["member", "editor"];
|
||||
if (!acceptedRoles.includes(data.role)) {
|
||||
throw new Error("Invalid role");
|
||||
export const CreateNewUser = async (data: NewUserInput): Promise<number[]> => {
|
||||
if (!data.role_ids || data.role_ids.length === 0) {
|
||||
throw new Error("At least one role is required");
|
||||
}
|
||||
|
||||
if (currentUser.role === "member") {
|
||||
throw new Error("Only admins and editors can create new users");
|
||||
// Validate all role_ids exist and are active
|
||||
for (const roleId of data.role_ids) {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" does not exist`);
|
||||
}
|
||||
if (role.status !== "ACTIVE") {
|
||||
throw new Error(`Role "${roleId}" is not active`);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedEmail = validateEmailOrThrow(data.email);
|
||||
@@ -163,11 +175,6 @@ export const CreateNewUser = async (currentUser: { role: string }, data: NewUser
|
||||
throw new Error("Password cannot be empty");
|
||||
}
|
||||
|
||||
//if data.role empty, throw error
|
||||
if (!!!data.role) {
|
||||
throw new Error("Role cannot be empty");
|
||||
}
|
||||
|
||||
//if data.password not equal to data.plainPassword, throw error
|
||||
if (data.password !== data.plainPassword) {
|
||||
throw new Error("Passwords do not match");
|
||||
@@ -182,7 +189,7 @@ export const CreateNewUser = async (currentUser: { role: string }, data: NewUser
|
||||
email: normalizedEmail,
|
||||
password_hash: await HashPassword(data.password),
|
||||
name: normalizedName,
|
||||
role: data.role,
|
||||
role_ids: data.role_ids,
|
||||
};
|
||||
return await db.insertUser(user);
|
||||
};
|
||||
@@ -202,7 +209,7 @@ export const CreateFirstUser = async (data: { email: string; name: string; passw
|
||||
email: normalizedEmail,
|
||||
password_hash: await HashPassword(data.password),
|
||||
name: normalizedName,
|
||||
role: "admin",
|
||||
role_ids: ["admin"],
|
||||
is_owner: "YES",
|
||||
};
|
||||
return await db.insertUser(user);
|
||||
@@ -229,33 +236,34 @@ export const UpdatePassword = async (data: PasswordUpdateInput): Promise<number>
|
||||
});
|
||||
};
|
||||
|
||||
const VALID_ROLES = ["admin", "editor", "member"] as const;
|
||||
|
||||
export const ManualUpdateUserData = async (
|
||||
byUser: { id: number; role: string; is_owner: string },
|
||||
forUserId: number,
|
||||
data: ManualUserUpdateInput,
|
||||
): Promise<number | undefined> => {
|
||||
export const ManualUpdateUserData = async (forUserId: number, data: ManualUserUpdateInput): Promise<number | void> => {
|
||||
let forUser = await db.getUserById(forUserId);
|
||||
if (!forUser) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
//only admins can update
|
||||
if (byUser.role !== "admin") {
|
||||
throw new Error("You do not have permission to update user");
|
||||
}
|
||||
// non-owner admins cannot modify other admins (self-updates are allowed)
|
||||
if (forUser.role === "admin" && byUser.is_owner !== "YES" && forUser.id !== byUser.id) {
|
||||
throw new Error("Only the owner can modify other admins");
|
||||
}
|
||||
if (data.updateType == "role") {
|
||||
if (!data.role) throw new Error("Role is required");
|
||||
if (!VALID_ROLES.includes(data.role as (typeof VALID_ROLES)[number])) {
|
||||
throw new Error(`Invalid role. Must be one of: ${VALID_ROLES.join(", ")}`);
|
||||
if (!data.role_ids || data.role_ids.length === 0) throw new Error("At least one role is required");
|
||||
// Owner must always retain the admin role
|
||||
if (forUser.is_owner === "YES" && !data.role_ids.includes("admin")) {
|
||||
throw new Error("Owner must retain the admin role");
|
||||
}
|
||||
return await db.updateUserRole(forUser.id, data.role);
|
||||
// Validate all role_ids exist and are active
|
||||
for (const roleId of data.role_ids) {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" does not exist`);
|
||||
}
|
||||
if (role.status !== "ACTIVE") {
|
||||
throw new Error(`Role "${roleId}" is not active`);
|
||||
}
|
||||
}
|
||||
return await db.updateUserRoles(forUser.id, data.role_ids);
|
||||
} else if (data.updateType == "is_active") {
|
||||
if (data.is_active === undefined) throw new Error("is_active is required");
|
||||
// Owner cannot be deactivated
|
||||
if (forUser.is_owner === "YES" && data.is_active === 0) {
|
||||
throw new Error("Owner account cannot be deactivated");
|
||||
}
|
||||
return await db.updateUserIsActive(forUser.id, data.is_active);
|
||||
} else if (data.updateType == "password") {
|
||||
if (!data.password || !data.passwordPlain) throw new Error("Password is required");
|
||||
@@ -297,15 +305,20 @@ export const GetTotalUserPages = async (limit: number): Promise<number> => {
|
||||
};
|
||||
|
||||
//send invitation email to user for account creation
|
||||
export const SendInvitationEmail = async (email: string, role: string, name: string, currentUserRole: string) => {
|
||||
if (currentUserRole === "member") {
|
||||
throw new Error("Only admins and editors can create new users");
|
||||
export const SendInvitationEmail = async (email: string, role_ids: string[], name: string) => {
|
||||
if (!role_ids || role_ids.length === 0) {
|
||||
throw new Error("At least one role is required");
|
||||
}
|
||||
|
||||
// Admins can add admin, editor, member; Editors can only add editor, member
|
||||
const acceptedRoles = currentUserRole === "admin" ? ["admin", "editor", "member"] : ["editor", "member"];
|
||||
if (!acceptedRoles.includes(role)) {
|
||||
throw new Error("Invalid role");
|
||||
// Validate all role_ids exist and are active
|
||||
for (const roleId of role_ids) {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" does not exist`);
|
||||
}
|
||||
if (role.status !== "ACTIVE") {
|
||||
throw new Error(`Role "${roleId}" is not active`);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedEmail = validateEmailOrThrow(email);
|
||||
@@ -323,7 +336,7 @@ export const SendInvitationEmail = async (email: string, role: string, name: str
|
||||
email: normalizedEmail,
|
||||
password_hash: "",
|
||||
name: normalizedName,
|
||||
role,
|
||||
role_ids: role_ids,
|
||||
is_active: 0,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
@@ -364,11 +377,7 @@ export const SendInvitationEmail = async (email: string, role: string, name: str
|
||||
};
|
||||
|
||||
//resend invitation email to existing user with blank password
|
||||
export const ResendInvitationEmail = async (email: string, currentUserRole: string) => {
|
||||
if (currentUserRole === "member") {
|
||||
throw new Error("Only admins and editors can resend invitations");
|
||||
}
|
||||
|
||||
export const ResendInvitationEmail = async (email: string) => {
|
||||
const normalizedEmail = validateEmailOrThrow(email);
|
||||
|
||||
const user = await db.getUserByEmail(normalizedEmail);
|
||||
@@ -410,17 +419,11 @@ export const ResendInvitationEmail = async (email: string, currentUserRole: stri
|
||||
};
|
||||
|
||||
// send verification email with verification link
|
||||
export const SendVerificationEmail = async (toUserId: number, currentUser: { id: number; role: string }) => {
|
||||
export const SendVerificationEmail = async (toUserId: number, currentUserId: number) => {
|
||||
if (!toUserId) {
|
||||
throw new Error("User ID is required");
|
||||
}
|
||||
|
||||
// Only admins/editors can send verification to other users.
|
||||
// Members can only send verification email to themselves.
|
||||
if (currentUser.role === "member" && currentUser.id !== toUserId) {
|
||||
throw new Error("You do not have permission to send verification email for this user");
|
||||
}
|
||||
|
||||
const user = await db.getUserById(toUserId);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
@@ -458,3 +461,221 @@ export const SendVerificationEmail = async (toUserId: number, currentUser: { id:
|
||||
template.template_text_body || "",
|
||||
);
|
||||
};
|
||||
|
||||
const RESTRICTED_ROLE_IDS = ["admin", "editor", "member"];
|
||||
const ROLE_ID_REGEX = /^[a-z0-9_-]+$/;
|
||||
|
||||
const normalizeRoleId = (id: string): string => {
|
||||
return id.trim().toLowerCase().replace(/\s+/g, "_");
|
||||
};
|
||||
|
||||
export const CreateRole = async (data: { role_id: string; name: string }): Promise<RoleRecord> => {
|
||||
const roleId = normalizeRoleId(data.role_id || "");
|
||||
const roleName = data.name?.trim();
|
||||
|
||||
if (!roleId) {
|
||||
throw new Error("Role ID is required");
|
||||
}
|
||||
if (!ROLE_ID_REGEX.test(roleId)) {
|
||||
throw new Error("Role ID can only contain lowercase letters, numbers, underscores, and hyphens");
|
||||
}
|
||||
if (!roleName) {
|
||||
throw new Error("Role name is required");
|
||||
}
|
||||
|
||||
if (RESTRICTED_ROLE_IDS.includes(roleId)) {
|
||||
throw new Error(`Role ID "${roleId}" is restricted and cannot be used`);
|
||||
}
|
||||
|
||||
const existing = await db.getRoleById(roleId);
|
||||
if (existing) {
|
||||
throw new Error(`Role with ID "${roleId}" already exists`);
|
||||
}
|
||||
|
||||
await db.insertRole({ id: roleId, role_name: roleName });
|
||||
|
||||
const created = await db.getRoleById(roleId);
|
||||
if (!created) {
|
||||
throw new Error("Failed to create role");
|
||||
}
|
||||
return created;
|
||||
};
|
||||
|
||||
export const UpdateRole = async (roleId: string, data: { name?: string; status?: string }): Promise<RoleRecord> => {
|
||||
if (!roleId) {
|
||||
throw new Error("Role ID is required");
|
||||
}
|
||||
|
||||
const existing = await db.getRoleById(roleId);
|
||||
if (!existing) {
|
||||
throw new Error(`Role "${roleId}" not found`);
|
||||
}
|
||||
|
||||
if (existing.readonly === 1) {
|
||||
throw new Error("Readonly roles cannot be updated");
|
||||
}
|
||||
|
||||
const updates: { role_name?: string; status?: string } = {};
|
||||
|
||||
if (data.name !== undefined) {
|
||||
const trimmed = data.name.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Role name cannot be empty");
|
||||
}
|
||||
updates.role_name = trimmed;
|
||||
}
|
||||
|
||||
if (data.status !== undefined) {
|
||||
if (data.status !== "ACTIVE" && data.status !== "INACTIVE") {
|
||||
throw new Error("Status must be ACTIVE or INACTIVE");
|
||||
}
|
||||
updates.status = data.status;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
throw new Error("No valid fields to update");
|
||||
}
|
||||
|
||||
await db.updateRole(roleId, updates);
|
||||
|
||||
const updated = await db.getRoleById(roleId);
|
||||
if (!updated) {
|
||||
throw new Error("Failed to retrieve updated role");
|
||||
}
|
||||
return updated;
|
||||
};
|
||||
|
||||
export const DeleteRole = async (
|
||||
roleId: string,
|
||||
options: { action: "migrate"; targetRoleId: string } | { action: "remove" },
|
||||
): Promise<{ success: true }> => {
|
||||
if (!roleId) {
|
||||
throw new Error("Role ID is required");
|
||||
}
|
||||
|
||||
const existing = await db.getRoleById(roleId);
|
||||
if (!existing) {
|
||||
throw new Error(`Role "${roleId}" not found`);
|
||||
}
|
||||
|
||||
if (existing.readonly === 1) {
|
||||
throw new Error("Readonly roles cannot be deleted");
|
||||
}
|
||||
|
||||
if (options.action === "migrate") {
|
||||
const targetRoleId = options.targetRoleId?.trim();
|
||||
if (!targetRoleId) {
|
||||
throw new Error("Target role ID is required for migration");
|
||||
}
|
||||
if (targetRoleId === roleId) {
|
||||
throw new Error("Target role cannot be the same as the role being deleted");
|
||||
}
|
||||
const targetRole = await db.getRoleById(targetRoleId);
|
||||
if (!targetRole) {
|
||||
throw new Error(`Target role "${targetRoleId}" not found`);
|
||||
}
|
||||
if (targetRole.status !== "ACTIVE") {
|
||||
throw new Error("Cannot migrate users to an inactive role");
|
||||
}
|
||||
await db.migrateUsersRole(roleId, targetRoleId);
|
||||
}
|
||||
|
||||
// CASCADE on FK will clean up users_roles and roles_permissions
|
||||
await db.deleteRole(roleId);
|
||||
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const GetAllRoles = async (): Promise<RoleRecord[]> => {
|
||||
return await db.getAllRoles();
|
||||
};
|
||||
|
||||
export const GetAllPermissions = async () => {
|
||||
return await db.getAllPermissions();
|
||||
};
|
||||
|
||||
export const GetRolePermissions = async (roleId: string) => {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" not found`);
|
||||
}
|
||||
return await db.getRolePermissions(roleId);
|
||||
};
|
||||
|
||||
export const UpdateRolePermissions = async (roleId: string, permissionIds: string[]) => {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" not found`);
|
||||
}
|
||||
if (role.readonly === 1) {
|
||||
throw new Error("Readonly roles cannot have their permissions modified");
|
||||
}
|
||||
|
||||
// Get current permissions
|
||||
const current = await db.getRolePermissions(roleId);
|
||||
const currentIds = new Set(current.map((p) => p.permissions_id));
|
||||
const desiredIds = new Set(permissionIds);
|
||||
|
||||
// Add new permissions
|
||||
for (const pid of permissionIds) {
|
||||
if (!currentIds.has(pid)) {
|
||||
await db.addRolePermission(roleId, pid);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old permissions
|
||||
for (const pid of currentIds) {
|
||||
if (!desiredIds.has(pid)) {
|
||||
await db.removeRolePermission(roleId, pid);
|
||||
}
|
||||
}
|
||||
|
||||
return await db.getRolePermissions(roleId);
|
||||
};
|
||||
|
||||
export const GetRoleUsers = async (roleId: string) => {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" not found`);
|
||||
}
|
||||
return await db.getUsersByRoleId(roleId);
|
||||
};
|
||||
|
||||
export const AddUserToRole = async (roleId: string, userId: number) => {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" not found`);
|
||||
}
|
||||
if (role.status !== "ACTIVE") {
|
||||
throw new Error(`Role "${roleId}" is not active`);
|
||||
}
|
||||
// Check if user already in role
|
||||
const users = await db.getUsersByRoleId(roleId);
|
||||
if (users.some((u) => u.id === userId)) {
|
||||
throw new Error("User is already assigned to this role");
|
||||
}
|
||||
await db.addUserToRole(roleId, userId);
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const RemoveUserFromRole = async (roleId: string, userId: number) => {
|
||||
if (roleId === "admin") {
|
||||
const user = await db.getUserById(userId);
|
||||
if (user && user.is_owner === "YES") {
|
||||
throw new Error("The owner cannot be removed from the admin role");
|
||||
}
|
||||
}
|
||||
await db.removeUserFromRole(roleId, userId);
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const GetUserPermissions = async (userId: number): Promise<Set<string>> => {
|
||||
const permissionIds = await db.getUserPermissionIds(userId);
|
||||
return new Set(permissionIds);
|
||||
};
|
||||
|
||||
export const RequirePermission = (userPermissions: Set<string>, permissionId: string): void => {
|
||||
if (!userPermissions.has(permissionId)) {
|
||||
throw new Error("You do not have permission to perform this action");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DbImpl from "./dbimpl";
|
||||
import knexOb from "../../../../knexfile.js";
|
||||
import knexOb, { workerKnexOb } from "../../../../knexfile.js";
|
||||
|
||||
const instance: DbImpl = new DbImpl(knexOb);
|
||||
const instance: DbImpl = new DbImpl(knexOb, workerKnexOb);
|
||||
export default instance;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Knex from "knex";
|
||||
import type { Knex as KnexType } from "knex";
|
||||
import { runWithWorkerKnex } from "./poolContext.js";
|
||||
|
||||
// Import all repositories
|
||||
import { MonitoringRepository } from "./repositories/monitoring.js";
|
||||
@@ -29,6 +30,9 @@ export type * from "../types/db.js";
|
||||
*/
|
||||
class DbImpl {
|
||||
private knex: KnexType;
|
||||
// Dedicated pool for background jobs (Postgres/MySQL). Equals `knex` when
|
||||
// there is no separate worker pool (e.g. SQLite).
|
||||
private workerKnex: KnexType;
|
||||
|
||||
// Domain repositories
|
||||
private monitoring!: MonitoringRepository;
|
||||
@@ -48,7 +52,6 @@ class DbImpl {
|
||||
// ============ Monitoring Data ============
|
||||
insertMonitoringData!: MonitoringRepository["insertMonitoringData"];
|
||||
getMonitoringData!: MonitoringRepository["getMonitoringData"];
|
||||
getMonitoringDataAll!: MonitoringRepository["getMonitoringDataAll"];
|
||||
getLatestMonitoringData!: MonitoringRepository["getLatestMonitoringData"];
|
||||
getLatestMonitoringDataN!: MonitoringRepository["getLatestMonitoringDataN"];
|
||||
getMonitoringDataPaginated!: MonitoringRepository["getMonitoringDataPaginated"];
|
||||
@@ -65,11 +68,15 @@ class DbImpl {
|
||||
consecutivelyStatusFor!: MonitoringRepository["consecutivelyStatusFor"];
|
||||
consecutivelyLatencyGreaterThan!: MonitoringRepository["consecutivelyLatencyGreaterThan"];
|
||||
consecutivelyLatencyLessThan!: MonitoringRepository["consecutivelyLatencyLessThan"];
|
||||
getRecentSamplesForConfirmation!: MonitoringRepository["getRecentSamplesForConfirmation"];
|
||||
getLastObservedStatus!: MonitoringRepository["getLastObservedStatus"];
|
||||
backfillConfirmedStatus!: MonitoringRepository["backfillConfirmedStatus"];
|
||||
updateMonitoringData!: MonitoringRepository["updateMonitoringData"];
|
||||
deleteMonitorDataByTag!: MonitoringRepository["deleteMonitorDataByTag"];
|
||||
getStatusCountsByInterval!: MonitoringRepository["getStatusCountsByInterval"];
|
||||
getStatusCountsByIntervalGroupedByMonitor!: MonitoringRepository["getStatusCountsByIntervalGroupedByMonitor"];
|
||||
getStatusCountsForLastN!: MonitoringRepository["getStatusCountsForLastN"];
|
||||
getLastKnownStatus!: MonitoringRepository["getLastKnownStatus"];
|
||||
|
||||
// ============ Monitors ============
|
||||
getMonitorsByTags!: MonitorsRepository["getMonitorsByTags"];
|
||||
@@ -115,11 +122,29 @@ class DbImpl {
|
||||
getUsersPaginated!: UsersRepository["getUsersPaginated"];
|
||||
getTotalUsers!: UsersRepository["getTotalUsers"];
|
||||
updateUserName!: UsersRepository["updateUserName"];
|
||||
updateUserRole!: UsersRepository["updateUserRole"];
|
||||
updateUserRoles!: UsersRepository["updateUserRoles"];
|
||||
updateUserIsActive!: UsersRepository["updateUserIsActive"];
|
||||
updateUserPasswordById!: UsersRepository["updateUserPasswordById"];
|
||||
updateIsVerified!: UsersRepository["updateIsVerified"];
|
||||
|
||||
// ============ Roles ============
|
||||
getRoleById!: UsersRepository["getRoleById"];
|
||||
getAllRoles!: UsersRepository["getAllRoles"];
|
||||
insertRole!: UsersRepository["insertRole"];
|
||||
updateRole!: UsersRepository["updateRole"];
|
||||
deleteRole!: UsersRepository["deleteRole"];
|
||||
getUsersCountByRoleId!: UsersRepository["getUsersCountByRoleId"];
|
||||
migrateUsersRole!: UsersRepository["migrateUsersRole"];
|
||||
getRolePermissions!: UsersRepository["getRolePermissions"];
|
||||
getAllPermissions!: UsersRepository["getAllPermissions"];
|
||||
addRolePermission!: UsersRepository["addRolePermission"];
|
||||
removeRolePermission!: UsersRepository["removeRolePermission"];
|
||||
getUsersByRoleId!: UsersRepository["getUsersByRoleId"];
|
||||
addUserToRole!: UsersRepository["addUserToRole"];
|
||||
removeUserFromRole!: UsersRepository["removeUserFromRole"];
|
||||
getUserPermissionIds!: UsersRepository["getUserPermissionIds"];
|
||||
getUserRoleIds!: UsersRepository["getUserRoleIds"];
|
||||
|
||||
// ============ API Keys ============
|
||||
createNewApiKey!: UsersRepository["createNewApiKey"];
|
||||
updateApiKeyStatus!: UsersRepository["updateApiKeyStatus"];
|
||||
@@ -353,8 +378,11 @@ class DbImpl {
|
||||
deleteEmailTemplate!: EmailTemplateConfigRepository["deleteEmailTemplate"];
|
||||
upsertEmailTemplate!: EmailTemplateConfigRepository["upsertEmailTemplate"];
|
||||
|
||||
constructor(opts: KnexType.Config) {
|
||||
constructor(opts: KnexType.Config, workerOpts?: KnexType.Config | null) {
|
||||
this.knex = Knex(opts);
|
||||
// Separate pool for background jobs when configured (Postgres/MySQL);
|
||||
// otherwise reuse the web pool (SQLite has a single connection).
|
||||
this.workerKnex = workerOpts ? Knex(workerOpts) : this.knex;
|
||||
|
||||
// Initialize repositories
|
||||
this.monitoring = new MonitoringRepository(this.knex);
|
||||
@@ -390,7 +418,6 @@ class DbImpl {
|
||||
private bindMonitoringMethods(): void {
|
||||
this.insertMonitoringData = this.monitoring.insertMonitoringData.bind(this.monitoring);
|
||||
this.getMonitoringData = this.monitoring.getMonitoringData.bind(this.monitoring);
|
||||
this.getMonitoringDataAll = this.monitoring.getMonitoringDataAll.bind(this.monitoring);
|
||||
this.getLatestMonitoringData = this.monitoring.getLatestMonitoringData.bind(this.monitoring);
|
||||
this.getLatestMonitoringDataN = this.monitoring.getLatestMonitoringDataN.bind(this.monitoring);
|
||||
this.getMonitoringDataPaginated = this.monitoring.getMonitoringDataPaginated.bind(this.monitoring);
|
||||
@@ -407,6 +434,9 @@ class DbImpl {
|
||||
this.consecutivelyStatusFor = this.monitoring.consecutivelyStatusFor.bind(this.monitoring);
|
||||
this.consecutivelyLatencyGreaterThan = this.monitoring.consecutivelyLatencyGreaterThan.bind(this.monitoring);
|
||||
this.consecutivelyLatencyLessThan = this.monitoring.consecutivelyLatencyLessThan.bind(this.monitoring);
|
||||
this.getRecentSamplesForConfirmation = this.monitoring.getRecentSamplesForConfirmation.bind(this.monitoring);
|
||||
this.getLastObservedStatus = this.monitoring.getLastObservedStatus.bind(this.monitoring);
|
||||
this.backfillConfirmedStatus = this.monitoring.backfillConfirmedStatus.bind(this.monitoring);
|
||||
this.updateMonitoringData = this.monitoring.updateMonitoringData.bind(this.monitoring);
|
||||
this.deleteMonitorDataByTag = this.monitoring.deleteMonitorDataByTag.bind(this.monitoring);
|
||||
this.getStatusCountsByInterval = this.monitoring.getStatusCountsByInterval.bind(this.monitoring);
|
||||
@@ -414,6 +444,7 @@ class DbImpl {
|
||||
this.monitoring,
|
||||
);
|
||||
this.getStatusCountsForLastN = this.monitoring.getStatusCountsForLastN.bind(this.monitoring);
|
||||
this.getLastKnownStatus = this.monitoring.getLastKnownStatus.bind(this.monitoring);
|
||||
}
|
||||
|
||||
private bindMonitorsMethods(): void {
|
||||
@@ -460,7 +491,7 @@ class DbImpl {
|
||||
this.getUsersPaginated = this.users.getUsersPaginated.bind(this.users);
|
||||
this.getTotalUsers = this.users.getTotalUsers.bind(this.users);
|
||||
this.updateUserName = this.users.updateUserName.bind(this.users);
|
||||
this.updateUserRole = this.users.updateUserRole.bind(this.users);
|
||||
this.updateUserRoles = this.users.updateUserRoles.bind(this.users);
|
||||
this.updateUserIsActive = this.users.updateUserIsActive.bind(this.users);
|
||||
this.updateUserPasswordById = this.users.updateUserPasswordById.bind(this.users);
|
||||
this.updateIsVerified = this.users.updateIsVerified.bind(this.users);
|
||||
@@ -469,6 +500,24 @@ class DbImpl {
|
||||
this.deleteApiKey = this.users.deleteApiKey.bind(this.users);
|
||||
this.getApiKeyByHashedKey = this.users.getApiKeyByHashedKey.bind(this.users);
|
||||
this.getAllApiKeys = this.users.getAllApiKeys.bind(this.users);
|
||||
|
||||
// Roles
|
||||
this.getRoleById = this.users.getRoleById.bind(this.users);
|
||||
this.getAllRoles = this.users.getAllRoles.bind(this.users);
|
||||
this.insertRole = this.users.insertRole.bind(this.users);
|
||||
this.updateRole = this.users.updateRole.bind(this.users);
|
||||
this.deleteRole = this.users.deleteRole.bind(this.users);
|
||||
this.getUsersCountByRoleId = this.users.getUsersCountByRoleId.bind(this.users);
|
||||
this.migrateUsersRole = this.users.migrateUsersRole.bind(this.users);
|
||||
this.getRolePermissions = this.users.getRolePermissions.bind(this.users);
|
||||
this.getAllPermissions = this.users.getAllPermissions.bind(this.users);
|
||||
this.addRolePermission = this.users.addRolePermission.bind(this.users);
|
||||
this.removeRolePermission = this.users.removeRolePermission.bind(this.users);
|
||||
this.getUsersByRoleId = this.users.getUsersByRoleId.bind(this.users);
|
||||
this.addUserToRole = this.users.addUserToRole.bind(this.users);
|
||||
this.removeUserFromRole = this.users.removeUserFromRole.bind(this.users);
|
||||
this.getUserPermissionIds = this.users.getUserPermissionIds.bind(this.users);
|
||||
this.getUserRoleIds = this.users.getUserRoleIds.bind(this.users);
|
||||
}
|
||||
|
||||
private bindSiteDataMethods(): void {
|
||||
@@ -798,8 +847,30 @@ class DbImpl {
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
/**
|
||||
* Runs `fn` with all repository queries routed to the worker connection pool.
|
||||
* Wrap background work (BullMQ job processors, schedulers) with this so a
|
||||
* burst of jobs cannot exhaust the web pool that serves page loads.
|
||||
*/
|
||||
runInWorkerContext<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return runWithWorkerKnex(this.workerKnex, fn);
|
||||
}
|
||||
|
||||
/** Probes database connectivity with a trivial query. Never throws. */
|
||||
async ping(): Promise<boolean> {
|
||||
try {
|
||||
await this.knex.raw("select 1");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
return await this.knex.destroy();
|
||||
await this.knex.destroy();
|
||||
if (this.workerKnex !== this.knex) {
|
||||
await this.workerKnex.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import type { Knex as KnexType } from "knex";
|
||||
|
||||
// Per-execution-context selection of the database connection pool.
|
||||
//
|
||||
// Kener runs SvelteKit requests, the cron scheduler, and the BullMQ workers in
|
||||
// a single process, all sharing one Knex instance. A burst of background jobs
|
||||
// could therefore exhaust the connection pool and time out user-facing page
|
||||
// loads (KnexTimeoutError on acquire). To prevent that, background work runs
|
||||
// against a dedicated worker pool: queues/q.ts wraps every job processor in
|
||||
// runWithWorkerKnex(), and BaseRepository reads getWorkerKnex() so its queries
|
||||
// route to that pool. Anything outside a job (requests, startup, migrations)
|
||||
// has no store set and falls back to the web pool.
|
||||
//
|
||||
// See knexfile.ts for pool sizing and docs .../setup/database-setup.md.
|
||||
const workerKnexStorage = new AsyncLocalStorage<KnexType>();
|
||||
|
||||
/** Runs `fn` with all repository queries routed to the worker pool `knex`. */
|
||||
export function runWithWorkerKnex<T>(knex: KnexType, fn: () => Promise<T>): Promise<T> {
|
||||
return workerKnexStorage.run(knex, fn);
|
||||
}
|
||||
|
||||
/** The worker pool for the current context, or undefined when not in a job. */
|
||||
export function getWorkerKnex(): KnexType | undefined {
|
||||
return workerKnexStorage.getStore();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Knex as KnexType } from "knex";
|
||||
import { getWorkerKnex } from "../poolContext.js";
|
||||
|
||||
// Filter types for queries
|
||||
export interface MonitorFilter {
|
||||
@@ -35,9 +36,22 @@ export interface CountResult {
|
||||
* Base repository class that provides access to the Knex instance
|
||||
*/
|
||||
export abstract class BaseRepository {
|
||||
protected knex: KnexType;
|
||||
private readonly fallbackKnex: KnexType;
|
||||
|
||||
constructor(knex: KnexType) {
|
||||
this.knex = knex;
|
||||
this.fallbackKnex = knex;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Knex instance for the current execution context.
|
||||
*
|
||||
* Background jobs run inside a worker-pool context (set in queues/q.ts), so
|
||||
* their queries use the dedicated worker connection pool. Everything else —
|
||||
* SvelteKit requests, startup — falls back to the web pool this repository
|
||||
* was constructed with. This keeps a burst of background jobs from exhausting
|
||||
* the connections that serve page loads. See poolContext.ts and knexfile.ts.
|
||||
*/
|
||||
protected get knex(): KnexType {
|
||||
return getWorkerKnex() ?? this.fallbackKnex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,6 +483,7 @@ export class MaintenancesRepository extends BaseRepository {
|
||||
"maintenances_events.created_at",
|
||||
"maintenances_events.updated_at",
|
||||
"maintenances_events.status as status",
|
||||
"maintenances.is_global as is_global",
|
||||
)
|
||||
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
|
||||
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
|
||||
@@ -516,6 +517,7 @@ export class MaintenancesRepository extends BaseRepository {
|
||||
"maintenances_events.created_at",
|
||||
"maintenances_events.updated_at",
|
||||
"maintenances_events.status as status",
|
||||
"maintenances.is_global as is_global",
|
||||
)
|
||||
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
|
||||
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
|
||||
@@ -564,6 +566,7 @@ export class MaintenancesRepository extends BaseRepository {
|
||||
"monitors.is_hidden as monitor_is_hidden",
|
||||
"maintenances_events.created_at",
|
||||
"maintenances_events.updated_at",
|
||||
"maintenances.is_global as is_global",
|
||||
)
|
||||
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
|
||||
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
|
||||
@@ -606,6 +609,7 @@ export class MaintenancesRepository extends BaseRepository {
|
||||
"maintenances_events.created_at",
|
||||
"maintenances_events.updated_at",
|
||||
"maintenances_events.status as status",
|
||||
"maintenances.is_global as is_global",
|
||||
)
|
||||
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
|
||||
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
|
||||
@@ -648,6 +652,7 @@ export class MaintenancesRepository extends BaseRepository {
|
||||
"maintenances_events.created_at",
|
||||
"maintenances_events.updated_at",
|
||||
"maintenances_events.status as status",
|
||||
"maintenances.is_global as is_global",
|
||||
)
|
||||
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
|
||||
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
|
||||
@@ -686,6 +691,7 @@ export class MaintenancesRepository extends BaseRepository {
|
||||
"maintenances_events.created_at",
|
||||
"maintenances_events.updated_at",
|
||||
"maintenances_events.status as status",
|
||||
"maintenances.is_global as is_global",
|
||||
)
|
||||
.join("maintenances", "maintenances_events.maintenance_id", "maintenances.id")
|
||||
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
|
||||
@@ -714,6 +720,7 @@ export class MaintenancesRepository extends BaseRepository {
|
||||
start_date_time: row.start_date_time,
|
||||
end_date_time: row.end_date_time,
|
||||
status: row.status,
|
||||
is_global: row.is_global,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
monitors: [],
|
||||
|
||||
@@ -141,9 +141,17 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a monitor alert config by ID
|
||||
* Delete a monitor alert config by ID, including all child rows.
|
||||
*
|
||||
* Child rows are removed explicitly even though FK cascades are declared:
|
||||
* SQLite never enforces them (foreign_keys pragma is off), so relying on
|
||||
* CASCADE orphans children on the default deployment. See
|
||||
* docs/adr/0008-explicit-deletes-over-fk-cascades.md.
|
||||
*/
|
||||
async deleteMonitorAlertConfig(id: number): Promise<number> {
|
||||
await this.knex("monitor_alerts_v2").where({ config_id: id }).del();
|
||||
await this.knex("monitor_alerts_config_triggers").where({ monitor_alerts_id: id }).del();
|
||||
await this.knex("monitor_alerts_config_monitors").where({ monitor_alerts_id: id }).del();
|
||||
return await this.knex("monitor_alerts_config").where({ id }).del();
|
||||
}
|
||||
|
||||
@@ -158,7 +166,11 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
|
||||
if (configIds.length === 0) return 0;
|
||||
|
||||
// Remove the monitor from the junction table
|
||||
// Remove the monitor from the junction table, along with its per-monitor
|
||||
// alert state — a shared config survives the detach, but its v2 rows for
|
||||
// this tag would otherwise dangle (see deleteMonitorAlertConfig on why
|
||||
// FK cascades can't be relied on)
|
||||
await this.knex("monitor_alerts_v2").where({ monitor_tag: monitorTag }).del();
|
||||
await this.knex("monitor_alerts_config_monitors").where({ monitor_tag: monitorTag }).del();
|
||||
|
||||
// Delete any configs that now have zero monitors
|
||||
@@ -170,7 +182,7 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
.where({ monitor_alerts_id: id })
|
||||
.first<CountResult>();
|
||||
if (Number(remainingMonitors?.count) === 0) {
|
||||
await this.knex("monitor_alerts_config").where({ id }).del();
|
||||
await this.deleteMonitorAlertConfig(id);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,18 +10,42 @@ import type {
|
||||
TimestampStatusCountByMonitor,
|
||||
} from "../../types/db.js";
|
||||
|
||||
/**
|
||||
* Sample types alert evaluation can see (see docs/adr/0005-alerts-evaluate-alert-visible-samples.md).
|
||||
* Exactly the types written by flows that enqueue alert evaluation: scheduler checks
|
||||
* (REALTIME/ERROR/TIMEOUT), default-status fill (DEFAULT_STATUS), and data-API pushes (MANUAL).
|
||||
* SIGNAL rows (raw heartbeat receipts) and INCIDENT/MAINTENANCE overlays stay invisible, so the
|
||||
* alert window freezes during manual overlays instead of triggering or resolving on them.
|
||||
*/
|
||||
const ALERT_VISIBLE_TYPES = [GC.REALTIME, GC.ERROR, GC.TIMEOUT, GC.MANUAL, GC.DEFAULT_STATUS];
|
||||
|
||||
/**
|
||||
* Scheduled-check sample types that count toward a monitor's Confirmation Threshold
|
||||
* (issue #712). Intentionally narrower than ALERT_VISIBLE_TYPES: MANUAL pushes
|
||||
* and DEFAULT_STATUS fill stay transparent to threshold counting.
|
||||
*/
|
||||
const OBSERVED_CHECK_TYPES = [GC.REALTIME, GC.TIMEOUT, GC.ERROR];
|
||||
|
||||
/**
|
||||
* Overlay sample types that FREEZE Confirmation Threshold counting (issue #712):
|
||||
* while one is active the count does not advance, and it acts as a hard boundary the
|
||||
* pending run cannot cross. Included in the confirmation lookback (unlike MANUAL/DEFAULT,
|
||||
* which stay transparent) so the resolver can detect the boundary.
|
||||
*/
|
||||
const OVERLAY_TYPES = [GC.INCIDENT, GC.MAINTENANCE];
|
||||
|
||||
/**
|
||||
* Repository for monitoring data operations
|
||||
*/
|
||||
export class MonitoringRepository extends BaseRepository {
|
||||
async insertMonitoringData(data: MonitoringDataInsert): Promise<MonitoringData | null> {
|
||||
const { monitor_tag, timestamp, status, latency, type, error_message } = data;
|
||||
const { monitor_tag, timestamp, status, latency, type, error_message, raw_status } = data;
|
||||
|
||||
// Perform insert/update - works across PostgreSQL, MySQL, and SQLite
|
||||
await this.knex("monitoring_data")
|
||||
.insert({ monitor_tag, timestamp, status, latency, type, error_message })
|
||||
.insert({ monitor_tag, timestamp, status, latency, type, error_message, raw_status })
|
||||
.onConflict(["monitor_tag", "timestamp"])
|
||||
.merge({ status, latency, type, error_message });
|
||||
.merge({ status, latency, type, error_message, raw_status });
|
||||
|
||||
// Query and return the inserted/updated record (works consistently across all databases)
|
||||
const record = await this.knex("monitoring_data")
|
||||
@@ -40,27 +64,6 @@ export class MonitoringRepository extends BaseRepository {
|
||||
.orderBy("timestamp", "asc");
|
||||
}
|
||||
|
||||
// Groups by timestamp and applies priority: DOWN > DEGRADED > UP
|
||||
async getMonitoringDataAll(monitor_tags: string[], start: number, end: number): Promise<MonitoringData[]> {
|
||||
return await this.knex("monitoring_data")
|
||||
.select(
|
||||
"timestamp",
|
||||
this.knex.raw(`
|
||||
CASE
|
||||
WHEN MAX(CASE WHEN status = 'DOWN' THEN 1 ELSE 0 END) = 1 THEN 'DOWN'
|
||||
WHEN MAX(CASE WHEN status = 'DEGRADED' THEN 1 ELSE 0 END) = 1 THEN 'DEGRADED'
|
||||
ELSE 'UP'
|
||||
END as status
|
||||
`),
|
||||
)
|
||||
.whereIn("monitor_tag", monitor_tags)
|
||||
.where("timestamp", ">=", start)
|
||||
.where("timestamp", "<=", end)
|
||||
.whereNotNull("status")
|
||||
.groupBy("timestamp")
|
||||
.orderBy("timestamp", "asc");
|
||||
}
|
||||
|
||||
async getLatestMonitoringData(monitor_tag: string): Promise<MonitoringData | undefined> {
|
||||
return await this.knex("monitoring_data")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
@@ -262,7 +265,7 @@ export class MonitoringRepository extends BaseRepository {
|
||||
qb.select("*")
|
||||
.from("monitoring_data")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
.andWhere("type", "=", GC.REALTIME)
|
||||
.whereIn("type", ALERT_VISIBLE_TYPES)
|
||||
.orderBy("timestamp", "desc")
|
||||
.limit(lastX);
|
||||
})
|
||||
@@ -288,7 +291,7 @@ export class MonitoringRepository extends BaseRepository {
|
||||
qb.select("*")
|
||||
.from("monitoring_data")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
.andWhere("type", "=", GC.REALTIME)
|
||||
.whereIn("type", ALERT_VISIBLE_TYPES)
|
||||
.orderBy("timestamp", "desc")
|
||||
.limit(lastX);
|
||||
})
|
||||
@@ -310,7 +313,7 @@ export class MonitoringRepository extends BaseRepository {
|
||||
qb.select("*")
|
||||
.from("monitoring_data")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
.andWhere("type", "=", GC.REALTIME)
|
||||
.whereIn("type", ALERT_VISIBLE_TYPES)
|
||||
.orderBy("timestamp", "desc")
|
||||
.limit(lastX);
|
||||
})
|
||||
@@ -326,6 +329,107 @@ export class MonitoringRepository extends BaseRepository {
|
||||
return result.is_recovered === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent samples the Confirmation Threshold resolver needs, newest first: scheduled-check
|
||||
* observations (REALTIME/TIMEOUT/ERROR) plus incident/maintenance overlays. MANUAL pushes
|
||||
* and DEFAULT fill are excluded — they stay transparent to the counter. Returns `type` so
|
||||
* the resolver can stop at overlay rows (freeze). Observations whose status is NO_DATA are
|
||||
* excluded entirely (neutral — they neither advance nor reset the count and must not consume lookback slots).
|
||||
*/
|
||||
async getRecentSamplesForConfirmation(
|
||||
monitor_tag: string,
|
||||
beforeTs: number,
|
||||
limit: number,
|
||||
): Promise<Array<{ timestamp: number; status: string | null; raw_status: string | null; type: string | null }>> {
|
||||
return await this.knex("monitoring_data")
|
||||
.select("timestamp", "status", "raw_status", "type")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
.where("timestamp", "<", beforeTs)
|
||||
.whereIn("type", [...OBSERVED_CHECK_TYPES, ...OVERLAY_TYPES])
|
||||
.whereNot("status", GC.NO_DATA)
|
||||
.orderBy("timestamp", "desc")
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* The committed status of the most recent real scheduled-check observation before `beforeTs`
|
||||
* — the Confirmation Threshold "anchor" (the side currently shown). Looks past overlays,
|
||||
* MANUAL/DEFAULT, and NO_DATA so a long incident/maintenance window can never hide the anchor
|
||||
* (issue #712). Returns null when there is no prior observation (cold start).
|
||||
*/
|
||||
async getLastObservedStatus(monitor_tag: string, beforeTs: number): Promise<string | null> {
|
||||
const row = await this.knex("monitoring_data")
|
||||
.select("status")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
.where("timestamp", "<", beforeTs)
|
||||
.whereIn("type", OBSERVED_CHECK_TYPES)
|
||||
.whereNot("status", GC.NO_DATA)
|
||||
.orderBy("timestamp", "desc")
|
||||
.limit(1)
|
||||
.first();
|
||||
return row ? (row.status ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill a confirmed status flip: set each row's committed status to its observed raw_status.
|
||||
* `confirmThreshold` is the number of consecutive checks that confirmed the flip — when it is a
|
||||
* number the run resolved to an unhealthy side and a per-row note ("Down"/"Degraded confirmed
|
||||
* after N consecutive checks", matching each row's own severity) is appended to the existing
|
||||
* error text; when it is null the run resolved to UP (recovery) and the error text is cleared.
|
||||
*/
|
||||
async backfillConfirmedStatus(
|
||||
monitor_tag: string,
|
||||
timestamps: number[],
|
||||
confirmThreshold: number | null,
|
||||
): Promise<number> {
|
||||
if (timestamps.length === 0) return 0;
|
||||
|
||||
// Recovery (confirmed UP): rows become the UP side — clear any held error text in one update.
|
||||
if (confirmThreshold === null) {
|
||||
return await this.knex("monitoring_data")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
.whereIn("timestamp", timestamps)
|
||||
.whereNotNull("raw_status")
|
||||
.update({
|
||||
status: this.knex.ref("raw_status"),
|
||||
error_message: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Confirmed unhealthy: set each row's status from its observed raw_status and APPEND a
|
||||
// severity-matched confirmation note to the existing error text (preserving the observed
|
||||
// failure reason). Done per-row for portable string concatenation (|| vs CONCAT differ across
|
||||
// SQLite/PG/MySQL), per-row severity wording, and idempotency if the backfill is replayed.
|
||||
// The whole read+update window runs in one transaction — a confirmation flip is one logical
|
||||
// write, so it must not leave the window half-confirmed/half-held if a row update fails.
|
||||
return await this.knex.transaction(async (trx: KnexType.Transaction) => {
|
||||
const rows = await trx("monitoring_data")
|
||||
.select("timestamp", "error_message", "raw_status")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
.whereIn("timestamp", timestamps)
|
||||
.whereNotNull("raw_status");
|
||||
|
||||
let updated = 0;
|
||||
for (const row of rows) {
|
||||
const severity = row.raw_status === GC.DEGRADED ? "Degraded" : "Down";
|
||||
const note = `${severity} confirmed after ${confirmThreshold} consecutive checks`;
|
||||
const existing: string | null = row.error_message;
|
||||
let nextMessage: string;
|
||||
if (!existing) {
|
||||
nextMessage = note;
|
||||
} else if (existing.indexOf(note) !== -1) {
|
||||
nextMessage = existing; // already appended — keep idempotent
|
||||
} else {
|
||||
nextMessage = `${existing} | ${note}`;
|
||||
}
|
||||
updated += await trx("monitoring_data")
|
||||
.where({ monitor_tag, timestamp: row.timestamp })
|
||||
.update({ status: row.raw_status, error_message: nextMessage });
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
async updateMonitoringData(
|
||||
monitor_tag: string,
|
||||
start: number,
|
||||
@@ -587,4 +691,9 @@ export class MonitoringRepository extends BaseRepository {
|
||||
minLatency: Number(result?.min_latency) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
//get the last known status for a monitor
|
||||
async getLastKnownStatus(monitor_tag: string): Promise<MonitoringData | undefined> {
|
||||
return await this.knex("monitoring_data").where("monitor_tag", monitor_tag).orderBy("timestamp", "desc").first();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,17 @@ import type { Knex as KnexType } from "knex";
|
||||
import { BaseRepository, type MonitorFilter, type CountResult } from "./base.js";
|
||||
import type { MonitorRecord, MonitorRecordInsert } from "../../types/db.js";
|
||||
|
||||
/**
|
||||
* Clamp the Confirmation Threshold to its 1–60 invariant at the data layer, so the bound holds
|
||||
* for every app write path (v4 API, manage API, clone, group), not only the v4 API validator.
|
||||
* A non-finite/missing value defaults to 1 (off).
|
||||
*/
|
||||
function clampConfirmationThreshold(value: number | null | undefined): number {
|
||||
const n = Math.round(Number(value));
|
||||
if (!Number.isFinite(n)) return 1;
|
||||
return Math.min(60, Math.max(1, n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository for monitors CRUD operations
|
||||
*/
|
||||
@@ -28,6 +39,7 @@ export class MonitorsRepository extends BaseRepository {
|
||||
type_data: data.type_data,
|
||||
day_degraded_minimum_count: data.day_degraded_minimum_count,
|
||||
day_down_minimum_count: data.day_down_minimum_count,
|
||||
confirmation_threshold: clampConfirmationThreshold(data.confirmation_threshold),
|
||||
include_degraded_in_downtime: data.include_degraded_in_downtime,
|
||||
is_hidden: data.is_hidden || "NO",
|
||||
monitor_settings_json: data.monitor_settings_json,
|
||||
@@ -51,6 +63,7 @@ export class MonitorsRepository extends BaseRepository {
|
||||
type_data: data.type_data,
|
||||
day_degraded_minimum_count: data.day_degraded_minimum_count,
|
||||
day_down_minimum_count: data.day_down_minimum_count,
|
||||
confirmation_threshold: clampConfirmationThreshold(data.confirmation_threshold),
|
||||
include_degraded_in_downtime: data.include_degraded_in_downtime,
|
||||
is_hidden: data.is_hidden,
|
||||
monitor_settings_json: data.monitor_settings_json,
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { BaseRepository, type CountResult } from "./base.js";
|
||||
import type { UserRecordInsert, UserRecordPublic, ApiKeyRecord, ApiKeyRecordInsert } from "../../types/db.js";
|
||||
import type {
|
||||
UserRecordInsert,
|
||||
UserRecordPublic,
|
||||
ApiKeyRecord,
|
||||
ApiKeyRecordInsert,
|
||||
RoleRecord,
|
||||
RolePermissionRecord,
|
||||
UserRoleRecord,
|
||||
} from "../../types/db.js";
|
||||
import { GetDbType } from "../../tool.js";
|
||||
|
||||
/**
|
||||
* Repository for users, API keys operations
|
||||
@@ -11,11 +20,46 @@ export class UsersRepository extends BaseRepository {
|
||||
return await this.knex("users").count("* as count").first<CountResult>();
|
||||
}
|
||||
|
||||
private readonly userColumns = [
|
||||
"id",
|
||||
"email",
|
||||
"name",
|
||||
"is_active",
|
||||
"is_verified",
|
||||
"is_owner",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
] as const;
|
||||
|
||||
private async enrichWithRoleIds(user: Record<string, unknown>): Promise<UserRecordPublic> {
|
||||
const roleIds = await this.getUserRoleIds(user.id as number);
|
||||
return { ...user, role_ids: roleIds } as UserRecordPublic;
|
||||
}
|
||||
|
||||
private async enrichManyWithRoleIds(users: Record<string, unknown>[]): Promise<UserRecordPublic[]> {
|
||||
if (users.length === 0) return [];
|
||||
const userIds = users.map((u) => u.id as number);
|
||||
const roleRows = await this.knex("users_roles")
|
||||
.join("roles", "users_roles.roles_id", "roles.id")
|
||||
.whereIn("users_roles.users_id", userIds)
|
||||
.where("roles.status", "ACTIVE")
|
||||
.select("users_roles.users_id as users_id", "roles.id as role_id");
|
||||
const roleMap = new Map<number, string[]>();
|
||||
for (const row of roleRows) {
|
||||
const list = roleMap.get(row.users_id) || [];
|
||||
list.push(row.role_id);
|
||||
roleMap.set(row.users_id, list);
|
||||
}
|
||||
return users.map((u) => ({ ...u, role_ids: roleMap.get(u.id as number) || [] }) as UserRecordPublic);
|
||||
}
|
||||
|
||||
async getUserByEmail(email: string): Promise<UserRecordPublic | undefined> {
|
||||
return await this.knex("users")
|
||||
.select("id", "email", "name", "is_active", "is_verified", "is_owner", "role", "created_at", "updated_at")
|
||||
const row = await this.knex("users")
|
||||
.select(...this.userColumns)
|
||||
.where("email", email)
|
||||
.first();
|
||||
if (!row) return undefined;
|
||||
return await this.enrichWithRoleIds(row);
|
||||
}
|
||||
|
||||
async getUserPasswordHashById(id: number): Promise<{ password_hash: string } | undefined> {
|
||||
@@ -28,22 +72,43 @@ export class UsersRepository extends BaseRepository {
|
||||
}
|
||||
|
||||
async getUserById(id: number): Promise<UserRecordPublic | undefined> {
|
||||
return await this.knex("users")
|
||||
.select("id", "email", "name", "is_active", "is_verified", "is_owner", "role", "created_at", "updated_at")
|
||||
const row = await this.knex("users")
|
||||
.select(...this.userColumns)
|
||||
.where("id", id)
|
||||
.first();
|
||||
if (!row) return undefined;
|
||||
return await this.enrichWithRoleIds(row);
|
||||
}
|
||||
|
||||
async insertUser(data: UserRecordInsert): Promise<number[]> {
|
||||
return await this.knex("users").insert({
|
||||
const dbType = GetDbType();
|
||||
|
||||
const insertData = {
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
password_hash: data.password_hash,
|
||||
role: data.role,
|
||||
is_owner: data.is_owner || "NO",
|
||||
created_at: this.knex.fn.now(),
|
||||
updated_at: this.knex.fn.now(),
|
||||
});
|
||||
};
|
||||
|
||||
let userId: number;
|
||||
if (dbType === "postgresql") {
|
||||
const [row] = await this.knex("users").insert(insertData).returning("id");
|
||||
userId = typeof row === "object" ? (row as { id: number }).id : (row as number);
|
||||
} else {
|
||||
const result = await this.knex("users").insert(insertData);
|
||||
userId = result[0];
|
||||
}
|
||||
|
||||
if (data.role_ids && data.role_ids.length > 0) {
|
||||
const roleInserts = data.role_ids.map((roleId) => ({
|
||||
users_id: userId,
|
||||
roles_id: roleId,
|
||||
}));
|
||||
await this.knex("users_roles").insert(roleInserts);
|
||||
}
|
||||
return [userId];
|
||||
}
|
||||
|
||||
async updateUserPassword(data: { id: number; password_hash: string }): Promise<number> {
|
||||
@@ -54,21 +119,31 @@ export class UsersRepository extends BaseRepository {
|
||||
}
|
||||
|
||||
async getAllUsers(): Promise<UserRecordPublic[]> {
|
||||
return await this.knex("users")
|
||||
.select("id", "email", "name", "role", "is_active", "is_verified", "is_owner", "created_at", "updated_at")
|
||||
const rows = await this.knex("users")
|
||||
.select(...this.userColumns)
|
||||
.orderBy("created_at", "desc");
|
||||
return await this.enrichManyWithRoleIds(rows);
|
||||
}
|
||||
|
||||
async getUsersPaginated(page: number, limit: number): Promise<UserRecordPublic[]> {
|
||||
return await this.knex("users")
|
||||
.select("id", "email", "name", "role", "is_active", "is_verified", "is_owner", "created_at", "updated_at")
|
||||
async getUsersPaginated(page: number, limit: number, filter?: { is_active?: number }): Promise<UserRecordPublic[]> {
|
||||
const query = this.knex("users")
|
||||
.select(...this.userColumns)
|
||||
.orderBy("created_at", "desc")
|
||||
.limit(limit)
|
||||
.offset((page - 1) * limit);
|
||||
if (filter?.is_active !== undefined) {
|
||||
query.where("is_active", filter.is_active);
|
||||
}
|
||||
const rows = await query;
|
||||
return await this.enrichManyWithRoleIds(rows);
|
||||
}
|
||||
|
||||
async getTotalUsers(): Promise<CountResult | undefined> {
|
||||
return await this.knex("users").count("* as count").first<CountResult>();
|
||||
async getTotalUsers(filter?: { is_active?: number }): Promise<CountResult | undefined> {
|
||||
const query = this.knex("users").count("* as count");
|
||||
if (filter?.is_active !== undefined) {
|
||||
query.where("is_active", filter.is_active);
|
||||
}
|
||||
return await query.first<CountResult>();
|
||||
}
|
||||
|
||||
async updateUserName(id: number, name: string): Promise<number> {
|
||||
@@ -78,11 +153,18 @@ export class UsersRepository extends BaseRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async updateUserRole(id: number, role: string): Promise<number> {
|
||||
return await this.knex("users").where({ id }).update({
|
||||
role,
|
||||
updated_at: this.knex.fn.now(),
|
||||
});
|
||||
async updateUserRoles(id: number, roleIds: string[]): Promise<void> {
|
||||
await this.knex("users_roles").where("users_id", id).delete();
|
||||
if (roleIds.length > 0) {
|
||||
const inserts = roleIds.map((roleId) => ({
|
||||
users_id: id,
|
||||
roles_id: roleId,
|
||||
created_at: this.knex.fn.now(),
|
||||
updated_at: this.knex.fn.now(),
|
||||
}));
|
||||
await this.knex("users_roles").insert(inserts);
|
||||
}
|
||||
await this.knex("users").where({ id }).update({ updated_at: this.knex.fn.now() });
|
||||
}
|
||||
|
||||
async updateUserIsActive(id: number, is_active: number): Promise<number> {
|
||||
@@ -138,4 +220,140 @@ export class UsersRepository extends BaseRepository {
|
||||
}
|
||||
|
||||
// ============ Invitations ============
|
||||
|
||||
// ============ Roles ============
|
||||
|
||||
async getRoleById(id: string): Promise<RoleRecord | undefined> {
|
||||
return await this.knex("roles").where("id", id).first();
|
||||
}
|
||||
|
||||
async getAllRoles(): Promise<RoleRecord[]> {
|
||||
return await this.knex("roles").orderBy("created_at", "asc");
|
||||
}
|
||||
|
||||
async insertRole(data: { id: string; role_name: string; readonly?: number }): Promise<void> {
|
||||
await this.knex("roles").insert({
|
||||
id: data.id,
|
||||
role_name: data.role_name,
|
||||
readonly: data.readonly ?? 0,
|
||||
status: "ACTIVE",
|
||||
created_at: this.knex.fn.now(),
|
||||
updated_at: this.knex.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async updateRole(id: string, data: { role_name?: string; status?: string }): Promise<number> {
|
||||
const updateData: Record<string, unknown> = { updated_at: this.knex.fn.now() };
|
||||
if (data.role_name !== undefined) updateData.role_name = data.role_name;
|
||||
if (data.status !== undefined) updateData.status = data.status;
|
||||
return await this.knex("roles").where("id", id).update(updateData);
|
||||
}
|
||||
|
||||
async deleteRole(id: string): Promise<number> {
|
||||
return await this.knex("roles").where("id", id).delete();
|
||||
}
|
||||
|
||||
async getUsersCountByRoleId(roleId: string): Promise<number> {
|
||||
const result = await this.knex("users_roles").where("roles_id", roleId).count("* as count").first<CountResult>();
|
||||
return result ? Number(result.count) : 0;
|
||||
}
|
||||
|
||||
async migrateUsersRole(fromRoleId: string, toRoleId: string): Promise<void> {
|
||||
// Find users who already have the target role to avoid duplicate PK
|
||||
const usersWithTarget = this.knex("users_roles").where("roles_id", toRoleId).select("users_id");
|
||||
|
||||
// Update users who don't already have the target role
|
||||
await this.knex("users_roles").where("roles_id", fromRoleId).whereNotIn("users_id", usersWithTarget).update({
|
||||
roles_id: toRoleId,
|
||||
updated_at: this.knex.fn.now(),
|
||||
});
|
||||
|
||||
// Delete remaining assignments (users who already had the target role)
|
||||
await this.knex("users_roles").where("roles_id", fromRoleId).delete();
|
||||
}
|
||||
|
||||
// ============ Role Permissions ============
|
||||
|
||||
async getRolePermissions(roleId: string): Promise<RolePermissionRecord[]> {
|
||||
return await this.knex("roles_permissions").where("roles_id", roleId);
|
||||
}
|
||||
|
||||
async getAllPermissions(): Promise<Array<{ id: string; permission_name: string }>> {
|
||||
return await this.knex("permissions").select("id", "permission_name").orderBy("id", "asc");
|
||||
}
|
||||
|
||||
async addRolePermission(roleId: string, permissionId: string): Promise<void> {
|
||||
await this.knex("roles_permissions").insert({
|
||||
roles_id: roleId,
|
||||
permissions_id: permissionId,
|
||||
status: "ACTIVE",
|
||||
created_at: this.knex.fn.now(),
|
||||
updated_at: this.knex.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async removeRolePermission(roleId: string, permissionId: string): Promise<number> {
|
||||
return await this.knex("roles_permissions").where({ roles_id: roleId, permissions_id: permissionId }).delete();
|
||||
}
|
||||
|
||||
// ============ Role Users ============
|
||||
|
||||
async getUsersByRoleId(roleId: string): Promise<Array<UserRecordPublic & { roles_id: string }>> {
|
||||
const rows = await this.knex("users_roles")
|
||||
.join("users", "users_roles.users_id", "users.id")
|
||||
.where("users_roles.roles_id", roleId)
|
||||
.select(
|
||||
"users.id",
|
||||
"users.email",
|
||||
"users.name",
|
||||
"users.is_active",
|
||||
"users.is_verified",
|
||||
"users.is_owner",
|
||||
"users.created_at",
|
||||
"users.updated_at",
|
||||
"users_roles.roles_id",
|
||||
);
|
||||
const enriched = await this.enrichManyWithRoleIds(rows);
|
||||
return enriched.map((u, i) => ({ ...u, roles_id: rows[i].roles_id }));
|
||||
}
|
||||
|
||||
async addUserToRole(roleId: string, userId: number): Promise<void> {
|
||||
await this.knex("users_roles").insert({
|
||||
roles_id: roleId,
|
||||
users_id: userId,
|
||||
created_at: this.knex.fn.now(),
|
||||
updated_at: this.knex.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async removeUserFromRole(roleId: string, userId: number): Promise<number> {
|
||||
return await this.knex("users_roles").where({ roles_id: roleId, users_id: userId }).delete();
|
||||
}
|
||||
|
||||
async getUserRoleIds(userId: number): Promise<string[]> {
|
||||
const rows = await this.knex("users_roles")
|
||||
.join("roles", function () {
|
||||
this.on("users_roles.roles_id", "roles.id");
|
||||
})
|
||||
.where("users_roles.users_id", userId)
|
||||
.where("roles.status", "ACTIVE")
|
||||
.distinct("roles.id as id")
|
||||
.select();
|
||||
return rows.map((r: { id: string }) => r.id);
|
||||
}
|
||||
|
||||
async getUserPermissionIds(userId: number): Promise<string[]> {
|
||||
const knex = this.knex;
|
||||
const rows = await knex("users_roles")
|
||||
.join("roles", function () {
|
||||
this.on("users_roles.roles_id", "roles.id").andOn("roles.status", knex.raw("?", ["ACTIVE"]));
|
||||
})
|
||||
.join("roles_permissions", function () {
|
||||
this.on("roles_permissions.roles_id", "roles.id").andOn("roles_permissions.status", knex.raw("?", ["ACTIVE"]));
|
||||
})
|
||||
.where("users_roles.users_id", userId)
|
||||
.distinct("roles_permissions.permissions_id as id")
|
||||
.select();
|
||||
return rows.map((r: { id: string }) => r.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,12 +135,14 @@ const seedSiteData = {
|
||||
subMenuOptions: {
|
||||
showShareBadgeMonitor: true,
|
||||
showShareEmbedMonitor: true,
|
||||
showRssFeed: true,
|
||||
},
|
||||
dataRetentionPolicy: {
|
||||
enabled: true,
|
||||
retentionDays: 90,
|
||||
},
|
||||
eventDisplaySettings: {
|
||||
showInlineEvents: false,
|
||||
incidents: {
|
||||
enabled: true,
|
||||
ongoing: { show: true },
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import type { PageSettings, PageSettingsPatch } from "$lib/types/api";
|
||||
import GC from "$lib/global-constants";
|
||||
|
||||
// Stored page_settings_json keys differ from the API contract for the meta
|
||||
// fields: the manage UI writes camelCase (metaPageTitle, metaPageDescription,
|
||||
// socialPagePreviewImage) while the v4 API exposes snake_case. The mapping
|
||||
// lives here, at the storage boundary.
|
||||
interface StoredPageSettings {
|
||||
incidents?: unknown;
|
||||
include_maintenances?: unknown;
|
||||
monitor_status_history_days?: { desktop?: number; mobile?: number };
|
||||
monitor_layout_style?: string;
|
||||
metaPageTitle?: string;
|
||||
metaPageDescription?: string;
|
||||
socialPagePreviewImage?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const HISTORY_DAYS_MIN = GC.STATUS_HISTORY_DAYS_MIN;
|
||||
const HISTORY_DAYS_MAX = GC.STATUS_HISTORY_DAYS_MAX;
|
||||
|
||||
export function getDefaultPageSettings(): PageSettings {
|
||||
return {
|
||||
incidents: {
|
||||
enabled: true,
|
||||
ongoing: { show: true },
|
||||
resolved: { show: true, max_count: 5, days_in_past: 7 },
|
||||
},
|
||||
include_maintenances: {
|
||||
enabled: true,
|
||||
ongoing: {
|
||||
show: true,
|
||||
past: { show: true, max_count: 5, days_in_past: 7 },
|
||||
upcoming: { show: true, max_count: 5, days_in_future: 30 },
|
||||
},
|
||||
},
|
||||
monitor_status_history_days: {
|
||||
desktop: GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP,
|
||||
mobile: GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE,
|
||||
},
|
||||
monitor_layout_style: GC.DEFAULT_MONITOR_LAYOUT_STYLE,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStored(storedJson: string | null | undefined): StoredPageSettings {
|
||||
if (!storedJson) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(storedJson);
|
||||
return typeof parsed === "object" && parsed !== null ? (parsed as StoredPageSettings) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// Recursively merges patch into base: objects merge key-by-key, everything
|
||||
// else replaces. Keys absent from the patch — including ones this module does
|
||||
// not know about — are left untouched.
|
||||
function deepMerge(base: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = { ...base };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === undefined) continue;
|
||||
const current = result[key];
|
||||
result[key] = isPlainObject(current) && isPlainObject(value) ? deepMerge(current, value) : value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mergePageSettings(defaults: PageSettings, partial?: PageSettingsPatch): PageSettings {
|
||||
if (!partial) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
const merged: PageSettings = {
|
||||
incidents: {
|
||||
enabled: partial.incidents?.enabled ?? defaults.incidents.enabled,
|
||||
ongoing: {
|
||||
show: partial.incidents?.ongoing?.show ?? defaults.incidents.ongoing.show,
|
||||
},
|
||||
resolved: {
|
||||
show: partial.incidents?.resolved?.show ?? defaults.incidents.resolved.show,
|
||||
max_count: partial.incidents?.resolved?.max_count ?? defaults.incidents.resolved.max_count,
|
||||
days_in_past: partial.incidents?.resolved?.days_in_past ?? defaults.incidents.resolved.days_in_past,
|
||||
},
|
||||
},
|
||||
include_maintenances: {
|
||||
enabled: partial.include_maintenances?.enabled ?? defaults.include_maintenances.enabled,
|
||||
ongoing: {
|
||||
show: partial.include_maintenances?.ongoing?.show ?? defaults.include_maintenances.ongoing.show,
|
||||
past: {
|
||||
show: partial.include_maintenances?.ongoing?.past?.show ?? defaults.include_maintenances.ongoing.past.show,
|
||||
max_count:
|
||||
partial.include_maintenances?.ongoing?.past?.max_count ??
|
||||
defaults.include_maintenances.ongoing.past.max_count,
|
||||
days_in_past:
|
||||
partial.include_maintenances?.ongoing?.past?.days_in_past ??
|
||||
defaults.include_maintenances.ongoing.past.days_in_past,
|
||||
},
|
||||
upcoming: {
|
||||
show:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.show ??
|
||||
defaults.include_maintenances.ongoing.upcoming.show,
|
||||
max_count:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.max_count ??
|
||||
defaults.include_maintenances.ongoing.upcoming.max_count,
|
||||
days_in_future:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.days_in_future ??
|
||||
defaults.include_maintenances.ongoing.upcoming.days_in_future,
|
||||
},
|
||||
},
|
||||
},
|
||||
monitor_status_history_days: {
|
||||
desktop: partial.monitor_status_history_days?.desktop ?? defaults.monitor_status_history_days.desktop,
|
||||
mobile: partial.monitor_status_history_days?.mobile ?? defaults.monitor_status_history_days.mobile,
|
||||
},
|
||||
monitor_layout_style: partial.monitor_layout_style ?? defaults.monitor_layout_style,
|
||||
};
|
||||
|
||||
const metaPageTitle = partial.meta_page_title ?? defaults.meta_page_title;
|
||||
const metaPageDescription = partial.meta_page_description ?? defaults.meta_page_description;
|
||||
const socialPagePreviewImage = partial.social_page_preview_image ?? defaults.social_page_preview_image;
|
||||
if (metaPageTitle !== undefined) merged.meta_page_title = metaPageTitle;
|
||||
if (metaPageDescription !== undefined) merged.meta_page_description = metaPageDescription;
|
||||
if (socialPagePreviewImage !== undefined) merged.social_page_preview_image = socialPagePreviewImage;
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function isValidHistoryDays(value: unknown): boolean {
|
||||
return Number.isInteger(value) && (value as number) >= HISTORY_DAYS_MIN && (value as number) <= HISTORY_DAYS_MAX;
|
||||
}
|
||||
|
||||
const boolOrUndefined = (value: unknown): boolean | undefined => (typeof value === "boolean" ? value : undefined);
|
||||
const countOrUndefined = (value: unknown): number | undefined =>
|
||||
Number.isInteger(value) && (value as number) >= 0 ? (value as number) : undefined;
|
||||
|
||||
// Read-side sanitizers: keep only correctly-typed leaves from stored event
|
||||
// branches so wrong-typed values (e.g. enabled: "yes" from manual edits or
|
||||
// older versions) never override defaults in API responses
|
||||
function sanitizeStoredIncidents(value: unknown): PageSettingsPatch["incidents"] {
|
||||
if (!isPlainObject(value)) return undefined;
|
||||
const ongoing = isPlainObject(value.ongoing) ? value.ongoing : {};
|
||||
const resolved = isPlainObject(value.resolved) ? value.resolved : {};
|
||||
return {
|
||||
enabled: boolOrUndefined(value.enabled),
|
||||
ongoing: { show: boolOrUndefined(ongoing.show) },
|
||||
resolved: {
|
||||
show: boolOrUndefined(resolved.show),
|
||||
max_count: countOrUndefined(resolved.max_count),
|
||||
days_in_past: countOrUndefined(resolved.days_in_past),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeStoredMaintenances(value: unknown): PageSettingsPatch["include_maintenances"] {
|
||||
if (!isPlainObject(value)) return undefined;
|
||||
const ongoing = isPlainObject(value.ongoing) ? value.ongoing : {};
|
||||
const past = isPlainObject(ongoing.past) ? ongoing.past : {};
|
||||
const upcoming = isPlainObject(ongoing.upcoming) ? ongoing.upcoming : {};
|
||||
return {
|
||||
enabled: boolOrUndefined(value.enabled),
|
||||
ongoing: {
|
||||
show: boolOrUndefined(ongoing.show),
|
||||
past: {
|
||||
show: boolOrUndefined(past.show),
|
||||
max_count: countOrUndefined(past.max_count),
|
||||
days_in_past: countOrUndefined(past.days_in_past),
|
||||
},
|
||||
upcoming: {
|
||||
show: boolOrUndefined(upcoming.show),
|
||||
max_count: countOrUndefined(upcoming.max_count),
|
||||
days_in_future: countOrUndefined(upcoming.days_in_future),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isValidLayoutStyle(value: unknown): value is PageSettings["monitor_layout_style"] {
|
||||
return (GC.MONITOR_LAYOUT_STYLES as readonly string[]).includes(value as string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the API view of stored settings: defaults overlaid with stored
|
||||
* values. Stored values that violate the API contract (unknown layout style,
|
||||
* out-of-range days — e.g. from manual edits or older versions) are ignored
|
||||
* so responses stay schema-compliant.
|
||||
*/
|
||||
export function toApiPageSettings(storedJson: string | null | undefined): PageSettings {
|
||||
const stored = parseStored(storedJson);
|
||||
const storedDays = isPlainObject(stored.monitor_status_history_days) ? stored.monitor_status_history_days : {};
|
||||
const fromStore: PageSettingsPatch = {
|
||||
incidents: sanitizeStoredIncidents(stored.incidents),
|
||||
include_maintenances: sanitizeStoredMaintenances(stored.include_maintenances),
|
||||
monitor_status_history_days: {
|
||||
desktop: isValidHistoryDays(storedDays.desktop) ? (storedDays.desktop as number) : undefined,
|
||||
mobile: isValidHistoryDays(storedDays.mobile) ? (storedDays.mobile as number) : undefined,
|
||||
},
|
||||
monitor_layout_style: isValidLayoutStyle(stored.monitor_layout_style) ? stored.monitor_layout_style : undefined,
|
||||
meta_page_title: typeof stored.metaPageTitle === "string" ? stored.metaPageTitle : undefined,
|
||||
meta_page_description: typeof stored.metaPageDescription === "string" ? stored.metaPageDescription : undefined,
|
||||
social_page_preview_image:
|
||||
typeof stored.socialPagePreviewImage === "string" ? stored.socialPagePreviewImage : undefined,
|
||||
};
|
||||
return mergePageSettings(getDefaultPageSettings(), fromStore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-merges a partial API payload into the stored settings JSON and returns
|
||||
* the new JSON string. Only keys present in the patch are written; everything
|
||||
* else in the stored JSON — including nested keys and top-level keys this
|
||||
* module does not know about — is preserved, so an API write can never wipe
|
||||
* settings written by other parts of the app, and clients may persist extra
|
||||
* keys (the schema allows additional properties).
|
||||
*/
|
||||
export function applyPageSettingsPatch(
|
||||
storedJson: string | null | undefined,
|
||||
patch: PageSettingsPatch | undefined,
|
||||
): string {
|
||||
const stored = parseStored(storedJson);
|
||||
if (!patch) {
|
||||
return JSON.stringify(stored);
|
||||
}
|
||||
|
||||
// Map the API's snake_case meta fields to their stored camelCase keys; all
|
||||
// other keys are stored under their API names
|
||||
const { meta_page_title, meta_page_description, social_page_preview_image, ...rest } = patch;
|
||||
const mappedPatch: Record<string, unknown> = { ...rest };
|
||||
if (meta_page_title !== undefined) mappedPatch.metaPageTitle = meta_page_title;
|
||||
if (meta_page_description !== undefined) mappedPatch.metaPageDescription = meta_page_description;
|
||||
if (social_page_preview_image !== undefined) mappedPatch.socialPagePreviewImage = social_page_preview_image;
|
||||
|
||||
return JSON.stringify(deepMerge(stored, mappedPatch));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a partial page_settings payload from the API. Returns an error
|
||||
* message, or null when valid. Bounds mirror the manage UI (history days
|
||||
* 1-365, layout style one of the four shipped styles).
|
||||
*/
|
||||
export function validatePageSettings(partial: unknown): string | null {
|
||||
if (partial === undefined) return null;
|
||||
if (typeof partial !== "object" || partial === null || Array.isArray(partial)) {
|
||||
return "page_settings must be an object";
|
||||
}
|
||||
|
||||
const settings = partial as PageSettingsPatch;
|
||||
|
||||
// The event display branches and their known sub-objects must be objects;
|
||||
// anything else would be deep-merged into storage as-is
|
||||
if (settings.incidents !== undefined) {
|
||||
if (!isPlainObject(settings.incidents)) {
|
||||
return "incidents must be an object";
|
||||
}
|
||||
for (const key of ["ongoing", "resolved"] as const) {
|
||||
if (settings.incidents[key] !== undefined && !isPlainObject(settings.incidents[key])) {
|
||||
return `incidents.${key} must be an object`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.include_maintenances !== undefined) {
|
||||
if (!isPlainObject(settings.include_maintenances)) {
|
||||
return "include_maintenances must be an object";
|
||||
}
|
||||
const ongoing = settings.include_maintenances.ongoing;
|
||||
if (ongoing !== undefined) {
|
||||
if (!isPlainObject(ongoing)) {
|
||||
return "include_maintenances.ongoing must be an object";
|
||||
}
|
||||
for (const key of ["past", "upcoming"] as const) {
|
||||
if (ongoing[key] !== undefined && !isPlainObject(ongoing[key])) {
|
||||
return `include_maintenances.ongoing.${key} must be an object`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Leaf types inside the event branches must match the schema
|
||||
const leafChecks: Array<{ path: readonly string[]; kind: "boolean" | "count" }> = [
|
||||
{ path: ["incidents", "enabled"], kind: "boolean" },
|
||||
{ path: ["incidents", "ongoing", "show"], kind: "boolean" },
|
||||
{ path: ["incidents", "resolved", "show"], kind: "boolean" },
|
||||
{ path: ["incidents", "resolved", "max_count"], kind: "count" },
|
||||
{ path: ["incidents", "resolved", "days_in_past"], kind: "count" },
|
||||
{ path: ["include_maintenances", "enabled"], kind: "boolean" },
|
||||
{ path: ["include_maintenances", "ongoing", "show"], kind: "boolean" },
|
||||
{ path: ["include_maintenances", "ongoing", "past", "show"], kind: "boolean" },
|
||||
{ path: ["include_maintenances", "ongoing", "past", "max_count"], kind: "count" },
|
||||
{ path: ["include_maintenances", "ongoing", "past", "days_in_past"], kind: "count" },
|
||||
{ path: ["include_maintenances", "ongoing", "upcoming", "show"], kind: "boolean" },
|
||||
{ path: ["include_maintenances", "ongoing", "upcoming", "max_count"], kind: "count" },
|
||||
{ path: ["include_maintenances", "ongoing", "upcoming", "days_in_future"], kind: "count" },
|
||||
];
|
||||
for (const { path, kind } of leafChecks) {
|
||||
let value: unknown = settings;
|
||||
for (const key of path) {
|
||||
if (!isPlainObject(value)) {
|
||||
value = undefined;
|
||||
break;
|
||||
}
|
||||
value = value[key];
|
||||
}
|
||||
if (value === undefined) continue;
|
||||
if (kind === "boolean" && typeof value !== "boolean") {
|
||||
return `${path.join(".")} must be a boolean`;
|
||||
}
|
||||
if (kind === "count" && !(Number.isInteger(value) && (value as number) >= 0)) {
|
||||
return `${path.join(".")} must be a non-negative integer`;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.monitor_layout_style !== undefined) {
|
||||
if (!GC.MONITOR_LAYOUT_STYLES.includes(settings.monitor_layout_style)) {
|
||||
return `monitor_layout_style must be one of: ${GC.MONITOR_LAYOUT_STYLES.join(", ")}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.monitor_status_history_days !== undefined) {
|
||||
const days = settings.monitor_status_history_days;
|
||||
if (typeof days !== "object" || days === null || Array.isArray(days)) {
|
||||
return "monitor_status_history_days must be an object";
|
||||
}
|
||||
for (const key of ["desktop", "mobile"] as const) {
|
||||
const value = days[key];
|
||||
if (value !== undefined) {
|
||||
if (!Number.isInteger(value) || value < HISTORY_DAYS_MIN || value > HISTORY_DAYS_MAX) {
|
||||
return `monitor_status_history_days.${key} must be an integer between ${HISTORY_DAYS_MIN} and ${HISTORY_DAYS_MAX}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of ["meta_page_title", "meta_page_description", "social_page_preview_image"] as const) {
|
||||
const value = settings[key];
|
||||
if (value !== undefined && typeof value !== "string") {
|
||||
return `${key} must be a string`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
UpdateMonitorAlertV2Status,
|
||||
} from "../controllers/monitorAlertConfigController.js";
|
||||
import type { IncidentInput } from "../controllers/incidentController.js";
|
||||
import { InsertNewAlert } from "../controllers/controller.js";
|
||||
import { GetMonitorAlertsV2 } from "../controllers/monitorAlertConfigController.js";
|
||||
import db from "../db/db.js";
|
||||
import { getUnixTime, differenceInSeconds } from "date-fns";
|
||||
@@ -34,7 +33,6 @@ import sendEmail from "../notification/email_notification.js";
|
||||
import sendWebhook from "$lib/server/notification/webhook_notification.js";
|
||||
import sendSlack from "$lib/server/notification/slack_notification.js";
|
||||
import sendDiscord from "$lib/server/notification/discord_notification.js";
|
||||
import serverResolver from "../resolver.js";
|
||||
|
||||
import type { SiteDataForNotification, SubscriptionVariableMap } from "../notification/types.js";
|
||||
import mdToHTML from "../../marked.js";
|
||||
|
||||
@@ -7,6 +7,7 @@ import { GetMinuteStartNowTimestampUTC } from "../tool.js";
|
||||
import db from "../db/db.js";
|
||||
import monitorResponseQueue from "./monitorResponseQueue";
|
||||
import GC from "../../global-constants.js";
|
||||
import { resolveConfirmedStatus } from "../services/confirmationThreshold.js";
|
||||
|
||||
let monitorExecuteQueue: Queue | null = null;
|
||||
let worker: Worker | null = null;
|
||||
@@ -25,8 +26,13 @@ const getQueue = () => {
|
||||
return monitorExecuteQueue;
|
||||
};
|
||||
|
||||
async function manualMaintenance(monitor: MonitorRecordTyped): Promise<{ [timestamp: number]: MonitoringResult }> {
|
||||
let startTs = GetMinuteStartNowTimestampUTC();
|
||||
async function manualMaintenance(
|
||||
monitor: MonitorRecordTyped,
|
||||
ts?: number,
|
||||
): Promise<{ [timestamp: number]: MonitoringResult }> {
|
||||
// Key by the job's `ts` (already a minute-start) so the overlay aligns with the realtime/default
|
||||
// rows and the freeze gate; fall back to "now" only when called without a ts.
|
||||
let startTs = ts !== undefined ? ts : GetMinuteStartNowTimestampUTC();
|
||||
let maintenanceArr = await db.getMaintenancesByMonitorTagRealtime(monitor.tag, startTs);
|
||||
|
||||
let impact = "";
|
||||
@@ -66,8 +72,13 @@ async function manualMaintenance(monitor: MonitorRecordTyped): Promise<{ [timest
|
||||
return manualData;
|
||||
}
|
||||
|
||||
async function manualIncident(monitor: MonitorRecordTyped): Promise<{ [timestamp: number]: MonitoringResult }> {
|
||||
let startTs = GetMinuteStartNowTimestampUTC();
|
||||
async function manualIncident(
|
||||
monitor: MonitorRecordTyped,
|
||||
ts?: number,
|
||||
): Promise<{ [timestamp: number]: MonitoringResult }> {
|
||||
// Key by the job's `ts` (already a minute-start) so the overlay aligns with the realtime/default
|
||||
// rows and the freeze gate; fall back to "now" only when called without a ts.
|
||||
let startTs = ts !== undefined ? ts : GetMinuteStartNowTimestampUTC();
|
||||
let incidentArr = await db.getIncidentsByMonitorTagRealtime(monitor.tag, startTs);
|
||||
|
||||
let impact = "";
|
||||
@@ -115,13 +126,43 @@ const addWorker = () => {
|
||||
|
||||
const exeResult = await serviceClient.execute(ts);
|
||||
|
||||
// Fetch overlays AFTER the check runs so a maintenance/incident that starts mid-check is still
|
||||
// detected, and key them by the job's `ts` so the freeze gate (incidentData[ts]) is
|
||||
// timestamp-safe even if the job is delayed or retried (#756).
|
||||
let incidentData: MonitoringResultTS = await manualIncident(monitor, ts);
|
||||
let maintenanceData: MonitoringResultTS = await manualMaintenance(monitor, ts);
|
||||
|
||||
let realtimeData: MonitoringResultTS = {};
|
||||
if (exeResult) {
|
||||
realtimeData[ts] = exeResult;
|
||||
}
|
||||
// Always record what the check actually observed (forensics + grace counting).
|
||||
realtimeData[ts].raw_status = exeResult.status;
|
||||
|
||||
let incidentData: MonitoringResultTS = await manualIncident(monitor);
|
||||
let maintenanceData: MonitoringResultTS = await manualMaintenance(monitor);
|
||||
// Confirmation Threshold damping (#712): scheduled checks only.
|
||||
const threshold = Number(monitor.confirmation_threshold ?? 1);
|
||||
const isScheduledCheck = ([GC.REALTIME, GC.TIMEOUT, GC.ERROR] as string[]).includes(exeResult.type);
|
||||
// Confirmation Threshold freezes while an incident/maintenance overlay is active for this
|
||||
// minute: the overlay wins display and the count must neither advance nor backfill (#756).
|
||||
const overlayActive = incidentData[ts] !== undefined || maintenanceData[ts] !== undefined;
|
||||
if (threshold > 1 && isScheduledCheck && !overlayActive) {
|
||||
const resolved = await resolveConfirmedStatus({
|
||||
monitor_tag: monitor.tag,
|
||||
ts,
|
||||
rawStatus: exeResult.status,
|
||||
threshold,
|
||||
});
|
||||
realtimeData[ts].status = resolved.status;
|
||||
if (resolved.pendingHold) {
|
||||
// Hold the confirmed side for display, but PRESERVE the observed latency and error text —
|
||||
// no diagnostic info is discarded. Tag the row to record that the status is being held
|
||||
// during the grace period; on confirmation the backfill appends the confirmation note (#756).
|
||||
const observedError = realtimeData[ts].error_message;
|
||||
realtimeData[ts].error_message = observedError
|
||||
? `${observedError} | Status held during grace period`
|
||||
: "Status held during grace period";
|
||||
}
|
||||
}
|
||||
}
|
||||
let defaultData: MonitoringResultTS = {};
|
||||
let mergedData: MonitoringResultTS = {};
|
||||
|
||||
@@ -186,6 +227,15 @@ const addWorker = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve raw_status from realtime monitoring (overlays replace the merged object wholesale,
|
||||
// so re-attach the observed value the resolver recorded).
|
||||
for (const timestamp in mergedData) {
|
||||
const ts = parseInt(timestamp);
|
||||
if (realtimeData[ts]?.raw_status !== undefined) {
|
||||
mergedData[ts].raw_status = realtimeData[ts].raw_status;
|
||||
}
|
||||
}
|
||||
|
||||
for (const timestamp in mergedData) {
|
||||
monitorResponseQueue.push(monitor.tag, parseInt(timestamp), mergedData[timestamp]);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ interface JobData {
|
||||
monitorTag: string;
|
||||
ts: number;
|
||||
error_message?: string | null;
|
||||
raw_status?: string | null;
|
||||
}
|
||||
|
||||
const getQueue = () => {
|
||||
@@ -30,7 +31,7 @@ const addWorker = () => {
|
||||
if (worker) return worker;
|
||||
|
||||
worker = q.createWorker(getQueue(), async (job: Job): Promise<MonitoringData | null> => {
|
||||
const { monitorTag, ts, status, latency, type, error_message } = job.data as JobData;
|
||||
const { monitorTag, ts, status, latency, type, error_message, raw_status } = job.data as JobData;
|
||||
|
||||
const dbRes = await InsertMonitoringData({
|
||||
monitor_tag: monitorTag,
|
||||
@@ -39,6 +40,7 @@ const addWorker = () => {
|
||||
latency: latency,
|
||||
type: type,
|
||||
error_message: error_message,
|
||||
raw_status: raw_status,
|
||||
});
|
||||
|
||||
if (!dbRes) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { redisIOConnection } from "../redisConnector.js";
|
||||
import db from "../db/db.js";
|
||||
import {
|
||||
Queue,
|
||||
Worker,
|
||||
@@ -40,7 +41,15 @@ export const createWorker = <T = unknown, R = unknown>(
|
||||
concurrency: 5,
|
||||
...options,
|
||||
};
|
||||
return new Worker<T, R>(queue.name, processor, opts);
|
||||
// Route every job's database access to the worker pool. This is the single
|
||||
// chokepoint all BullMQ workers and schedulers flow through, so wrapping here
|
||||
// isolates background work from the web request pool (see db/poolContext.ts).
|
||||
// Sandboxed (string/URL) processors run out-of-process and pass through.
|
||||
const wrapped: Processor<T, R> =
|
||||
typeof processor === "function"
|
||||
? (job, token) => db.runInWorkerContext(() => Promise.resolve(processor(job, token)))
|
||||
: processor;
|
||||
return new Worker<T, R>(queue.name, wrapped, opts);
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,17 +1,45 @@
|
||||
import IORedis from "ioredis";
|
||||
import Redis from "ioredis";
|
||||
import type { RedisOptions } from "ioredis";
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
let redisIOClient: IORedis | null = null;
|
||||
let redisClient: IORedis | null = null;
|
||||
|
||||
function shouldReconnectAfterRedisError(message: string): boolean {
|
||||
const m = message.toUpperCase();
|
||||
// Failover: writes hit a replica until the client points at the new primary.
|
||||
if (m.includes("READONLY")) return true;
|
||||
// RDB/AOF reload after pod restart — commands fail until loading finishes.
|
||||
if (m.includes("LOADING")) return true;
|
||||
// Replication: primary unavailable during StatefulSet rollout.
|
||||
if (m.includes("MASTERDOWN")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const redisClientOptions: RedisOptions = {
|
||||
maxRetriesPerRequest: null,
|
||||
// Detect dead peers during long K8s / network stalls (default ioredis keepAlive is off).
|
||||
keepAlive: 30000,
|
||||
// Allow long RDB reloads after a StatefulSet restart before giving up on "ready".
|
||||
maxLoadingRetryTime: 120_000,
|
||||
reconnectOnError: (error: Error) => {
|
||||
const message = error?.message ?? "";
|
||||
if (shouldReconnectAfterRedisError(message)) {
|
||||
// Reconnect and retry the failed command once the connection is healthy again.
|
||||
return 2;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
export function redisIOConnection(): IORedis {
|
||||
if (!redisIOClient) {
|
||||
if (!process.env.REDIS_URL) {
|
||||
throw new Error("REDIS_URL is not defined in environment variables");
|
||||
}
|
||||
redisIOClient = new IORedis(process.env.REDIS_URL, { maxRetriesPerRequest: null });
|
||||
redisIOClient = new IORedis(process.env.REDIS_URL, redisClientOptions);
|
||||
}
|
||||
return redisIOClient;
|
||||
}
|
||||
@@ -21,7 +49,7 @@ export function redisConnection(): Redis {
|
||||
if (!process.env.REDIS_URL) {
|
||||
throw new Error("REDIS_URL is not defined in environment variables");
|
||||
}
|
||||
redisClient = new Redis(process.env.REDIS_URL, { maxRetriesPerRequest: null });
|
||||
redisClient = new Redis(process.env.REDIS_URL, redisClientOptions);
|
||||
}
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* RSS 2.0 feed for Kener status pages.
|
||||
*
|
||||
* Two layers live in this file:
|
||||
* 1. buildRssFeed — pure XML formatter (no I/O).
|
||||
* 2. renderRssFeedResponse — fetches recent incidents + maintenances for a
|
||||
* page, shapes them into items, and returns an HTTP Response. Shared by
|
||||
* both the default-page and named-page route handlers.
|
||||
*/
|
||||
|
||||
import db from "$lib/server/db/db.js";
|
||||
import { GetAllSiteData } from "$lib/server/controllers/siteDataController.js";
|
||||
import type { IncidentForMonitorListWithComments, MaintenanceEventsMonitorList } from "$lib/server/types/db.js";
|
||||
|
||||
export type RssFeedItemType = "incident" | "maintenance";
|
||||
|
||||
export interface RssFeedItem {
|
||||
type: RssFeedItemType;
|
||||
id: number;
|
||||
title: string;
|
||||
link: string;
|
||||
pubDate: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface BuildRssFeedArgs {
|
||||
siteName: string;
|
||||
siteURL: string;
|
||||
basePath: string;
|
||||
feedPath: string;
|
||||
items: RssFeedItem[];
|
||||
}
|
||||
|
||||
const TYPE_TITLE_PREFIX: Record<RssFeedItemType, string> = {
|
||||
incident: "[Incident]",
|
||||
maintenance: "[Maintenance]",
|
||||
};
|
||||
|
||||
export function buildRssFeed(args: BuildRssFeedArgs): string {
|
||||
const channelLink = joinUrl(args.siteURL, args.basePath);
|
||||
const selfLink = joinUrl(args.siteURL, args.basePath, args.feedPath);
|
||||
const channelTitle = `${args.siteName} — Incidents & Maintenance`;
|
||||
const channelDescription = `Latest incidents and scheduled maintenance for ${args.siteName}`;
|
||||
|
||||
const lastBuildSeconds = args.items.length > 0 ? Math.max(...args.items.map((i) => i.pubDate)) : nowSeconds();
|
||||
|
||||
const itemsXml = args.items.map(renderItem).join("\n");
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>${escapeXml(channelTitle)}</title>
|
||||
<link>${escapeXml(channelLink)}</link>
|
||||
<description>${escapeXml(channelDescription)}</description>
|
||||
<language>en</language>
|
||||
<lastBuildDate>${formatRfc822(lastBuildSeconds)}</lastBuildDate>
|
||||
<atom:link href="${escapeXml(selfLink)}" rel="self" type="application/rss+xml" />
|
||||
${itemsXml}
|
||||
</channel>
|
||||
</rss>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderItem(item: RssFeedItem): string {
|
||||
const prefixedTitle = `${TYPE_TITLE_PREFIX[item.type]} ${item.title}`;
|
||||
const guid = `${item.type}-${item.id}`;
|
||||
return ` <item>
|
||||
<title>${escapeXml(prefixedTitle)}</title>
|
||||
<link>${escapeXml(item.link)}</link>
|
||||
<guid isPermaLink="false">${escapeXml(guid)}</guid>
|
||||
<pubDate>${formatRfc822(item.pubDate)}</pubDate>
|
||||
<description>${cdata(item.description)}</description>
|
||||
</item>`;
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -47,7 +47,7 @@ const getRetentionPolicy = async (): Promise<DataRetentionPolicy> => {
|
||||
const runDailyCleanup = async (): Promise<DailyCleanupResult> => {
|
||||
const policy = await getRetentionPolicy();
|
||||
const retentionDays = Math.max(1, Math.floor(policy.retentionDays || defaultPolicy.retentionDays));
|
||||
|
||||
console.log(`Data retention policy: enabled=${policy.enabled}, retentionDays=${retentionDays}`);
|
||||
if (!policy.enabled) {
|
||||
return {
|
||||
skipped: true,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import GC from "../../global-constants.js";
|
||||
import db from "../db/db.js";
|
||||
|
||||
export type Side = "UP" | "DOWN" | null;
|
||||
|
||||
/** Binary side: UP is healthy; DOWN/DEGRADED are unhealthy; everything else (NO_DATA) is neutral. */
|
||||
export function sideOf(status: string | null | undefined): Side {
|
||||
if (status === GC.UP) return "UP";
|
||||
if (status === GC.DOWN || status === GC.DEGRADED) return "DOWN";
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ResolveInput {
|
||||
monitor_tag: string;
|
||||
ts: number;
|
||||
rawStatus: string;
|
||||
threshold: number; // >= 2 to damp; 1 behaves as off (any opposite observation confirms instantly)
|
||||
}
|
||||
|
||||
export interface ResolveResult {
|
||||
/** Effective status to commit for this minute. */
|
||||
status: string;
|
||||
/**
|
||||
* True while the displayed status is the held confirmed side (pending confirmation) rather
|
||||
* than the observed side. The caller keeps the observed latency and error text (tagging the
|
||||
* row) — it does not blank them.
|
||||
*/
|
||||
pendingHold: boolean;
|
||||
}
|
||||
|
||||
/** Minimal data access this resolver needs; defaults to the db singleton, injectable for tests. */
|
||||
export interface ConfirmationDeps {
|
||||
getRecentSamplesForConfirmation: typeof db.getRecentSamplesForConfirmation;
|
||||
getLastObservedStatus: typeof db.getLastObservedStatus;
|
||||
backfillConfirmedStatus: typeof db.backfillConfirmedStatus;
|
||||
}
|
||||
|
||||
// Overlay sample types that freeze the count (must match OVERLAY_TYPES in the monitoring repository).
|
||||
const OVERLAY_TYPES: string[] = [GC.INCIDENT, GC.MAINTENANCE];
|
||||
// Extra lookback rows beyond the threshold for the pending-run scan: headroom for interleaved
|
||||
// overlay rows. NO_DATA observations are excluded by the query, and the anchor is fetched
|
||||
// separately, so the buffer need not scale with NO_DATA density or overlay-window length.
|
||||
const LOOKBACK_BUFFER = 10;
|
||||
|
||||
/**
|
||||
* Resolve the status to commit for one scheduled-check observation under Confirmation
|
||||
* Threshold damping (issue #712).
|
||||
*
|
||||
* IMPORTANT ordering contract: this MUST be called BEFORE the current row at `ts` is
|
||||
* persisted, and only when no incident/maintenance overlay is active for `ts` (the caller
|
||||
* gates that — overlays freeze the count). It anchors on the most recent stored observation
|
||||
* (timestamp < ts).
|
||||
*
|
||||
* Neutral (`NO_DATA`) observations are excluded from the count (neither advance nor reset).
|
||||
* Overlay rows act as a hard boundary: the pending run never crosses one, so monitoring
|
||||
* resumes with a fresh count after an incident/maintenance window.
|
||||
*/
|
||||
export async function resolveConfirmedStatus(
|
||||
input: ResolveInput,
|
||||
deps: ConfirmationDeps = db,
|
||||
): Promise<ResolveResult> {
|
||||
const { monitor_tag, ts, rawStatus, threshold } = input;
|
||||
const observedSide = sideOf(rawStatus);
|
||||
|
||||
// Neutral observation (NO_DATA): pass through untouched — written honestly as grey.
|
||||
if (observedSide === null) {
|
||||
return { status: rawStatus, pendingHold: false };
|
||||
}
|
||||
|
||||
// Anchor = the side currently shown = the most recent real observation's committed status.
|
||||
// Fetched with a dedicated query (not from the windowed scan) so a long incident/maintenance
|
||||
// window can never push the anchor out of range and bypass damping.
|
||||
const confirmedStatus = await deps.getLastObservedStatus(monitor_tag, ts);
|
||||
const confirmedSide = sideOf(confirmedStatus);
|
||||
|
||||
// Cold start / no usable anchor / same side: commit immediately.
|
||||
if (confirmedStatus === null || confirmedSide === null || observedSide === confirmedSide) {
|
||||
return { status: rawStatus, pendingHold: false };
|
||||
}
|
||||
|
||||
// Opposite side: count the trailing pending run (incl. current = 1), stopping at an overlay
|
||||
// boundary (freeze) or at a confirmed-side observation. NO_DATA rows are excluded by the query.
|
||||
const recent = await deps.getRecentSamplesForConfirmation(monitor_tag, ts, threshold + LOOKBACK_BUFFER);
|
||||
let pendingRun = 1;
|
||||
const pendingTimestamps: number[] = [];
|
||||
for (const row of recent) {
|
||||
if (row.type !== null && OVERLAY_TYPES.includes(row.type)) break; // freeze boundary
|
||||
const rawSide = sideOf(row.raw_status);
|
||||
if (rawSide === null) continue; // NO_DATA: neutral (excluded by the query; this is a defensive guard)
|
||||
if (rawSide === observedSide && sideOf(row.status) === confirmedSide) {
|
||||
pendingRun++;
|
||||
pendingTimestamps.push(row.timestamp);
|
||||
} else {
|
||||
break; // hit a confirmed-side observation (the anchor)
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingRun >= threshold) {
|
||||
// Unhealthy confirm passes the count so the backfill can write a per-row severity note
|
||||
// ("Down"/"Degraded confirmed after N…"); recovery passes null (clears the held error text).
|
||||
await deps.backfillConfirmedStatus(monitor_tag, pendingTimestamps, observedSide === "DOWN" ? threshold : null);
|
||||
return { status: rawStatus, pendingHold: false };
|
||||
}
|
||||
|
||||
// Still pending: hold the confirmed side.
|
||||
return { status: confirmedStatus, pendingHold: true };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import DNSResolver from "../dns.js";
|
||||
import { GetLastKnownStatus } from "../controllers/monitorsController.js";
|
||||
import type { NoneMonitor, MonitoringResult } from "../types/monitor.js";
|
||||
import GC from "../../global-constants.js";
|
||||
|
||||
class NoneCall {
|
||||
monitor: NoneMonitor;
|
||||
@@ -8,7 +9,24 @@ class NoneCall {
|
||||
this.monitor = monitor;
|
||||
}
|
||||
|
||||
async execute(): Promise<null> {
|
||||
async execute(): Promise<MonitoringResult | null> {
|
||||
let overrideWithLastKnownStatus = this.monitor.type_data.overrideWithLastKnownStatus;
|
||||
if (!!overrideWithLastKnownStatus) {
|
||||
//get the last known status
|
||||
let lastKnownStatus = await GetLastKnownStatus(this.monitor.tag);
|
||||
if (
|
||||
!!lastKnownStatus &&
|
||||
!!lastKnownStatus.status &&
|
||||
!!lastKnownStatus.type &&
|
||||
lastKnownStatus.type === GC.MANUAL
|
||||
) {
|
||||
return {
|
||||
status: lastKnownStatus.status,
|
||||
latency: lastKnownStatus.latency || 0,
|
||||
type: lastKnownStatus.type,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Server-only database types (based on migrations schema)
|
||||
import type { Knex } from "knex";
|
||||
import type { PageMonitorLayoutStyle } from "$lib/types/api";
|
||||
|
||||
// ============ monitoring_data table ============
|
||||
export interface MonitoringData {
|
||||
@@ -9,6 +10,7 @@ export interface MonitoringData {
|
||||
latency: number | null;
|
||||
type: string | null;
|
||||
error_message?: string | null;
|
||||
raw_status?: string | null;
|
||||
}
|
||||
|
||||
export interface MonitoringDataInsert {
|
||||
@@ -18,6 +20,7 @@ export interface MonitoringDataInsert {
|
||||
latency: number;
|
||||
type: string;
|
||||
error_message?: string | null;
|
||||
raw_status?: string | null;
|
||||
}
|
||||
|
||||
export interface AggregatedMonitoringData {
|
||||
@@ -77,6 +80,7 @@ export interface MonitorRecord {
|
||||
external_url?: string | null;
|
||||
day_degraded_minimum_count?: number | null;
|
||||
day_down_minimum_count?: number | null;
|
||||
confirmation_threshold?: number | null;
|
||||
include_degraded_in_downtime?: string;
|
||||
is_hidden: string;
|
||||
monitor_settings_json: string | null;
|
||||
@@ -134,6 +138,7 @@ export interface MonitorRecordTyped {
|
||||
type_data: Record<string, unknown> | null;
|
||||
day_degraded_minimum_count?: number | null;
|
||||
day_down_minimum_count?: number | null;
|
||||
confirmation_threshold?: number | null;
|
||||
include_degraded_in_downtime?: string;
|
||||
is_hidden: string;
|
||||
monitor_settings_json: MonitorSettings | null;
|
||||
@@ -157,6 +162,7 @@ export interface MonitorRecordInsert {
|
||||
type_data?: string | null;
|
||||
day_degraded_minimum_count?: number | null;
|
||||
day_down_minimum_count?: number | null;
|
||||
confirmation_threshold?: number | null;
|
||||
include_degraded_in_downtime?: string;
|
||||
is_hidden?: string;
|
||||
monitor_settings_json?: string | null;
|
||||
@@ -236,7 +242,7 @@ export interface UserRecord {
|
||||
password_hash: string;
|
||||
is_active: number;
|
||||
is_verified: number;
|
||||
role: string;
|
||||
role_ids: string[]; // Array of role IDs
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -245,9 +251,9 @@ export interface UserRecordInsert {
|
||||
email: string;
|
||||
name: string;
|
||||
password_hash: string;
|
||||
role_ids: string[]; // Array of role IDs
|
||||
is_active?: number;
|
||||
is_verified?: number;
|
||||
role?: string;
|
||||
is_owner?: string;
|
||||
}
|
||||
|
||||
@@ -258,7 +264,7 @@ export interface UserRecordPublic {
|
||||
is_active: number;
|
||||
is_verified: number;
|
||||
is_owner: string;
|
||||
role: string;
|
||||
role_ids: string[];
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -266,6 +272,31 @@ export interface UserRecordDashboard extends UserRecordPublic {
|
||||
has_password: boolean;
|
||||
}
|
||||
|
||||
// ============ roles table ============
|
||||
export interface RoleRecord {
|
||||
id: string;
|
||||
role_name: string;
|
||||
readonly: number;
|
||||
status: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface RolePermissionRecord {
|
||||
roles_id: string;
|
||||
permissions_id: string;
|
||||
status: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface UserRoleRecord {
|
||||
roles_id: string;
|
||||
users_id: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
// ============ api_keys table ============
|
||||
export interface ApiKeyRecord {
|
||||
id: number;
|
||||
@@ -443,7 +474,7 @@ export interface PageSettingsType {
|
||||
desktop: number;
|
||||
mobile: number;
|
||||
};
|
||||
monitor_layout_style: "default-list" | "default-grid" | "compact-list" | "compact-grid";
|
||||
monitor_layout_style: PageMonitorLayoutStyle;
|
||||
metaPageTitle?: string;
|
||||
metaPageDescription?: string;
|
||||
socialPagePreviewImage?: string;
|
||||
@@ -616,6 +647,7 @@ export interface MaintenanceEventsMonitorList {
|
||||
description: string | null;
|
||||
start_date_time: number; // Unix timestamp - when the first occurrence starts
|
||||
end_date_time: number; // Unix timestamp - when the first occurrence ends
|
||||
is_global: YesNoType; // "YES" when the maintenance affects all monitors (no per-monitor rows)
|
||||
monitors: MaintenanceMonitorImpact[];
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
|
||||
@@ -8,13 +8,16 @@ export interface MonitoringResult {
|
||||
latency: number;
|
||||
type: string;
|
||||
error_message?: string;
|
||||
raw_status?: string;
|
||||
}
|
||||
|
||||
export interface MonitoringResultTS {
|
||||
[timestamp: number]: MonitoringResult;
|
||||
}
|
||||
|
||||
export interface NoneMonitorTypeData {}
|
||||
export interface NoneMonitorTypeData {
|
||||
overrideWithLastKnownStatus: boolean;
|
||||
}
|
||||
export interface ApiMonitorTypeData {
|
||||
url: string;
|
||||
body?: string;
|
||||
|
||||
+68
-4
@@ -3,6 +3,7 @@
|
||||
|
||||
import type { MonitorRecordTyped } from "$lib/server/types/db";
|
||||
import type { MonitorPublicView } from "$lib/types/monitor";
|
||||
import type GC from "$lib/global-constants";
|
||||
|
||||
export type ApiError = {
|
||||
code: string;
|
||||
@@ -91,6 +92,7 @@ export interface MonitorResponse {
|
||||
type_data: MonitorTypeData | null;
|
||||
include_degraded_in_downtime: string;
|
||||
is_hidden: string;
|
||||
confirmation_threshold?: number | null;
|
||||
monitor_settings_json: MonitorSettings | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -117,6 +119,7 @@ export interface CreateMonitorRequest {
|
||||
type_data?: MonitorTypeData | null;
|
||||
include_degraded_in_downtime?: string;
|
||||
is_hidden?: string;
|
||||
confirmation_threshold?: number | null;
|
||||
monitor_settings_json?: MonitorSettings | null;
|
||||
}
|
||||
|
||||
@@ -136,6 +139,7 @@ export interface UpdateMonitorRequest {
|
||||
type_data?: MonitorTypeData | null;
|
||||
include_degraded_in_downtime?: string;
|
||||
is_hidden?: string;
|
||||
confirmation_threshold?: number | null;
|
||||
monitor_settings_json?: MonitorSettings | null;
|
||||
}
|
||||
|
||||
@@ -143,6 +147,10 @@ export interface UpdateMonitorResponse {
|
||||
monitor: MonitorRecordTyped;
|
||||
}
|
||||
|
||||
export interface DeleteMonitorResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Monitoring Data API types
|
||||
export interface MonitoringDataPoint {
|
||||
monitor_tag: string;
|
||||
@@ -198,6 +206,8 @@ export interface IncidentResponse {
|
||||
monitors: IncidentMonitor[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/** Absolute URL of the public incident page */
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface IncidentDetailResponse extends IncidentResponse {
|
||||
@@ -298,6 +308,13 @@ export interface MaintenanceResponse {
|
||||
monitors: MaintenanceMonitor[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/**
|
||||
* Absolute URL of the public page for this maintenance.
|
||||
* Note: the public /maintenances/<id> route is keyed by maintenance EVENT id
|
||||
* by default, so this URL carries ?type=maintenance. Link via this field,
|
||||
* never by concatenating `id` onto a path. See docs/adr/0002.
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GetMaintenancesListResponse {
|
||||
@@ -344,6 +361,8 @@ export interface MaintenanceEventResponse {
|
||||
status: "SCHEDULED" | "READY" | "ONGOING" | "COMPLETED" | "CANCELLED";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/** Absolute URL of the public page for this maintenance event */
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GetMaintenanceEventsListResponse {
|
||||
@@ -357,8 +376,15 @@ export interface GetMaintenanceEventResponse {
|
||||
}
|
||||
|
||||
export interface UpdateMaintenanceEventRequest {
|
||||
start_date_time: number;
|
||||
end_date_time: number;
|
||||
/** Window edit mode: both times required. Cannot be combined with `status`. */
|
||||
start_date_time?: number;
|
||||
end_date_time?: number;
|
||||
/**
|
||||
* Transition mode: COMPLETED (from ONGOING) or CANCELLED (from SCHEDULED/READY/ONGOING).
|
||||
* Cannot be combined with time fields. Transitioning an ONGOING event moves its
|
||||
* end_date_time to the moment of the transition.
|
||||
*/
|
||||
status?: "COMPLETED" | "CANCELLED";
|
||||
}
|
||||
|
||||
export interface UpdateMaintenanceEventResponse {
|
||||
@@ -386,6 +412,8 @@ export interface MaintenanceEventDetailResponse {
|
||||
maintenance_rrule: string;
|
||||
maintenance_duration_seconds: number;
|
||||
monitors: MaintenanceMonitor[];
|
||||
/** Absolute URL of the public page for this maintenance event */
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GetMaintenanceEventsDetailListResponse {
|
||||
@@ -436,9 +464,40 @@ export interface PageSettingsMaintenances {
|
||||
ongoing: PageSettingsMaintenancesOngoing;
|
||||
}
|
||||
|
||||
export interface PageSettingsHistoryDays {
|
||||
desktop: number;
|
||||
mobile: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive partial, so patch payloads can update any subset of nested fields.
|
||||
* Recursion applies only to plain object maps; arrays and other special object
|
||||
* types pass through unchanged.
|
||||
*/
|
||||
export type DeepPartial<T> = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[K in keyof T]?: T[K] extends (infer U)[] ? U[] : T[K] extends Record<string, any> ? DeepPartial<T[K]> : T[K];
|
||||
};
|
||||
|
||||
/**
|
||||
* Patch payload for page_settings: any subset of nested fields. Provided
|
||||
* fields are deep-merged into the current settings; omitted fields are left
|
||||
* untouched.
|
||||
*/
|
||||
export type PageSettingsPatch = DeepPartial<PageSettings>;
|
||||
|
||||
export type PageMonitorLayoutStyle = (typeof GC.MONITOR_LAYOUT_STYLES)[number];
|
||||
|
||||
export interface PageSettings {
|
||||
incidents: PageSettingsIncidents;
|
||||
include_maintenances: PageSettingsMaintenances;
|
||||
/** Days of status history shown on the page, per device class (1-365). */
|
||||
monitor_status_history_days: PageSettingsHistoryDays;
|
||||
monitor_layout_style: PageMonitorLayoutStyle;
|
||||
/** Per-page meta/social overrides; stored as camelCase keys internally. */
|
||||
meta_page_title?: string;
|
||||
meta_page_description?: string;
|
||||
social_page_preview_image?: string;
|
||||
}
|
||||
|
||||
export interface PageMonitorResponse {
|
||||
@@ -447,6 +506,11 @@ export interface PageMonitorResponse {
|
||||
|
||||
export interface PageResponse {
|
||||
id: number;
|
||||
/**
|
||||
* The page's path segment. The home page (stored path is empty) renders as
|
||||
* the addressable token `~home`; its public URL is the site root.
|
||||
* See docs/adr/0004-home-page-api-token.md.
|
||||
*/
|
||||
page_path: string;
|
||||
page_title: string;
|
||||
page_header: string;
|
||||
@@ -472,7 +536,7 @@ export interface CreatePageRequest {
|
||||
page_header: string;
|
||||
page_subheader?: string | null;
|
||||
page_logo?: string | null;
|
||||
page_settings?: Partial<PageSettings>;
|
||||
page_settings?: PageSettingsPatch;
|
||||
monitors?: string[];
|
||||
}
|
||||
|
||||
@@ -486,7 +550,7 @@ export interface UpdatePageRequest {
|
||||
page_header?: string;
|
||||
page_subheader?: string | null;
|
||||
page_logo?: string | null;
|
||||
page_settings?: Partial<PageSettings>;
|
||||
page_settings?: PageSettingsPatch;
|
||||
monitors?: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface NotificationEvent {
|
||||
eventURL: string;
|
||||
eventTitle: string;
|
||||
eventDate: string;
|
||||
eventType: string;
|
||||
eventStartDateTime: number;
|
||||
eventEndDateTime: number | null;
|
||||
eventStatus: string;
|
||||
}
|
||||
@@ -89,6 +89,7 @@ export interface SiteSubscriptionsSettings {
|
||||
export interface SiteSubMenuOptions {
|
||||
showShareBadgeMonitor: boolean;
|
||||
showShareEmbedMonitor: boolean;
|
||||
showRssFeed: boolean;
|
||||
}
|
||||
|
||||
export interface DataRetentionPolicy {
|
||||
@@ -97,6 +98,7 @@ export interface DataRetentionPolicy {
|
||||
}
|
||||
|
||||
export interface EventDisplaySettings {
|
||||
showInlineEvents: boolean;
|
||||
incidents: {
|
||||
enabled: boolean;
|
||||
ongoing: {
|
||||
|
||||
@@ -4,15 +4,9 @@
|
||||
import { ModeWatcher } from "mode-watcher";
|
||||
import { resolve } from "$app/paths";
|
||||
import { Toaster } from "$lib/components/ui/sonner/index.js";
|
||||
|
||||
let base = resolve("/");
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
|
||||
let { children, data } = $props();
|
||||
|
||||
const colorUp = $derived(data.siteStatusColors.UP);
|
||||
const colorDegraded = $derived(data.siteStatusColors.DEGRADED);
|
||||
const colorDown = $derived(data.siteStatusColors.DOWN);
|
||||
const colorMaintenance = $derived(data.siteStatusColors.MAINTENANCE);
|
||||
import KenerNav from "$lib/components/KenerNav.svelte";
|
||||
</script>
|
||||
|
||||
@@ -21,7 +15,32 @@
|
||||
|
||||
<svelte:head>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
{@html `<style>:root{--up:${colorUp};--degraded:${colorDegraded};--down:${colorDown};--maintenance:${colorMaintenance};}</style>`}
|
||||
<link rel="icon" href={data.favicon} />
|
||||
{#if data.font?.cssSrc}
|
||||
<link rel="stylesheet" href={data.font.cssSrc} />
|
||||
{/if}
|
||||
{@html `
|
||||
<style id="dynamic-styles">
|
||||
body {
|
||||
--up: ${data.siteStatusColors.UP};
|
||||
--degraded: ${data.siteStatusColors.DEGRADED};
|
||||
--down: ${data.siteStatusColors.DOWN};
|
||||
--maintenance: ${data.siteStatusColors.MAINTENANCE};
|
||||
--accent: ${data.siteStatusColors.ACCENT || "#f4f4f5"};
|
||||
--accent-foreground: ${data.siteStatusColors.ACCENT_FOREGROUND || data.siteStatusColors.ACCENT || "#e96e2d"};
|
||||
${data.font?.family ? `--font-family:'${data.font.family}', sans-serif;` : ""}
|
||||
}
|
||||
:is(.dark) body {
|
||||
--up: ${data.siteStatusColorsDark.UP};
|
||||
--degraded: ${data.siteStatusColorsDark.DEGRADED};
|
||||
--down: ${data.siteStatusColorsDark.DOWN};
|
||||
--maintenance: ${data.siteStatusColorsDark.MAINTENANCE};
|
||||
--accent: ${data.siteStatusColorsDark.ACCENT || "#27272a"};
|
||||
--accent-foreground: ${data.siteStatusColorsDark.ACCENT_FOREGROUND || data.siteStatusColorsDark.ACCENT || "#e96e2d"};
|
||||
}
|
||||
${data.customCSS || ""}
|
||||
</style>`}
|
||||
<script src={clientResolver(resolve, "/capture.js")}></script>
|
||||
</svelte:head>
|
||||
<main>
|
||||
<!-- Nav -->
|
||||
|
||||
@@ -59,6 +59,13 @@ export const actions: Actions = {
|
||||
});
|
||||
}
|
||||
|
||||
if (!userDB.role_ids || userDB.role_ids.length === 0) {
|
||||
return fail(403, {
|
||||
error: "Your account has no active roles assigned. Please contact an administrator.",
|
||||
values: { email },
|
||||
});
|
||||
}
|
||||
|
||||
const token = await GenerateToken(userDB);
|
||||
const cookieConfig = CookieConfig();
|
||||
cookies.set(cookieConfig.name, token, {
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
} from "$lib/types/api";
|
||||
import GC from "$lib/global-constants";
|
||||
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
|
||||
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
|
||||
import serverResolver from "$lib/server/resolver";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
if (date instanceof Date) {
|
||||
@@ -56,6 +58,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
}
|
||||
|
||||
// Build response with monitors for each incident
|
||||
const siteUrl = await GetSiteURL();
|
||||
const incidents: IncidentResponse[] = [];
|
||||
for (const incident of rawIncidents) {
|
||||
const monitors = await db.getIncidentMonitorsByIncidentID(incident.id);
|
||||
@@ -72,6 +75,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
})),
|
||||
created_at: formatDateToISO(incident.created_at),
|
||||
updated_at: formatDateToISO(incident.updated_at),
|
||||
url: siteUrl + serverResolver(`/incidents/${incident.id}`),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -207,6 +211,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
})),
|
||||
created_at: formatDateToISO(createdIncident.created_at),
|
||||
updated_at: formatDateToISO(createdIncident.updated_at),
|
||||
url: (await GetSiteURL()) + serverResolver(`/incidents/${createdIncident.id}`),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
BadRequestResponse,
|
||||
} from "$lib/types/api";
|
||||
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
|
||||
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
|
||||
import serverResolver from "$lib/server/resolver";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
if (date instanceof Date) {
|
||||
@@ -43,6 +45,7 @@ async function buildIncidentResponse(incidentId: number): Promise<IncidentDetail
|
||||
})),
|
||||
created_at: formatDateToISO(incident.created_at),
|
||||
updated_at: formatDateToISO(incident.updated_at),
|
||||
url: (await GetSiteURL()) + serverResolver(`/incidents/${incident.id}`),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
GenerateMaintenanceEvents,
|
||||
isOneTimeRrule,
|
||||
} from "$lib/server/controllers/maintenanceController";
|
||||
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
|
||||
import serverResolver from "$lib/server/resolver";
|
||||
import { rrulestr } from "rrule";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
@@ -67,6 +69,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
}
|
||||
|
||||
// Build response with monitors for each maintenance
|
||||
const siteUrl = await GetSiteURL();
|
||||
const maintenances: MaintenanceResponse[] = [];
|
||||
for (const maintenance of rawMaintenances) {
|
||||
const monitors = await db.getMaintenanceMonitors(maintenance.id);
|
||||
@@ -84,6 +87,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
})),
|
||||
created_at: formatDateToISO(maintenance.created_at),
|
||||
updated_at: formatDateToISO(maintenance.updated_at),
|
||||
url: siteUrl + serverResolver(`/maintenances/${maintenance.id}?type=maintenance`),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -267,6 +271,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
})),
|
||||
created_at: formatDateToISO(maintenance.created_at),
|
||||
updated_at: formatDateToISO(maintenance.updated_at),
|
||||
url: (await GetSiteURL()) + serverResolver(`/maintenances/${maintenance.id}?type=maintenance`),
|
||||
};
|
||||
|
||||
const response: CreateMaintenanceResponse = {
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
} from "$lib/types/api";
|
||||
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
|
||||
import { GenerateMaintenanceEvents, isOneTimeRrule } from "$lib/server/controllers/maintenanceController";
|
||||
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
|
||||
import serverResolver from "$lib/server/resolver";
|
||||
import { rrulestr } from "rrule";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
@@ -55,6 +57,7 @@ async function buildMaintenanceResponse(maintenanceId: number): Promise<Maintena
|
||||
})),
|
||||
created_at: formatDateToISO(maintenance.created_at),
|
||||
updated_at: formatDateToISO(maintenance.updated_at),
|
||||
url: (await GetSiteURL()) + serverResolver(`/maintenances/${maintenance.id}?type=maintenance`),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { json, type RequestHandler } from "@sveltejs/kit";
|
||||
import db from "$lib/server/db/db";
|
||||
import type { GetMaintenanceEventsListResponse, MaintenanceEventResponse } from "$lib/types/api";
|
||||
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
|
||||
import serverResolver from "$lib/server/resolver";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
if (date instanceof Date) {
|
||||
@@ -31,14 +33,16 @@ export const GET: RequestHandler = async ({ locals, url }) => {
|
||||
const paginatedEvents = allEvents.slice(offset, offset + limit);
|
||||
|
||||
// Build response
|
||||
const siteUrl = await GetSiteURL();
|
||||
const events: MaintenanceEventResponse[] = paginatedEvents.map((event) => ({
|
||||
id: event.id,
|
||||
maintenance_id: event.maintenance_id,
|
||||
start_date_time: event.start_date_time,
|
||||
end_date_time: event.end_date_time,
|
||||
status: event.status as "SCHEDULED" | "ONGOING" | "COMPLETED" | "CANCELLED",
|
||||
status: event.status as MaintenanceEventResponse["status"],
|
||||
created_at: formatDateToISO(event.created_at),
|
||||
updated_at: formatDateToISO(event.updated_at),
|
||||
url: siteUrl + serverResolver(`/maintenances/${event.id}`),
|
||||
}));
|
||||
|
||||
const response: GetMaintenanceEventsListResponse = {
|
||||
|
||||
@@ -10,6 +10,10 @@ import type {
|
||||
BadRequestResponse,
|
||||
} from "$lib/types/api";
|
||||
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
|
||||
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
|
||||
import { UpdateMaintenanceEventStatus } from "$lib/server/controllers/maintenanceController";
|
||||
import GC from "$lib/global-constants";
|
||||
import serverResolver from "$lib/server/resolver";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
if (date instanceof Date) {
|
||||
@@ -20,7 +24,7 @@ function formatDateToISO(date: Date | string): string {
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
function buildEventResponse(event: {
|
||||
async function buildEventResponse(event: {
|
||||
id: number;
|
||||
maintenance_id: number;
|
||||
start_date_time: number;
|
||||
@@ -28,15 +32,16 @@ function buildEventResponse(event: {
|
||||
status: string;
|
||||
created_at: Date | string;
|
||||
updated_at: Date | string;
|
||||
}): MaintenanceEventResponse {
|
||||
}): Promise<MaintenanceEventResponse> {
|
||||
return {
|
||||
id: event.id,
|
||||
maintenance_id: event.maintenance_id,
|
||||
start_date_time: event.start_date_time,
|
||||
end_date_time: event.end_date_time,
|
||||
status: event.status as "SCHEDULED" | "ONGOING" | "COMPLETED" | "CANCELLED",
|
||||
status: event.status as MaintenanceEventResponse["status"],
|
||||
created_at: formatDateToISO(event.created_at),
|
||||
updated_at: formatDateToISO(event.updated_at),
|
||||
url: (await GetSiteURL()) + serverResolver(`/maintenances/${event.id}`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,7 +72,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
|
||||
}
|
||||
|
||||
const response: GetMaintenanceEventResponse = {
|
||||
event: buildEventResponse(event),
|
||||
event: await buildEventResponse(event),
|
||||
};
|
||||
|
||||
return json(response);
|
||||
@@ -113,7 +118,46 @@ export const PATCH: RequestHandler = async ({ locals, params, request }) => {
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate required fields - both are required for event update
|
||||
// Transition mode: `status` alone, mutually exclusive with window edits
|
||||
if (body.status !== undefined) {
|
||||
if (body.start_date_time !== undefined || body.end_date_time !== undefined) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: {
|
||||
code: "BAD_REQUEST",
|
||||
message: "Cannot update status and start/end times in the same request",
|
||||
},
|
||||
};
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.status !== GC.COMPLETED && body.status !== GC.CANCELLED) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: {
|
||||
code: "BAD_REQUEST",
|
||||
message: `status must be ${GC.COMPLETED} or ${GC.CANCELLED}`,
|
||||
},
|
||||
};
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedEvent = await UpdateMaintenanceEventStatus(eventId, body.status);
|
||||
const response: UpdateMaintenanceEventResponse = {
|
||||
event: await buildEventResponse(updatedEvent),
|
||||
};
|
||||
return json(response);
|
||||
} catch (err) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: {
|
||||
code: "BAD_REQUEST",
|
||||
message: err instanceof Error ? err.message : "Failed to update event status",
|
||||
},
|
||||
};
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Window edit mode: both times required
|
||||
if (body.start_date_time === undefined || body.start_date_time === null) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: {
|
||||
@@ -182,7 +226,7 @@ export const PATCH: RequestHandler = async ({ locals, params, request }) => {
|
||||
}
|
||||
|
||||
const response: UpdateMaintenanceEventResponse = {
|
||||
event: buildEventResponse(updatedEvent),
|
||||
event: await buildEventResponse(updatedEvent),
|
||||
};
|
||||
|
||||
return json(response);
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
MaintenanceMonitor,
|
||||
} from "$lib/types/api";
|
||||
import { GetNowTimestampUTC, GetMinuteStartTimestampUTC } from "$lib/server/tool";
|
||||
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
|
||||
import serverResolver from "$lib/server/resolver";
|
||||
|
||||
const VALID_EVENT_STATUSES = ["SCHEDULED", "ONGOING", "COMPLETED", "CANCELLED", "READY"];
|
||||
|
||||
@@ -70,6 +72,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
});
|
||||
|
||||
// For each event, get the monitors for that maintenance
|
||||
const siteUrl = await GetSiteURL();
|
||||
const events: MaintenanceEventDetailResponse[] = [];
|
||||
for (const event of rawEvents) {
|
||||
const monitors = await db.getMaintenanceMonitors(event.maintenance_id);
|
||||
@@ -83,13 +86,14 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
event_id: event.event_id,
|
||||
event_start_date_time: event.event_start_date_time,
|
||||
event_end_date_time: event.event_end_date_time,
|
||||
event_status: event.event_status as "SCHEDULED" | "ONGOING" | "COMPLETED" | "CANCELLED",
|
||||
event_status: event.event_status as MaintenanceEventDetailResponse["event_status"],
|
||||
maintenance_title: event.maintenance_title,
|
||||
maintenance_description: event.maintenance_description,
|
||||
maintenance_status: event.maintenance_status as "ACTIVE" | "INACTIVE",
|
||||
maintenance_rrule: event.maintenance_rrule,
|
||||
maintenance_duration_seconds: event.maintenance_duration_seconds,
|
||||
monitors: monitorList,
|
||||
url: siteUrl + serverResolver(`/maintenances/${event.event_id}`),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,19 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate confirmation_threshold
|
||||
let confirmationThreshold = 1;
|
||||
if (body.confirmation_threshold !== undefined && body.confirmation_threshold !== null) {
|
||||
const ct = Number(body.confirmation_threshold);
|
||||
if (!Number.isInteger(ct) || ct < 1 || ct > 60) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: { code: "BAD_REQUEST", message: "confirmation_threshold must be an integer between 1 and 60" },
|
||||
};
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
confirmationThreshold = ct;
|
||||
}
|
||||
|
||||
// Prepare monitor data for insertion
|
||||
const monitorData = {
|
||||
tag: body.tag.trim(),
|
||||
@@ -115,6 +128,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
type_data: body.type_data ? JSON.stringify(body.type_data) : null,
|
||||
include_degraded_in_downtime: body.include_degraded_in_downtime ?? "NO",
|
||||
is_hidden: body.is_hidden ?? "NO",
|
||||
confirmation_threshold: confirmationThreshold,
|
||||
monitor_settings_json: body.monitor_settings_json ? JSON.stringify(body.monitor_settings_json) : null,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { json, type RequestHandler } from "@sveltejs/kit";
|
||||
import db from "$lib/server/db/db";
|
||||
import { GetMonitorsParsed } from "$lib/server/controllers/monitorsController";
|
||||
import { GetMonitorsParsed, DeleteMonitorCompletelyUsingTag } from "$lib/server/controllers/monitorsController";
|
||||
import type {
|
||||
GetMonitorResponse,
|
||||
MonitorResponse,
|
||||
UpdateMonitorRequest,
|
||||
UpdateMonitorResponse,
|
||||
DeleteMonitorResponse,
|
||||
BadRequestResponse,
|
||||
} from "$lib/types/api";
|
||||
|
||||
@@ -79,6 +80,22 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
|
||||
updateData.is_hidden = body.is_hidden !== undefined ? body.is_hidden : existingMonitor.is_hidden;
|
||||
|
||||
if (body.confirmation_threshold === null) {
|
||||
// Explicit null resets the grace period to the default (1 = off); undefined keeps the existing value.
|
||||
updateData.confirmation_threshold = 1;
|
||||
} else if (body.confirmation_threshold !== undefined) {
|
||||
const ct = Number(body.confirmation_threshold);
|
||||
if (!Number.isInteger(ct) || ct < 1 || ct > 60) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: { code: "BAD_REQUEST", message: "confirmation_threshold must be an integer between 1 and 60" },
|
||||
};
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
updateData.confirmation_threshold = ct;
|
||||
} else {
|
||||
updateData.confirmation_threshold = existingMonitor.confirmation_threshold ?? 1;
|
||||
}
|
||||
|
||||
// Handle JSON fields - merge with existing data instead of replacing
|
||||
if (body.type_data !== undefined) {
|
||||
if (body.type_data === null) {
|
||||
@@ -143,3 +160,20 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
|
||||
return json(response);
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ locals }) => {
|
||||
// Monitor is validated by middleware and available in locals
|
||||
const monitor = locals.monitor!;
|
||||
|
||||
// Removes the monitor and everything keyed to its tag: monitoring data,
|
||||
// incident/maintenance/page links, alerts, alert configs, group
|
||||
// memberships (with weight rebalancing), and caches. The scheduler drops
|
||||
// the orphaned BullMQ job on its next reconcile.
|
||||
await DeleteMonitorCompletelyUsingTag(monitor.tag);
|
||||
|
||||
const response: DeleteMonitorResponse = {
|
||||
message: `Monitor '${monitor.tag}' deleted successfully`,
|
||||
};
|
||||
|
||||
return json(response);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import GC from "$lib/global-constants";
|
||||
import { UpdateMonitoringData } from "$lib/server/controllers/monitorsController";
|
||||
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
|
||||
import { SetLastMonitoringValue } from "$lib/server/cache/setGet";
|
||||
import alertingQueue from "$lib/server/queues/alertingQueue";
|
||||
|
||||
export const GET: RequestHandler = async ({ locals, url }) => {
|
||||
// Monitor is validated by middleware and available in locals
|
||||
@@ -169,6 +170,18 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
await SetLastMonitoringValue(monitorTag, latestData);
|
||||
}
|
||||
|
||||
// MANUAL samples are alert-visible (docs/adr/0005), so re-evaluate alerts once for the
|
||||
// last written sample — for NONE monitors nothing else would ever trigger evaluation.
|
||||
// UpdateMonitoringData floors both bounds to minute starts and writes through the floored
|
||||
// end inclusive, so the last stored row is always at GetMinuteStartTimestampUTC(end_ts).
|
||||
// Best-effort: the rows are already committed; a queue outage must not fail the request.
|
||||
const lastWrittenTs = GetMinuteStartTimestampUTC(body.end_ts);
|
||||
try {
|
||||
await alertingQueue.push(monitorTag, lastWrittenTs, body.status);
|
||||
} catch (err) {
|
||||
console.error(`Failed to enqueue alert evaluation for ${monitorTag} after MANUAL data write:`, err);
|
||||
}
|
||||
|
||||
// Calculate the number of data points that will be returned by GET
|
||||
// GET uses: timestamp >= start_ts AND timestamp < end_ts
|
||||
// Data is stored at minute-aligned timestamps
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import GC from "$lib/global-constants";
|
||||
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
|
||||
import { SetLastMonitoringValue } from "$lib/server/cache/setGet";
|
||||
import alertingQueue from "$lib/server/queues/alertingQueue";
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
// Monitor is validated by middleware and available in locals
|
||||
@@ -160,6 +161,15 @@ export const PATCH: RequestHandler = async ({ params, locals, request }) => {
|
||||
await SetLastMonitoringValue(monitorTag, latestData);
|
||||
}
|
||||
|
||||
// MANUAL samples are alert-visible (docs/adr/0005), so re-evaluate alerts for this
|
||||
// sample — for NONE monitors nothing else would ever trigger evaluation.
|
||||
// Best-effort: the row is already committed; a queue outage must not fail the request.
|
||||
try {
|
||||
await alertingQueue.push(monitorTag, timestamp, status);
|
||||
} catch (err) {
|
||||
console.error(`Failed to enqueue alert evaluation for ${monitorTag} after MANUAL data write:`, err);
|
||||
}
|
||||
|
||||
// Fetch the updated data
|
||||
const updatedData = await db.getMonitoringDataAt(monitorTag, timestamp);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user