mirror of
https://github.com/rajnandan1/kener.git
synced 2026-08-07 15:27:13 +00:00
Compare commits
177 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5b745ebbe | |||
| ce17afc2cb | |||
| fd1f21cad4 | |||
| 6c47ba4d9a | |||
| e61c61ccc7 | |||
| 7c297fce41 | |||
| 0412dd91fd | |||
| 4e593ec86d | |||
| 0f171fb8e6 | |||
| 86490909ec | |||
| 329cbccf75 | |||
| 10fb002a81 | |||
| 6292fe05ae | |||
| 88c6d3f76e | |||
| 70d4578a87 | |||
| ea07851b9d | |||
| 7c35d0ce66 | |||
| e7a5b1df39 | |||
| 864896c416 | |||
| 2d330b82de | |||
| f2f58e6b87 | |||
| c155a1b637 | |||
| c92c9ec273 | |||
| 4470346e23 | |||
| 29fcd22e20 | |||
| ef46836c5b | |||
| 5f01558f7c | |||
| 66350b8268 | |||
| 1889f86e01 | |||
| 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 | |||
| 555372f175 | |||
| bd5fd409d1 | |||
| f61d82e13f | |||
| f39588fff7 | |||
| 0716f271df | |||
| 1085c3e561 | |||
| 93907e6d96 | |||
| d03fd63c64 | |||
| 3ff43af787 | |||
| 7f2fef8e8e | |||
| 7658170865 | |||
| b1f8a505a5 | |||
| 104da58646 | |||
| ed6e97e8c9 | |||
| 0b25274874 | |||
| bb8ab41bfe | |||
| b43a5fb343 | |||
| 554caa5018 | |||
| ffe7403043 | |||
| a26d0ece59 |
+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;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"entry": ["src/app.html", "build/main.js", "scripts/**/*.{js,ts}", "migrations/**/*.{js,ts}", "seeds/**/*.{js,ts}"],
|
||||
"project": ["src/**/*.{js,ts,svelte}", "scripts/**/*.{js,ts}", "migrations/**/*.{js,ts}", "seeds/**/*.{js,ts}"],
|
||||
"ignore": ["src/lib/components/ui/**"],
|
||||
"ignoreDependencies": [
|
||||
"@babel/runtime",
|
||||
"js-yaml",
|
||||
"mysql2",
|
||||
"node-cache",
|
||||
"pg",
|
||||
"pg-pool",
|
||||
"randomstring",
|
||||
"style-to-object",
|
||||
"lucide-svelte",
|
||||
"marked-gfm-heading-id"
|
||||
],
|
||||
"ignoreExportsUsedInFile": {
|
||||
"interface": true,
|
||||
"type": true
|
||||
},
|
||||
"ignoreBinaries": [],
|
||||
"compilers": {
|
||||
"css": ["postcss"],
|
||||
"svelte": ["svelte"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Migration: Multi-Monitor Alerts
|
||||
*
|
||||
* This migration refactors the monitor alerting system to support many-to-many
|
||||
* relationships between alert configurations and monitors. Previously, each
|
||||
* `monitor_alerts_config` row was tied to exactly one monitor via a `monitor_tag`
|
||||
* foreign key column. This migration:
|
||||
*
|
||||
* 1. Creates a new `monitor_alerts_config_monitors` junction table that links
|
||||
* `monitor_alerts_config` rows to one or more `monitors` rows, enabling a
|
||||
* single alert configuration to fire across multiple monitors.
|
||||
*
|
||||
* 2. Adds a `monitor_tag` column to `monitor_alerts_v2` so that each firing
|
||||
* alert record knows which specific monitor triggered it (important when one
|
||||
* config covers many monitors).
|
||||
*
|
||||
* 3. Migrates existing data: copies every `monitor_alerts_config.monitor_tag`
|
||||
* value into the new junction table and backfills `monitor_alerts_v2.monitor_tag`
|
||||
* from the same source, preserving all historical alert records.
|
||||
*
|
||||
* 4. Removes the one-to-one constraint on `monitor_alerts_config.monitor_tag` by
|
||||
* dropping its foreign key and setting the column nullable (SQLite workaround:
|
||||
* nulls the column directly since SQLite cannot drop foreign key constraints
|
||||
* inline).
|
||||
*
|
||||
* 5. Adds a composite index on `monitor_alerts_v2 (config_id, monitor_tag,
|
||||
* alert_status)` for fast per-monitor alert status lookups.
|
||||
*
|
||||
* The `down` migration reverses these steps: restores the first junction-table
|
||||
* entry back onto `monitor_alerts_config.monitor_tag`, re-adds the foreign key
|
||||
* (non-SQLite), removes the `monitor_tag` column from `monitor_alerts_v2`
|
||||
* (non-SQLite), and drops the junction table.
|
||||
*/
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// Step 1: Create monitor_alerts_config_monitors junction table
|
||||
if (!(await knex.schema.hasTable("monitor_alerts_config_monitors"))) {
|
||||
await knex.schema.createTable("monitor_alerts_config_monitors", (table) => {
|
||||
table.integer("monitor_alerts_id").unsigned().notNullable();
|
||||
table.string("monitor_tag", 255).notNullable();
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
// Composite primary key
|
||||
table.primary(["monitor_alerts_id", "monitor_tag"]);
|
||||
|
||||
// Foreign keys
|
||||
table.foreign("monitor_alerts_id").references("id").inTable("monitor_alerts_config").onDelete("CASCADE");
|
||||
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: Add monitor_tag column to monitor_alerts_v2 for per-monitor alert tracking
|
||||
const hasV2Column = await knex.schema.hasColumn("monitor_alerts_v2", "monitor_tag");
|
||||
if (!hasV2Column) {
|
||||
await knex.schema.alterTable("monitor_alerts_v2", (table) => {
|
||||
table.string("monitor_tag", 255).nullable();
|
||||
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: Migrate existing data from monitor_alerts_config.monitor_tag to junction table
|
||||
// and backfill monitor_alerts_v2.monitor_tag from the same source
|
||||
const existingConfigs = await knex("monitor_alerts_config").whereNotNull("monitor_tag").select("id", "monitor_tag");
|
||||
|
||||
if (existingConfigs.length > 0) {
|
||||
// Build a config_id -> monitor_tag map for backfilling alerts
|
||||
const configTagMap = new Map<number, string>();
|
||||
const inserts = existingConfigs.map((config: { id: number; monitor_tag: string }) => {
|
||||
configTagMap.set(config.id, config.monitor_tag);
|
||||
return {
|
||||
monitor_alerts_id: config.id,
|
||||
monitor_tag: config.monitor_tag,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
};
|
||||
});
|
||||
|
||||
// Batch insert into junction table
|
||||
const chunkSize = 100;
|
||||
for (let i = 0; i < inserts.length; i += chunkSize) {
|
||||
await knex("monitor_alerts_config_monitors").insert(inserts.slice(i, i + chunkSize));
|
||||
}
|
||||
|
||||
// Backfill monitor_tag on existing monitor_alerts_v2 rows
|
||||
const existingAlerts = await knex("monitor_alerts_v2").whereNull("monitor_tag").select("id", "config_id");
|
||||
for (const alert of existingAlerts) {
|
||||
const tag = configTagMap.get(alert.config_id);
|
||||
if (tag) {
|
||||
await knex("monitor_alerts_v2").where({ id: alert.id }).update({ monitor_tag: tag });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Drop foreign key and make monitor_tag nullable on monitor_alerts_config
|
||||
const dbClient = knex.client.config.client;
|
||||
|
||||
if (dbClient === "sqlite3" || dbClient === "better-sqlite3") {
|
||||
// SQLite cannot ALTER COLUMN, so we rebuild the table with monitor_tag nullable
|
||||
await knex.transaction(async (trx) => {
|
||||
await trx.raw("PRAGMA foreign_keys = OFF");
|
||||
await trx.raw("DROP TABLE IF EXISTS monitor_alerts_config_new");
|
||||
await trx.raw(`
|
||||
CREATE TABLE monitor_alerts_config_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
monitor_tag VARCHAR(255),
|
||||
alert_for VARCHAR(50) NOT NULL,
|
||||
alert_value VARCHAR(255) NOT NULL,
|
||||
failure_threshold INTEGER NOT NULL DEFAULT 1,
|
||||
success_threshold INTEGER NOT NULL DEFAULT 1,
|
||||
alert_description TEXT,
|
||||
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
|
||||
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
|
||||
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
await trx.raw(`
|
||||
INSERT INTO monitor_alerts_config_new
|
||||
(id, monitor_tag, alert_for, alert_value, failure_threshold,
|
||||
success_threshold, alert_description, create_incident,
|
||||
is_active, severity, created_at, updated_at)
|
||||
SELECT
|
||||
id, NULL, alert_for, alert_value, failure_threshold,
|
||||
success_threshold, alert_description, create_incident,
|
||||
is_active, severity, created_at, updated_at
|
||||
FROM monitor_alerts_config
|
||||
`);
|
||||
await trx.raw("DROP TABLE monitor_alerts_config");
|
||||
await trx.raw("ALTER TABLE monitor_alerts_config_new RENAME TO monitor_alerts_config");
|
||||
try {
|
||||
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
|
||||
} catch (_e) {
|
||||
/* index may already exist */
|
||||
}
|
||||
try {
|
||||
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
|
||||
} catch (_e) {
|
||||
/* index may already exist */
|
||||
}
|
||||
await trx.raw("PRAGMA foreign_keys = ON");
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await knex.schema.alterTable("monitor_alerts_config", (table) => {
|
||||
table.dropForeign(["monitor_tag"]);
|
||||
});
|
||||
} catch (_e) {
|
||||
// Foreign key may not exist or already dropped
|
||||
}
|
||||
|
||||
await knex.schema.alterTable("monitor_alerts_config", (table) => {
|
||||
table.string("monitor_tag", 255).nullable().alter();
|
||||
});
|
||||
|
||||
await knex("monitor_alerts_config").update({ monitor_tag: null });
|
||||
}
|
||||
|
||||
// Step 5: Add composite index on monitor_alerts_v2 for fast lookups
|
||||
try {
|
||||
await knex.raw(
|
||||
"CREATE INDEX idx_monitor_alerts_v2_config_tag_status ON monitor_alerts_v2 (config_id, monitor_tag, alert_status)",
|
||||
);
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
// Step 1: Drop the composite index on monitor_alerts_v2
|
||||
try {
|
||||
await knex.raw("DROP INDEX IF EXISTS idx_monitor_alerts_v2_config_tag_status");
|
||||
} catch (_e) {
|
||||
/* index may not exist */
|
||||
}
|
||||
|
||||
// Step 2: Copy first monitor_tag from junction table back to monitor_alerts_config
|
||||
const configs = await knex("monitor_alerts_config").select("id");
|
||||
|
||||
for (const config of configs) {
|
||||
const firstMonitor = await knex("monitor_alerts_config_monitors").where({ monitor_alerts_id: config.id }).first();
|
||||
|
||||
if (firstMonitor) {
|
||||
await knex("monitor_alerts_config").where({ id: config.id }).update({ monitor_tag: firstMonitor.monitor_tag });
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Delete configs that have no monitors (can't satisfy NOT NULL)
|
||||
await knex("monitor_alerts_config").whereNull("monitor_tag").del();
|
||||
|
||||
// Step 4: Re-add foreign key constraint on monitor_alerts_config (non-SQLite only)
|
||||
const dbClient = knex.client.config.client;
|
||||
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
|
||||
await knex.schema.alterTable("monitor_alerts_config", (table) => {
|
||||
table.string("monitor_tag", 255).notNullable().alter();
|
||||
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
|
||||
});
|
||||
}
|
||||
|
||||
// Step 5: Drop monitor_tag column from monitor_alerts_v2 (non-SQLite only)
|
||||
const hasV2Column = await knex.schema.hasColumn("monitor_alerts_v2", "monitor_tag");
|
||||
if (hasV2Column) {
|
||||
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
|
||||
await knex.schema.alterTable("monitor_alerts_v2", (table) => {
|
||||
table.dropForeign(["monitor_tag"]);
|
||||
table.dropColumn("monitor_tag");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Drop junction table
|
||||
await knex.schema.dropTableIfExists("monitor_alerts_config_monitors");
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Migration: Fix SQLite monitor_alerts_config.monitor_tag NOT NULL constraint
|
||||
*
|
||||
* The earlier migration 20260325120000_multi_monitor_alerts nulled out data in
|
||||
* monitor_alerts_config.monitor_tag for SQLite but could not alter the column
|
||||
* constraint (SQLite doesn't support ALTER COLUMN). This migration recreates
|
||||
* the table with monitor_tag as nullable, preserving all data and indexes.
|
||||
*
|
||||
* Only runs on SQLite/better-sqlite3; other databases already had the column
|
||||
* altered in the previous migration.
|
||||
*/
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const dbClient = knex.client.config.client;
|
||||
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
|
||||
return; // Already handled by 20260325120000_multi_monitor_alerts
|
||||
}
|
||||
|
||||
// Check if monitor_tag is already nullable (migration 1 already rebuilt the table)
|
||||
const tableInfo: Array<{ name: string; notnull: number }> = await knex.raw(
|
||||
"PRAGMA table_info(monitor_alerts_config)",
|
||||
);
|
||||
const monitorTagCol = tableInfo.find((col) => col.name === "monitor_tag");
|
||||
if (monitorTagCol && monitorTagCol.notnull === 0) {
|
||||
return; // Column is already nullable, nothing to do
|
||||
}
|
||||
|
||||
// Column is still NOT NULL — rebuild the table to make it nullable
|
||||
try {
|
||||
await knex.transaction(async (trx) => {
|
||||
await trx.raw("PRAGMA foreign_keys = OFF");
|
||||
await trx.raw("DROP TABLE IF EXISTS monitor_alerts_config_new");
|
||||
await trx.raw(`
|
||||
CREATE TABLE monitor_alerts_config_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
monitor_tag VARCHAR(255),
|
||||
alert_for VARCHAR(50) NOT NULL,
|
||||
alert_value VARCHAR(255) NOT NULL,
|
||||
failure_threshold INTEGER NOT NULL DEFAULT 1,
|
||||
success_threshold INTEGER NOT NULL DEFAULT 1,
|
||||
alert_description TEXT,
|
||||
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
|
||||
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
|
||||
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
await trx.raw(`
|
||||
INSERT INTO monitor_alerts_config_new
|
||||
(id, monitor_tag, alert_for, alert_value, failure_threshold,
|
||||
success_threshold, alert_description, create_incident,
|
||||
is_active, severity, created_at, updated_at)
|
||||
SELECT
|
||||
id, NULL, alert_for, alert_value, failure_threshold,
|
||||
success_threshold, alert_description, create_incident,
|
||||
is_active, severity, created_at, updated_at
|
||||
FROM monitor_alerts_config
|
||||
`);
|
||||
|
||||
await trx.raw("DROP TABLE monitor_alerts_config");
|
||||
await trx.raw("ALTER TABLE monitor_alerts_config_new RENAME TO monitor_alerts_config");
|
||||
|
||||
try {
|
||||
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
|
||||
} catch (_e) {
|
||||
/* index may already exist */
|
||||
}
|
||||
try {
|
||||
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
|
||||
} catch (_e) {
|
||||
/* index may already exist */
|
||||
}
|
||||
await trx.raw("PRAGMA foreign_keys = ON");
|
||||
});
|
||||
} catch (e) {
|
||||
await knex.raw("PRAGMA foreign_keys = ON");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const dbClient = knex.client.config.client;
|
||||
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Revert: make monitor_tag NOT NULL again via table rebuild
|
||||
try {
|
||||
await knex.transaction(async (trx) => {
|
||||
await trx.raw("PRAGMA foreign_keys = OFF");
|
||||
await trx.raw(`
|
||||
CREATE TABLE monitor_alerts_config_old (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
monitor_tag VARCHAR(255) NOT NULL,
|
||||
alert_for VARCHAR(50) NOT NULL,
|
||||
alert_value VARCHAR(255) NOT NULL,
|
||||
failure_threshold INTEGER NOT NULL DEFAULT 1,
|
||||
success_threshold INTEGER NOT NULL DEFAULT 1,
|
||||
alert_description TEXT,
|
||||
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
|
||||
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
|
||||
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Only copy rows that have a non-null monitor_tag
|
||||
await trx.raw(`
|
||||
INSERT INTO monitor_alerts_config_old
|
||||
(id, monitor_tag, alert_for, alert_value, failure_threshold,
|
||||
success_threshold, alert_description, create_incident,
|
||||
is_active, severity, created_at, updated_at)
|
||||
SELECT
|
||||
id, monitor_tag, alert_for, alert_value, failure_threshold,
|
||||
success_threshold, alert_description, create_incident,
|
||||
is_active, severity, created_at, updated_at
|
||||
FROM monitor_alerts_config
|
||||
WHERE monitor_tag IS NOT NULL
|
||||
`);
|
||||
|
||||
await trx.raw("DROP TABLE monitor_alerts_config");
|
||||
await trx.raw("ALTER TABLE monitor_alerts_config_old RENAME TO monitor_alerts_config");
|
||||
|
||||
try {
|
||||
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
|
||||
} catch (_e) {
|
||||
/* index may already exist */
|
||||
}
|
||||
try {
|
||||
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
|
||||
} catch (_e) {
|
||||
/* index may already exist */
|
||||
}
|
||||
await trx.raw("PRAGMA foreign_keys = ON");
|
||||
});
|
||||
} catch (e) {
|
||||
await knex.raw("PRAGMA foreign_keys = ON");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// 1. Roles table
|
||||
if (!(await knex.schema.hasTable("roles"))) {
|
||||
await knex.schema.createTable("roles", (table) => {
|
||||
table.string("id", 100).primary();
|
||||
table.text("role_name").notNullable();
|
||||
table.integer("readonly").notNullable().defaultTo(0);
|
||||
table.string("status", 20).notNullable().defaultTo("ACTIVE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Permissions table
|
||||
if (!(await knex.schema.hasTable("permissions"))) {
|
||||
await knex.schema.createTable("permissions", (table) => {
|
||||
table.string("id", 100).primary();
|
||||
table.text("permission_name").notNullable();
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Roles ↔ Permissions junction table
|
||||
if (!(await knex.schema.hasTable("roles_permissions"))) {
|
||||
await knex.schema.createTable("roles_permissions", (table) => {
|
||||
table.string("roles_id", 100).notNullable().references("id").inTable("roles").onDelete("CASCADE");
|
||||
table.string("permissions_id", 100).notNullable().references("id").inTable("permissions").onDelete("CASCADE");
|
||||
table.string("status", 20).notNullable().defaultTo("ACTIVE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
table.primary(["roles_id", "permissions_id"]);
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Users ↔ Roles junction table
|
||||
if (!(await knex.schema.hasTable("users_roles"))) {
|
||||
await knex.schema.createTable("users_roles", (table) => {
|
||||
table.string("roles_id", 100).notNullable().references("id").inTable("roles").onDelete("CASCADE");
|
||||
table.integer("users_id").unsigned().notNullable().references("id").inTable("users").onDelete("CASCADE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
table.primary(["roles_id", "users_id"]);
|
||||
table.index("users_id", "idx_users_roles_users_id");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists("users_roles");
|
||||
await knex.schema.dropTableIfExists("roles_permissions");
|
||||
await knex.schema.dropTableIfExists("permissions");
|
||||
await knex.schema.dropTableIfExists("roles");
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
// Maps the legacy users.role string to the new roles.id value.
|
||||
// The old default was "user"; everything unmapped falls back to "member".
|
||||
const ROLE_MAP: Record<string, string> = {
|
||||
admin: "admin",
|
||||
editor: "editor",
|
||||
member: "member",
|
||||
user: "member",
|
||||
};
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn("users", "role");
|
||||
if (!hasColumn) return;
|
||||
|
||||
// 1. Ensure the three target roles exist so FK inserts succeed.
|
||||
// Seeds will reconcile permissions later; we only need the rows.
|
||||
const rolesToEnsure = [
|
||||
{ id: "admin", role_name: "Administrator" },
|
||||
{ id: "editor", role_name: "Editor" },
|
||||
{ id: "member", role_name: "Member" },
|
||||
];
|
||||
for (const role of rolesToEnsure) {
|
||||
const exists = await knex("roles").where("id", role.id).first();
|
||||
if (!exists) {
|
||||
await knex("roles").insert({
|
||||
id: role.id,
|
||||
role_name: role.role_name,
|
||||
readonly: 1,
|
||||
status: "ACTIVE",
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Read users.role into memory BEFORE dropping the column.
|
||||
// On SQLite, dropColumn recreates the table (create → copy → drop → rename),
|
||||
// which can discard DML inserts to tables with FKs pointing at users.
|
||||
const users: Array<{ id: number; role: string }> = await knex("users").select("id", "role");
|
||||
|
||||
// 3. Drop the column first.
|
||||
await knex.schema.alterTable("users", (table) => {
|
||||
table.dropColumn("role");
|
||||
});
|
||||
|
||||
// 4. Now populate users_roles from the in-memory snapshot.
|
||||
for (const user of users) {
|
||||
const newRoleId = ROLE_MAP[user.role] ?? "member";
|
||||
|
||||
const alreadyAssigned = await knex("users_roles").where({ roles_id: newRoleId, users_id: user.id }).first();
|
||||
|
||||
if (!alreadyAssigned) {
|
||||
await knex("users_roles").insert({
|
||||
roles_id: newRoleId,
|
||||
users_id: user.id,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse map: pick the highest-precedence role when backfilling.
|
||||
const REVERSE_ROLE_PRECEDENCE: string[] = ["admin", "editor", "member"];
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn("users", "role");
|
||||
if (!hasColumn) {
|
||||
await knex.schema.alterTable("users", (table) => {
|
||||
table.string("role").defaultTo("member");
|
||||
});
|
||||
}
|
||||
|
||||
// Backfill users.role from users_roles using deterministic precedence
|
||||
const assignments: Array<{ users_id: number; roles_id: string }> = await knex("users_roles").select(
|
||||
"users_id",
|
||||
"roles_id",
|
||||
);
|
||||
|
||||
// Group roles by user
|
||||
const userRolesMap = new Map<number, string[]>();
|
||||
for (const row of assignments) {
|
||||
const list = userRolesMap.get(row.users_id) || [];
|
||||
list.push(row.roles_id);
|
||||
userRolesMap.set(row.users_id, list);
|
||||
}
|
||||
|
||||
// Pick highest-precedence role for each user
|
||||
for (const [userId, roleIds] of userRolesMap) {
|
||||
const bestRole = REVERSE_ROLE_PRECEDENCE.find((r) => roleIds.includes(r)) || roleIds[0] || "member";
|
||||
await knex("users").where("id", userId).update({ role: bestRole });
|
||||
}
|
||||
}
|
||||
@@ -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
+211
-221
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "kener",
|
||||
"version": "4.0.20",
|
||||
"version": "4.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kener",
|
||||
"version": "4.0.20",
|
||||
"version": "4.1.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
@@ -89,7 +89,7 @@
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-node": "^5.4.0",
|
||||
"@sveltejs/kit": "^2.48.5",
|
||||
"@sveltejs/kit": "^2.53.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
@@ -114,7 +114,7 @@
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"svelte": "^5.43.8",
|
||||
"svelte": "^5.53.5",
|
||||
"svelte-awesome-color-picker": "^4.1.0",
|
||||
"svelte-check": "^4.3.4",
|
||||
"svelte-sonner": "^1.0.7",
|
||||
@@ -1792,9 +1792,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
||||
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
|
||||
"integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1805,9 +1805,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
|
||||
"integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1818,9 +1818,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
|
||||
"integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1831,9 +1831,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
|
||||
"integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1844,9 +1844,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
|
||||
"integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1857,9 +1857,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
|
||||
"integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1870,9 +1870,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
|
||||
"integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1883,9 +1883,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
|
||||
"integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1896,9 +1896,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1909,9 +1909,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1922,9 +1922,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1935,9 +1935,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1948,9 +1948,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1961,9 +1961,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1974,9 +1974,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1987,9 +1987,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -2000,9 +2000,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -2013,9 +2013,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2026,9 +2026,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2039,9 +2039,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz",
|
||||
"integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2052,9 +2052,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
|
||||
"integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2065,9 +2065,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
|
||||
"integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2078,9 +2078,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
|
||||
"integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -2091,9 +2091,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2104,9 +2104,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
|
||||
"integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2262,9 +2262,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/kit": {
|
||||
"version": "2.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.52.0.tgz",
|
||||
"integrity": "sha512-zG+HmJuSF7eC0e7xt2htlOcEMAdEtlVdb7+gAr+ef08EhtwUsjLxcAwBgUCJY3/5p08OVOxVZti91WfXeuLvsg==",
|
||||
"version": "2.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz",
|
||||
"integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
@@ -2272,12 +2272,11 @@
|
||||
"@types/cookie": "^0.6.0",
|
||||
"acorn": "^8.14.1",
|
||||
"cookie": "^0.6.0",
|
||||
"devalue": "^5.6.2",
|
||||
"devalue": "^5.6.4",
|
||||
"esm-env": "^1.2.2",
|
||||
"kleur": "^4.1.5",
|
||||
"magic-string": "^0.30.5",
|
||||
"mrmime": "^2.0.0",
|
||||
"sade": "^1.8.1",
|
||||
"set-cookie-parser": "^3.0.0",
|
||||
"sirv": "^3.0.0"
|
||||
},
|
||||
@@ -2289,10 +2288,10 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
|
||||
"svelte": "^4.0.0 || ^5.0.0-next.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
|
||||
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentelemetry/api": {
|
||||
@@ -2868,8 +2867,7 @@
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@uiw/codemirror-theme-github": {
|
||||
"version": "4.25.4",
|
||||
@@ -2984,9 +2982,9 @@
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -3142,10 +3140,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/barse": {
|
||||
"version": "0.4.3",
|
||||
@@ -3334,13 +3335,15 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
@@ -3755,12 +3758,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
@@ -3809,9 +3806,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
@@ -4538,9 +4535,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/devalue": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
|
||||
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
|
||||
"version": "5.6.4",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
|
||||
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dns2": {
|
||||
@@ -4591,9 +4588,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -5064,25 +5061,16 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-sha256": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
|
||||
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
|
||||
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -5091,7 +5079,24 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strnum": "^2.1.0"
|
||||
"path-expression-matcher": "^1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.5.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz",
|
||||
"integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.1.4",
|
||||
"path-expression-matcher": "^1.2.0",
|
||||
"strnum": "^2.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
@@ -5577,42 +5582,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/balanced-match": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz",
|
||||
"integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
|
||||
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
|
||||
"integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/globalthis": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
|
||||
@@ -7151,27 +7120,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser": {
|
||||
"version": "3.9.1",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.1.tgz",
|
||||
"integrity": "sha512-6vHZcco3fWsDMkf4Vz9iAfxvwrKNGbHx0dV1RKVphQ/zaNY34Buc7D37LSa09jeSeybWzYcTPjhiZFxzVRJedA==",
|
||||
"version": "3.9.5",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.5.tgz",
|
||||
"integrity": "sha512-i5mXdEqDrh7I095uiQiebOrc00AOIJRLlpze1nBb82oZpI4GaCJIn9ypXR2bqb/Ayr7q+nmT6k11yYU5Age/Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.8",
|
||||
"encoding-japanese": "2.2.0",
|
||||
"he": "1.2.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"iconv-lite": "0.7.0",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libmime": "5.3.7",
|
||||
"linkify-it": "5.0.0",
|
||||
"nodemailer": "7.0.11",
|
||||
"nodemailer": "8.0.3",
|
||||
"punycode.js": "2.3.1",
|
||||
"tlds": "1.261.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser/node_modules/iconv-lite": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
||||
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@@ -7185,9 +7154,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser/node_modules/nodemailer": {
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||
"integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==",
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.3.tgz",
|
||||
"integrity": "sha512-JQNBqvK+bj3NMhUFR3wmCl3SYcOeMotDiwDBvIoCuQdF0PvlIY0BH+FJ2CG7u4cXKPChplE78oowlH/Otsc4ZQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -7358,15 +7327,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"license": "ISC",
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
@@ -7466,6 +7438,7 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
@@ -8090,6 +8063,21 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz",
|
||||
"integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -8979,9 +8967,9 @@
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
|
||||
"integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
@@ -8994,31 +8982,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
||||
"@rollup/rollup-android-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-x64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.57.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.60.0",
|
||||
"@rollup/rollup-android-arm64": "4.60.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.60.0",
|
||||
"@rollup/rollup-darwin-x64": "4.60.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.60.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.60.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.60.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.60.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.60.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.60.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.60.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.60.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.60.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.60.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.60.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -9092,6 +9080,7 @@
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mri": "^1.1.0"
|
||||
@@ -9849,9 +9838,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
||||
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
|
||||
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -9904,20 +9893,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "5.50.1",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.50.1.tgz",
|
||||
"integrity": "sha512-/Jlom4ddkISyVHXpM2O5dXP9pYnaiFrVQzPbIL1/pEoOa77ZunCb6nDgUCTNCQ/X3t64z9ukrK6R+BbB3kPR3A==",
|
||||
"version": "5.54.1",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.54.1.tgz",
|
||||
"integrity": "sha512-ow8tncN097Ty8U1H+C3bM1xNlsCbnO2UZeN0lWBnv8f3jKho7QTTQ2LWbMXrPQDodLjH91n4kpNnLolyRhVE6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||
"@types/estree": "^1.0.5",
|
||||
"@types/trusted-types": "^2.0.7",
|
||||
"acorn": "^8.12.1",
|
||||
"aria-query": "^5.3.1",
|
||||
"aria-query": "5.3.1",
|
||||
"axobject-query": "^4.1.0",
|
||||
"clsx": "^2.1.1",
|
||||
"devalue": "^5.6.2",
|
||||
"devalue": "^5.6.4",
|
||||
"esm-env": "^1.2.1",
|
||||
"esrap": "^2.2.2",
|
||||
"is-reference": "^3.0.3",
|
||||
@@ -10448,9 +10438,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz",
|
||||
"integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==",
|
||||
"version": "7.24.5",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz",
|
||||
"integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
|
||||
+13
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kener",
|
||||
"version": "4.0.20",
|
||||
"version": "4.1.2",
|
||||
"type": "module",
|
||||
"private": false,
|
||||
"license": "MIT",
|
||||
@@ -55,7 +55,7 @@
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-node": "^5.4.0",
|
||||
"@sveltejs/kit": "^2.48.5",
|
||||
"@sveltejs/kit": "^2.53.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
@@ -80,7 +80,7 @@
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"svelte": "^5.43.8",
|
||||
"svelte": "^5.53.5",
|
||||
"svelte-awesome-color-picker": "^4.1.0",
|
||||
"svelte-check": "^4.3.4",
|
||||
"svelte-sonner": "^1.0.7",
|
||||
@@ -172,5 +172,15 @@
|
||||
"style-to-object": "^1.0.14",
|
||||
"svelte-codemirror-editor": "^2.1.0",
|
||||
"vite-plugin-package-version": "^1.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"fast-xml-parser": "^5.5.6",
|
||||
"rollup": "^4.59.0",
|
||||
"undici": "^7.24.0",
|
||||
"minimatch": "^10.2.3",
|
||||
"devalue": "^5.6.4",
|
||||
"dompurify": "^3.3.2",
|
||||
"cookie": "^0.7.0",
|
||||
"mailparser": "^3.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
+110
-70
@@ -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);
|
||||
}
|
||||
|
||||
+58
-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) {
|
||||
@@ -152,6 +152,21 @@ function IsValidNameServer(nameServer: string): boolean {
|
||||
const regex = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/;
|
||||
return regex.test(nameServer);
|
||||
}
|
||||
function IsValidDnsResolver(resolver: string): boolean {
|
||||
const normalizedResolver = resolver.trim();
|
||||
const ipv4Parts = normalizedResolver.split(".");
|
||||
if (
|
||||
ipv4Parts.length === 4 &&
|
||||
ipv4Parts.every((part) => /^\d+$/.test(part) && Number(part) >= 0 && Number(part) <= 255)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const ipType = ValidateIpAddress(normalizedResolver);
|
||||
if (ipType === "IP6") {
|
||||
return true;
|
||||
}
|
||||
return IsValidHost(normalizedResolver);
|
||||
}
|
||||
const IsValidURL = function (url: string): boolean {
|
||||
return /^(http|https):\/\/[^ "]+$/.test(url);
|
||||
};
|
||||
@@ -316,46 +331,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 {
|
||||
@@ -376,8 +398,10 @@ export {
|
||||
ValidateIpAddress,
|
||||
IsValidHost,
|
||||
IsValidNameServer,
|
||||
IsValidDnsResolver,
|
||||
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"
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
config: ChartConfig;
|
||||
} = $props();
|
||||
|
||||
const chartId = `chart-${id || uid.replace(/:/g, "")}`;
|
||||
const chartId = $derived(`chart-${id || uid.replace(/:/g, "")}`);
|
||||
|
||||
setChartContext({
|
||||
get config() {
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
|
||||
type ToggleVariants = VariantProps<typeof toggleVariants>;
|
||||
|
||||
interface ToggleGroupContext extends ToggleVariants {
|
||||
spacing?: number;
|
||||
interface ToggleGroupContext {
|
||||
get variant(): ToggleVariants["variant"];
|
||||
get size(): ToggleVariants["size"];
|
||||
get spacing(): number | undefined;
|
||||
}
|
||||
|
||||
export function setToggleGroupCtx(props: ToggleGroupContext) {
|
||||
@@ -33,9 +35,15 @@
|
||||
}: ToggleGroupPrimitive.RootProps & ToggleVariants & { spacing?: number } = $props();
|
||||
|
||||
setToggleGroupCtx({
|
||||
variant,
|
||||
size,
|
||||
spacing,
|
||||
get variant() {
|
||||
return variant;
|
||||
},
|
||||
get size() {
|
||||
return size;
|
||||
},
|
||||
get spacing() {
|
||||
return spacing;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+31
-29
@@ -22,39 +22,39 @@
|
||||
"Day": "Den",
|
||||
"Day Uptime": "Denní dostupnost",
|
||||
"Days": "Dní",
|
||||
"Degraded": "Zhoršený",
|
||||
"DEGRADED": "ZHORŠENÝ",
|
||||
"Degraded Performance": "Zhoršený výkon",
|
||||
"Didn't receive the code? Resend": "Nepřišel vám kód? Odeslat znovu",
|
||||
"Down": "Nedostupný",
|
||||
"DOWN": "VÝPADEK",
|
||||
"Degraded": "Omezený",
|
||||
"DEGRADED": "OMEZENÝ",
|
||||
"Degraded Performance": "Snížený výkon",
|
||||
"Didn't receive the code? Resend": "Nepřišel kód? Odeslat znovu",
|
||||
"Down": "Nedostupné",
|
||||
"DOWN": "NEDOSTUPNÉ",
|
||||
"Duration": "Trvání",
|
||||
"Edit Monitor": "Upravit monitor",
|
||||
"Email address": "E-mailová adresa",
|
||||
"Embed Monitor": "Vložit monitor",
|
||||
"Embed this monitor in your website or app": "Vložte tento monitor na svůj web nebo do aplikace",
|
||||
"Embed this monitor in your website or app": "Vložte tento monitor na web nebo do aplikace",
|
||||
"End Time": "Konec",
|
||||
"Enter the verification code sent to your email.": "Zadejte ověřovací kód zaslaný na váš e-mail.",
|
||||
"Enter the verification code sent to your email.": "Zadejte ověřovací kód zaslaný na e-mail",
|
||||
"Events": "Události",
|
||||
"Failed to load data": "Nepodařilo se načíst data",
|
||||
"Failed to load latency data": "Nepodařilo se načíst data latence",
|
||||
"Failed to load status data for this day": "Nepodařilo se načíst data stavu pro tento den",
|
||||
"Failed to send verification code": "Nepodařilo se odeslat ověřovací kód",
|
||||
"Failed to update preference": "Nepodařilo se aktualizovat nastavení",
|
||||
"Failed to update preference": "Nepodařilo se uložit nastavení",
|
||||
"Format": "Formát",
|
||||
"Get badges for this monitor": "Získat odznaky pro tento monitor",
|
||||
"Get notified about incidents and scheduled maintenance.": "Dostávejte upozornění na incidenty a plánovanou údržbu.",
|
||||
"Get notified about incidents and scheduled maintenance.": "Dostávejte upozornění na incidenty a plánovanou údržbu",
|
||||
"Get notified about incidents updates": "Dostávejte upozornění na aktualizace incidentů",
|
||||
"Get notified about scheduled maintenance": "Dostávejte upozornění na plánovanou údržbu",
|
||||
"Home": "Domů",
|
||||
"IDENTIFIED": "IDENTIFIKOVÁNO",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Dopad",
|
||||
"incident": "Incident",
|
||||
"incident": "incident",
|
||||
"Incident": "Incident",
|
||||
"Incident Updates": "Aktualizace incidentů",
|
||||
"Incidents": "Incidenty",
|
||||
"Included Monitors (%count)": "Zahrnutých monitorů: %count",
|
||||
"Included Monitors (%count)": "Zahrnuté monitory (%count)",
|
||||
"INVESTIGATING": "VYŠETŘOVÁNÍ",
|
||||
"Last Updated": "Naposledy aktualizováno",
|
||||
"Latency": "Latence",
|
||||
@@ -62,18 +62,18 @@
|
||||
"Latency Over Time": "Latence v čase",
|
||||
"Latency Trend": "Trend latence",
|
||||
"Latest Latency": "Poslední latence",
|
||||
"Latest Status": "Poslední stav",
|
||||
"Latest Status": "Naposledy zjištěný stav",
|
||||
"Light": "Světlý",
|
||||
"Live Status": "Aktuální stav",
|
||||
"Loading your preferences...": "Načítám vaše nastavení...",
|
||||
"maintenance": "Údržba",
|
||||
"Loading your preferences...": "Načítání nastavení...",
|
||||
"maintenance": "údržba",
|
||||
"Maintenance": "Údržba",
|
||||
"MAINTENANCE": "ÚDRŽBA",
|
||||
"Maintenance Updates": "Aktualizace údržby",
|
||||
"Maintenances": "Údrždy",
|
||||
"Maintenances": "Údržby",
|
||||
"Major System Outage": "Závažný výpadek systému",
|
||||
"Manage Site": "Spravovat stránku",
|
||||
"Manage your notification preferences.": "Spravujte svá nastavení oznámení.",
|
||||
"Manage your notification preferences.": "Spravujte nastavení oznámení",
|
||||
"Max Latency": "Max. latence",
|
||||
"maximum": "maximální",
|
||||
"Maximum Latency": "Maximální latence",
|
||||
@@ -82,58 +82,60 @@
|
||||
"Minimum Latency": "Minimální latence",
|
||||
"Minute-by-minute status data for this day": "Minutová data stavu pro tento den",
|
||||
"MONITORING": "MONITOROVÁNÍ",
|
||||
"Network error. Please try again.": "Chyba sítě. Zkuste to prosím znovu.",
|
||||
"Network error. Please try again.": "Chyba sítě. Zkuste to znovu",
|
||||
"No Events in %currentMonth": "V měsíci %currentMonth nejsou plánované žádné události",
|
||||
"No events to show": "Žádné události k zobrazení",
|
||||
"No incidents for this day": "Pro tento den nejsou evidovány žádné incidenty",
|
||||
"No latency data available for this day": "Pro tento den nejsou k dispozici data latence",
|
||||
"No latency data available for this day": "Pro tento den nejsou dostupná data latence",
|
||||
"No maintenances for this day": "Pro tento den není naplánovaná žádná údržba",
|
||||
"No monitors affected": "Žádné zasažené monitory",
|
||||
"No monitors available.": "Žádné dostupné monitory.",
|
||||
"No monitors available.": "Žádné dostupné monitory",
|
||||
"No ongoing maintenances": "Žádná probíhající údržba",
|
||||
"No past maintenances": "Žádná minulá údržba",
|
||||
"No Status Available": "Stav není k dispozici",
|
||||
"No upcoming maintenances": "Žádná nadcházející údržba",
|
||||
"No Updates": "Žádné aktualizace",
|
||||
"No updates yet": "Zatím bez aktualizací",
|
||||
"No updates yet": "Zatím žádné aktualizace",
|
||||
"NO_DATA": "Žádná data",
|
||||
"Notifications": "Oznámení",
|
||||
"One-time": "Jednorázově",
|
||||
"Ongoing": "Probíhající",
|
||||
"ONGOING": "PROBÍHAJÍCÍ",
|
||||
"Ongoing Maintenances": "Probíhající údržby",
|
||||
"Operational": "V provozu",
|
||||
"Partial Degraded Performance": "Částečně zhoršený výkon",
|
||||
"Partial Degraded Performance": "Částečně omezený provoz",
|
||||
"Partial System Outage": "Částečný výpadek systému",
|
||||
"Past": "Minulé",
|
||||
"Per-Minute Status": "Stav po minutách",
|
||||
"Pinging": "Pingování",
|
||||
"Please enter a valid email address": "Zadejte platnou e-mailovou adresu",
|
||||
"Please enter the 6-digit verification code": "Zadejte prosím 6místný ověřovací kód",
|
||||
"Please enter the 6-digit verification code": "Zadejte 6místný ověřovací kód",
|
||||
"Read less": "Zobrazit méně",
|
||||
"Read more": "Zobrazit více",
|
||||
"READY": "PŘIPRAVENO",
|
||||
"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",
|
||||
"Script": "Skript",
|
||||
"Select Language": "Vyberte jazyk",
|
||||
"Select latency metric to display": "Vyberte metriku latence k zobrazení",
|
||||
"Select latency metric to display": "Vyberte metriku latence",
|
||||
"Select Range": "Vyberte rozsah",
|
||||
"Sending...": "Odesílání...",
|
||||
"Standard": "Standardní",
|
||||
"Start Time": "Začátek",
|
||||
"Status": "Stav",
|
||||
"Status Badge": "Odznak stavu",
|
||||
"Status Embed": "Status pro vložení",
|
||||
"Status Embed": "Stav pro vložení",
|
||||
"Status history and latency trend": "Historie stavu a trend latence",
|
||||
"Subscribe": "Odebírat",
|
||||
"Subscribe": "Přihlásit se k odběru",
|
||||
"Subscribe to Updates": "Odebírat aktualizace",
|
||||
"Theme": "Motiv",
|
||||
"There are no incidents or maintenances scheduled for this month.": "Na tento měsíc nejsou naplánované incidenty ani údržba.",
|
||||
"There are no ongoing incidents or maintenance events.": "V tuto chvíli neprobíhají žádné incidenty ani údržba.",
|
||||
"There are no incidents or maintenances scheduled for this month.": "Na tento měsíc nejsou naplánované žádné incidenty ani údržby",
|
||||
"There are no ongoing incidents or maintenance events.": "V tuto chvíli neprobíhají žádné incidenty ani údržba",
|
||||
"Timeline": "Časová osa",
|
||||
"Total Incidents": "Celkem incidentů",
|
||||
"Total Maintenances": "Celkem údržeb",
|
||||
@@ -151,6 +153,6 @@
|
||||
"Verification failed": "Ověření se nezdařilo",
|
||||
"Verify": "Ověřit",
|
||||
"Verifying": "Ověřování",
|
||||
"We sent a 6-digit code to": "Poslali jsme 6místný kód na"
|
||||
"We sent a 6-digit code to": "Odeslali jsme 6místný kód na"
|
||||
}
|
||||
}
|
||||
|
||||
+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": "Скрипт",
|
||||
|
||||
+36
-34
@@ -6,7 +6,7 @@
|
||||
"30 Days": "30 dní",
|
||||
"7 Days": "7 dní",
|
||||
"90 Days": "90 dní",
|
||||
"Affected Monitors (%count)": "Ovládacie monitory (%count)",
|
||||
"Affected Monitors (%count)": "Ovplyvnené monitory (%count)",
|
||||
"All Systems Operational": "Všetky systémy sú v prevádzke",
|
||||
"average": "priemerná",
|
||||
"Average Latency": "Priemerná latencia",
|
||||
@@ -22,58 +22,58 @@
|
||||
"Day": "Deň",
|
||||
"Day Uptime": "Denná dostupnosť",
|
||||
"Days": "Dní",
|
||||
"Degraded": "Zhoršený",
|
||||
"DEGRADED": "ZHORŠENÝ",
|
||||
"Degraded Performance": "Zhoršený výkon",
|
||||
"Didn't receive the code? Resend": "Nedostali ste kód? Odoslať znova",
|
||||
"Down": "Nedostupný",
|
||||
"DOWN": "VÝPADOK",
|
||||
"Degraded": "Obmedzený",
|
||||
"DEGRADED": "OBMEDZENÝ",
|
||||
"Degraded Performance": "Znížený výkon",
|
||||
"Didn't receive the code? Resend": "Neprišiel kód? Odoslať znova",
|
||||
"Down": "Nedostupné",
|
||||
"DOWN": "NEDOSTUPNÉ",
|
||||
"Duration": "Trvanie",
|
||||
"Edit Monitor": "Upraviť monitor",
|
||||
"Email address": "E-mailová adresa",
|
||||
"Embed Monitor": "Vložiť monitor",
|
||||
"Embed this monitor in your website or app": "Vložte tento monitor na svoj web alebo do aplikácie",
|
||||
"Embed this monitor in your website or app": "Vložte tento monitor na web alebo do aplikácie",
|
||||
"End Time": "Koniec",
|
||||
"Enter the verification code sent to your email.": "Zadajte overovací kód zaslaný na váš e-mail.",
|
||||
"Enter the verification code sent to your email.": "Zadajte overovací kód zaslaný na e-mail",
|
||||
"Events": "Udalosti",
|
||||
"Failed to load data": "Nepodarilo sa načítať dáta",
|
||||
"Failed to load latency data": "Nepodarilo sa načítať dáta latencie",
|
||||
"Failed to load status data for this day": "Nepodarilo sa načítať dáta stavu pre tento deň",
|
||||
"Failed to load status data for this day": "Nepodarilo sa načítať stavové dáta pre tento deň",
|
||||
"Failed to send verification code": "Nepodarilo sa odoslať overovací kód",
|
||||
"Failed to update preference": "Nepodarilo sa aktualizovať nastavenia",
|
||||
"Failed to update preference": "Nepodarilo sa uložiť nastavenie",
|
||||
"Format": "Formát",
|
||||
"Get badges for this monitor": "Získať odznaky pre tento monitor",
|
||||
"Get notified about incidents and scheduled maintenance.": "Dostávajte upozornenia na incidenty a plánovanú údržbu.",
|
||||
"Get notified about incidents and scheduled maintenance.": "Dostávajte upozornenia na incidenty a plánovanú údržbu",
|
||||
"Get notified about incidents updates": "Dostávajte upozornenia na aktualizácie incidentov",
|
||||
"Get notified about scheduled maintenance": "Dostávajte upozornenia na plánovanú údržbu",
|
||||
"Home": "Domov",
|
||||
"IDENTIFIED": "IDENTIFIKOVANÉ",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Dopad",
|
||||
"incident": "Incident",
|
||||
"incident": "incident",
|
||||
"Incident": "Incident",
|
||||
"Incident Updates": "Aktualizácie incidentov",
|
||||
"Incidents": "Incidenty",
|
||||
"Included Monitors (%count)": "Zahrnutých monitorov: %count",
|
||||
"INVESTIGATING": "VYŠETROVANIE",
|
||||
"Included Monitors (%count)": "Zahrnuté monitory (%count)",
|
||||
"INVESTIGATING": "PREBIEHA VYŠETROVANIE",
|
||||
"Last Updated": "Naposledy aktualizované",
|
||||
"Latency": "Latencia",
|
||||
"Latency Embed": "Vložená latencia",
|
||||
"Latency Over Time": "Latencia v čase",
|
||||
"Latency Trend": "Trend latencie",
|
||||
"Latest Latency": "Posledná latencia",
|
||||
"Latest Status": "Posledný stav",
|
||||
"Latest Status": "Naposledy zistený stav",
|
||||
"Light": "Svetlý",
|
||||
"Live Status": "Aktuálny stav",
|
||||
"Loading your preferences...": "Načítavam vaše nastavenia...",
|
||||
"maintenance": "Údržba",
|
||||
"Loading your preferences...": "Načítavanie nastavení...",
|
||||
"maintenance": "údržba",
|
||||
"Maintenance": "Údržba",
|
||||
"MAINTENANCE": "ÚDRŽBA",
|
||||
"Maintenance Updates": "Aktualizácie údržby",
|
||||
"Maintenances": "Údržby",
|
||||
"Major System Outage": "Závažný výpadok systému",
|
||||
"Manage Site": "Spravovať stránku",
|
||||
"Manage your notification preferences.": "Spravujte svoje nastavenia oznámení.",
|
||||
"Manage your notification preferences.": "Spravujte nastavenia upozornení",
|
||||
"Max Latency": "Max. latencia",
|
||||
"maximum": "maximálna",
|
||||
"Maximum Latency": "Maximálna latencia",
|
||||
@@ -82,27 +82,28 @@
|
||||
"Minimum Latency": "Minimálna latencia",
|
||||
"Minute-by-minute status data for this day": "Minútové dáta stavu pre tento deň",
|
||||
"MONITORING": "MONITOROVANIE",
|
||||
"Network error. Please try again.": "Chyba siete. Skúste to znova.",
|
||||
"No Events in %currentMonth": "V mesiaci %currentMonth nie sú plánované žiadne udalosti",
|
||||
"Network error. Please try again.": "Chyba siete. Skúste to znova",
|
||||
"No Events in %currentMonth": "V mesiaci %currentMonth nie sú žiadne udalosti",
|
||||
"No events to show": "Žiadne udalosti na zobrazenie",
|
||||
"No incidents for this day": "Pre tento deň nie sú evidované žiadne incidenty",
|
||||
"No latency data available for this day": "Pre tento deň nie sú k dispozícii dáta latencie",
|
||||
"No latency data available for this day": "Pre tento deň nie sú dostupné dáta latencie",
|
||||
"No maintenances for this day": "Pre tento deň nie je naplánovaná žiadna údržba",
|
||||
"No monitors affected": "Žiadne zasiahnuté monitory",
|
||||
"No monitors available.": "Žiadne dostupné monitory.",
|
||||
"No monitors affected": "Žiadne ovplyvnené monitory",
|
||||
"No monitors available.": "Žiadne dostupné monitory",
|
||||
"No ongoing maintenances": "Žiadna prebiehajúca údržba",
|
||||
"No past maintenances": "Žiadna minula údržba",
|
||||
"No past maintenances": "Žiadna minulá údržba",
|
||||
"No Status Available": "Stav nie je k dispozícii",
|
||||
"No upcoming maintenances": "Žiadna nadchádzajúca údržba",
|
||||
"No Updates": "Žiadne aktualizácie",
|
||||
"No updates yet": "Zatiaľ bez aktualizácií",
|
||||
"No updates yet": "Zatiaľ žiadne aktualizácie",
|
||||
"NO_DATA": "Žiadne dáta",
|
||||
"Notifications": "Oznámenia",
|
||||
"Notifications": "Upozornenia",
|
||||
"One-time": "Jednorazovo",
|
||||
"Ongoing": "Prebiehajúce",
|
||||
"ONGOING": "PREBIEHAJÚCE",
|
||||
"Ongoing Maintenances": "Prebiehajúce údržby",
|
||||
"Operational": "V prevádzke",
|
||||
"Partial Degraded Performance": "Čiastočne zhoršený výkon",
|
||||
"Partial Degraded Performance": "Čiastočne obmedzený výkon",
|
||||
"Partial System Outage": "Čiastočný výpadok systému",
|
||||
"Past": "Minulé",
|
||||
"Per-Minute Status": "Stav po minútach",
|
||||
@@ -115,25 +116,26 @@
|
||||
"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",
|
||||
"Script": "Skript",
|
||||
"Select Language": "Vyberte jazyk",
|
||||
"Select latency metric to display": "Vyberte metriku latencie na zobrazenie",
|
||||
"Select latency metric to display": "Vyberte metriku latencie",
|
||||
"Select Range": "Vyberte rozsah",
|
||||
"Sending...": "Odosielanie...",
|
||||
"Standard": "Štandardný",
|
||||
"Start Time": "Začiatok",
|
||||
"Status": "Stav",
|
||||
"Status Badge": "Odznak stavu",
|
||||
"Status Embed": "Status na vloženie",
|
||||
"Status Embed": "Stav na vloženie",
|
||||
"Status history and latency trend": "História stavu a trend latencie",
|
||||
"Subscribe": "Odoberať",
|
||||
"Subscribe": "Prihlásiť sa na odber",
|
||||
"Subscribe to Updates": "Odoberať aktualizácie",
|
||||
"Theme": "Motív",
|
||||
"There are no incidents or maintenances scheduled for this month.": "Na tento mesiac nie sú naplánované incidenty ani údržba.",
|
||||
"There are no ongoing incidents or maintenance events.": "V tejto chvíli neprebiehajú žiadne incidenty ani údržba.",
|
||||
"There are no incidents or maintenances scheduled for this month.": "Na tento mesiac nie sú naplánované žiadne incidenty ani údržby",
|
||||
"There are no ongoing incidents or maintenance events.": "Momentálne neprebiehajú žiadne incidenty ani údržba",
|
||||
"Timeline": "Časová os",
|
||||
"Total Incidents": "Celkom incidentov",
|
||||
"Total Maintenances": "Celkom údržieb",
|
||||
@@ -151,6 +153,6 @@
|
||||
"Verification failed": "Overenie zlyhalo",
|
||||
"Verify": "Overiť",
|
||||
"Verifying": "Overovanie",
|
||||
"We sent a 6-digit code to": "Poslali sme 6-miestny kód na"
|
||||
"We sent a 6-digit code to": "Odoslali sme 6-miestny kód na"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-значний код на"
|
||||
}
|
||||
}
|
||||
+44
-42
@@ -1,32 +1,32 @@
|
||||
{
|
||||
"name": "Tiếng Việt",
|
||||
"mappings": {
|
||||
"%latency %metric latency": "%latency %metric độ trễ",
|
||||
"Affected Monitors (%count)": "Màn hình bị ảnh hưởng (%count)",
|
||||
"All Systems Operational": "Tất cả hệ thống đang hoạt động",
|
||||
"%latency %metric latency": "Độ trễ %metric: %latency",
|
||||
"Affected Monitors (%count)": "Mục giám sát bị ảnh hưởng (%count)",
|
||||
"All Systems Operational": "Hệ thống hoạt động bình thường",
|
||||
"Average Latency": "Độ trễ trung bình",
|
||||
"Avg Latency": "Độ trễ trung bình",
|
||||
"Back": "Mặt sau",
|
||||
"Avg Latency": "Độ trễ TB",
|
||||
"Back": "Quay lại",
|
||||
"Badges": "Huy hiệu",
|
||||
"CANCELLED": "ĐÃ HỦY",
|
||||
"COMPLETED": "HOÀN THÀNH",
|
||||
"Continue": "Tiếp tục",
|
||||
"Copied": "Đã sao chép",
|
||||
"Current": "Hiện tại",
|
||||
"Dark": "Tối tăm",
|
||||
"Dark": "Tối",
|
||||
"Day": "Ngày",
|
||||
"Day Uptime": "Thời gian hoạt động trong ngày",
|
||||
"Days": "Ngày",
|
||||
"Degraded": "Suy giảm",
|
||||
"DEGRADED": "SUY GIẢM",
|
||||
"Degraded Performance": "Hiệu suất suy giảm",
|
||||
"Didn't receive the code? Resend": "Không nhận được mã? ",
|
||||
"Degraded Performance": "Hiệu năng suy giảm",
|
||||
"Didn't receive the code? Resend": "Không nhận được mã? Gửi lại",
|
||||
"Down": "Ngừng hoạt động",
|
||||
"DOWN": "NGỪNG HOẠT ĐỘNG",
|
||||
"Duration": "Khoảng thời gian",
|
||||
"Duration": "Thời lượng",
|
||||
"Email address": "Địa chỉ email",
|
||||
"Embed Monitor": "Màn hình nhúng",
|
||||
"Embed this monitor in your website or app": "Nhúng màn hình này vào trang web hoặc ứng dụng của bạn",
|
||||
"Embed Monitor": "Nhúng mục giám sát",
|
||||
"Embed this monitor in your website or app": "Nhúng mục giám sát này vào trang web hoặc ứng dụng của bạn",
|
||||
"End Time": "Thời gian kết thúc",
|
||||
"Enter the verification code sent to your email.": "Nhập mã xác minh được gửi tới email của bạn.",
|
||||
"Events": "Sự kiện",
|
||||
@@ -35,18 +35,18 @@
|
||||
"Failed to load status data for this day": "Không thể tải dữ liệu trạng thái cho ngày này",
|
||||
"Failed to send verification code": "Không gửi được mã xác minh",
|
||||
"Failed to update preference": "Không thể cập nhật tùy chọn",
|
||||
"Get badges for this monitor": "Nhận huy hiệu cho màn hình này",
|
||||
"Get notified about incidents and scheduled maintenance.": "Nhận thông báo về sự cố và bảo trì theo lịch trình.",
|
||||
"Get notified about incidents updates": "Nhận thông báo về cập nhật sự cố",
|
||||
"Get notified about scheduled maintenance": "Nhận thông báo về bảo trì theo lịch trình",
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"Get badges for this monitor": "Lấy huy hiệu cho mục giám sát này",
|
||||
"Get notified about incidents and scheduled maintenance.": "Nhận thông báo về sự cố và bảo trì đã lên lịch.",
|
||||
"Get notified about incidents updates": "Nhận thông báo về các cập nhật sự cố",
|
||||
"Get notified about scheduled maintenance": "Nhận thông báo khi có lịch bảo trì",
|
||||
"IDENTIFIED": "ĐÃ XÁC ĐỊNH",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Sự va chạm",
|
||||
"Impact": "Mức ảnh hưởng",
|
||||
"incident": "Sự cố",
|
||||
"Incident Updates": "Cập nhật sự cố",
|
||||
"Incidents": "Sự cố",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Included Monitors (%count)": "Mục giám sát được bao gồm (%count)",
|
||||
"INVESTIGATING": "ĐANG ĐIỀU TRA",
|
||||
"Last Updated": "Cập nhật lần cuối",
|
||||
"Latency": "Độ trễ",
|
||||
"Latency Embed": "Nhúng độ trễ",
|
||||
@@ -54,14 +54,14 @@
|
||||
"Latency Trend": "Xu hướng độ trễ",
|
||||
"Latest Latency": "Độ trễ mới nhất",
|
||||
"Latest Status": "Trạng thái mới nhất",
|
||||
"Light": "Ánh sáng",
|
||||
"Light": "Sáng",
|
||||
"Live Status": "Trạng thái trực tiếp",
|
||||
"Loading your preferences...": "Đang tải tùy chọn của bạn...",
|
||||
"maintenance": "Bảo trì",
|
||||
"MAINTENANCE": "BẢO TRÌ",
|
||||
"Maintenance Updates": "Cập nhật bảo trì",
|
||||
"Maintenances": "Bảo trì",
|
||||
"Major System Outage": "Sự cố hệ thống nghiêm trọng",
|
||||
"Major System Outage": "Gián đoạn hệ thống nghiêm trọng",
|
||||
"Manage Site": "Quản lý trang",
|
||||
"Manage your notification preferences.": "Quản lý tùy chọn thông báo của bạn.",
|
||||
"Max Latency": "Độ trễ tối đa",
|
||||
@@ -69,37 +69,39 @@
|
||||
"Min Latency": "Độ trễ tối thiểu",
|
||||
"Minimum Latency": "Độ trễ tối thiểu",
|
||||
"Minute-by-minute status data for this day": "Dữ liệu trạng thái từng phút cho ngày này",
|
||||
"MONITORING": "MONITORING",
|
||||
"Network error. Please try again.": "Lỗi mạng. ",
|
||||
"MONITORING": "ĐANG THEO DÕI",
|
||||
"Network error. Please try again.": "Lỗi mạng. Vui lòng thử lại.",
|
||||
"No Events in %currentMonth": "Không có sự kiện nào trong %currentMonth",
|
||||
"No events to show": "Không có sự kiện nào để hiển thị",
|
||||
"No incidents for this day": "Không có sự cố nào trong ngày này",
|
||||
"No latency data available for this day": "Không có dữ liệu về độ trễ cho ngày này",
|
||||
"No maintenances for this day": "Không bảo trì trong ngày này",
|
||||
"No monitors affected": "Không có màn hình nào bị ảnh hưởng",
|
||||
"No monitors available.": "Không có trình giám sát nào khả dụng.",
|
||||
"No ongoing maintenances": "Không có bảo trì liên tục",
|
||||
"No past maintenances": "Không có bảo trì trước đây",
|
||||
"No maintenances for this day": "Không có đợt bảo trì nào trong ngày này",
|
||||
"No monitors affected": "Không có mục giám sát nào bị ảnh hưởng",
|
||||
"No monitors available.": "Không có mục giám sát nào.",
|
||||
"No ongoing maintenances": "Không có đợt bảo trì nào đang diễn ra",
|
||||
"No past maintenances": "Không có đợt bảo trì nào đã qua",
|
||||
"No Status Available": "Không có trạng thái khả dụng",
|
||||
"No upcoming maintenances": "Không có bảo trì sắp tới",
|
||||
"No upcoming maintenances": "Không có đợt bảo trì nào sắp tới",
|
||||
"No Updates": "Không có cập nhật",
|
||||
"No updates yet": "Chưa có cập nhật nào",
|
||||
"Notifications": "Thông báo",
|
||||
"One-time": "Một lần",
|
||||
"Ongoing": "Đang thực hiện",
|
||||
"Operational": "Hoạt động",
|
||||
"Partial Degraded Performance": "Hiệu suất suy giảm một phần",
|
||||
"Partial System Outage": "Sự cố hệ thống một phần",
|
||||
"Past": "Quá khứ",
|
||||
"Per-Minute Status": "Trạng thái mỗi phút",
|
||||
"Pinging": "Ping",
|
||||
"Open events page": "Mở trang sự kiện",
|
||||
"Operational": "Hoạt động bình thường",
|
||||
"Partial Degraded Performance": "Hiệu năng suy giảm một phần",
|
||||
"Partial System Outage": "Gián đoạn hệ thống một phần",
|
||||
"Past": "Đã qua",
|
||||
"Per-Minute Status": "Trạng thái từng phút",
|
||||
"Pinging": "Đang ping",
|
||||
"Please enter a valid email address": "Vui lòng nhập địa chỉ email hợp lệ",
|
||||
"Please enter the 6-digit verification code": "Vui lòng nhập mã xác minh gồm 6 chữ số",
|
||||
"Read less": "Đọc ít hơn",
|
||||
"Read less": "Thu gọn",
|
||||
"Read more": "Đọc thêm",
|
||||
"READY": "SẴN SÀNG",
|
||||
"Recurring": "Định kỳ",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RESOLVED": "ĐÃ KHẮC PHỤC",
|
||||
"RSS feed": "Nguồn RSS",
|
||||
"SCHEDULED": "ĐÃ LÊN LỊCH",
|
||||
"Scheduled Events (%count)": "Sự kiện đã lên lịch (%count)",
|
||||
"Script": "Kịch bản",
|
||||
@@ -111,16 +113,16 @@
|
||||
"Start Time": "Thời gian bắt đầu",
|
||||
"Status": "Trạng thái",
|
||||
"Status Badge": "Huy hiệu trạng thái",
|
||||
"Status Embed": "Trạng thái nhúng",
|
||||
"Status Embed": "Nhúng trạng thái",
|
||||
"Status history and latency trend": "Lịch sử trạng thái và xu hướng độ trễ",
|
||||
"Subscribe": "Đặt mua",
|
||||
"Subscribe to Updates": "Đăng ký cập nhật",
|
||||
"There are no incidents or maintenances scheduled for this month.": "Không có sự cố hoặc bảo trì nào được lên kế hoạch trong tháng này.",
|
||||
"Subscribe": "Đăng ký",
|
||||
"Subscribe to Updates": "Đăng ký nhận cập nhật",
|
||||
"There are no incidents or maintenances scheduled for this month.": "Không có sự cố hoặc đợt bảo trì nào được lên lịch trong tháng này.",
|
||||
"There are no ongoing incidents or maintenance events.": "Không có sự cố hoặc sự kiện bảo trì nào đang diễn ra.",
|
||||
"Total Incidents": "Tổng số sự cố",
|
||||
"Total Maintenances": "Tổng số lần bảo trì",
|
||||
"Under Maintenance": "Đang bảo trì",
|
||||
"Unknown impact": "Tác động không xác định",
|
||||
"Unknown impact": "Không xác định được mức ảnh hưởng",
|
||||
"UP": "HOẠT ĐỘNG",
|
||||
"Upcoming": "Sắp tới",
|
||||
"Update Incident": "Cập nhật sự cố",
|
||||
|
||||
+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[];
|
||||
}
|
||||
@@ -159,6 +152,7 @@ export interface PageDashboardData {
|
||||
pageStatus: { statusSummary: string; statusClass: string };
|
||||
ongoingIncidents: IncidentForMonitorListWithComments[];
|
||||
ongoingMaintenances: MaintenanceEventsMonitorList[];
|
||||
upcomingMaintenances: MaintenanceEventsMonitorList[];
|
||||
monitorTags: string[];
|
||||
monitorGroupMembersByTag: Record<string, string[]>;
|
||||
pageDetails: PageRecordTyped;
|
||||
@@ -366,6 +360,7 @@ export const GetPageDashboardData = async (
|
||||
pageStatus: BuildPageStatus([], nowTs),
|
||||
ongoingIncidents: [],
|
||||
ongoingMaintenances: [],
|
||||
upcomingMaintenances: [],
|
||||
monitorTags,
|
||||
monitorGroupMembersByTag: {},
|
||||
pageDetails: pageDetailsTyped,
|
||||
@@ -375,16 +370,24 @@ 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] = await Promise.all([
|
||||
const [latestData, parsedMonitors, ongoingIncidents, ongoingMaintenances, upcomingMaintenances] = await Promise.all([
|
||||
GetLatestMonitoringDataAllActive(monitorTags),
|
||||
GetMonitorsParsed({ tags: monitorTags, status: "ACTIVE", is_hidden: "NO" }),
|
||||
eventSettings.incidents.enabled && eventSettings.incidents.ongoing.show
|
||||
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[]),
|
||||
showInlineEvents && eventSettings.maintenances.enabled && eventSettings.maintenances.upcoming.show
|
||||
? GetUpcomingMaintenanceEventsForMonitorList(
|
||||
monitorTags,
|
||||
eventSettings.maintenances.upcoming.maxCount,
|
||||
eventSettings.maintenances.upcoming.daysInFuture,
|
||||
)
|
||||
: Promise.resolve([] as MaintenanceEventsMonitorList[]),
|
||||
]);
|
||||
|
||||
const pageStatus = BuildPageStatus(latestData, nowTs);
|
||||
@@ -403,6 +406,7 @@ export const GetPageDashboardData = async (
|
||||
pageStatus,
|
||||
ongoingIncidents,
|
||||
ongoingMaintenances,
|
||||
upcomingMaintenances,
|
||||
monitorTags,
|
||||
monitorGroupMembersByTag,
|
||||
pageDetails: pageDetailsTyped,
|
||||
|
||||
@@ -17,6 +17,11 @@ import type {
|
||||
} from "../types/db.js";
|
||||
import GC from "../../global-constants.js";
|
||||
import { getUnixTime, differenceInSeconds } from "date-fns";
|
||||
import { siteDataToVariables } from "../notification/notification_utils.js";
|
||||
import { GetAllSiteData } from "./siteDataController.js";
|
||||
import subscriberQueue from "../queues/subscriberQueue.js";
|
||||
import mdToHTML from "../../marked.js";
|
||||
import type { SubscriptionVariableMap } from "../notification/types.js";
|
||||
|
||||
interface IncidentsDashboardInput {
|
||||
page: number;
|
||||
@@ -322,11 +327,6 @@ export const CreateIncident = async (data: IncidentInput): Promise<{ incident_id
|
||||
|
||||
let newIncident = await db.createIncident(incident);
|
||||
|
||||
//we need to
|
||||
// queueController.PushDataToQueue(newIncident.id, "createIncident", {
|
||||
// message: `${incident.incident_type} Created`,
|
||||
// ...incident,
|
||||
// });
|
||||
return {
|
||||
incident_id: newIncident.id,
|
||||
};
|
||||
@@ -354,25 +354,6 @@ export const UpdateIncident = async (incident_id: number, data: IncidentUpdateIn
|
||||
is_global: data.is_global !== undefined ? data.is_global : incidentExists.is_global,
|
||||
};
|
||||
|
||||
//check if updateObject same as incidentExists
|
||||
// if (
|
||||
// JSON.stringify(updateObject) ===
|
||||
// JSON.stringify({
|
||||
// id: incidentExists.id,
|
||||
// title: incidentExists.title,
|
||||
// start_date_time: incidentExists.start_date_time,
|
||||
// status: incidentExists.status,
|
||||
// state: incidentExists.state,
|
||||
// end_date_time: incidentExists.end_date_time,
|
||||
// })
|
||||
// ) {
|
||||
// queueController.PushDataToQueue(incident_id, "updateIncident", {
|
||||
// message: `${incidentExists.incident_type} has been updated to ${updateObject.state}`,
|
||||
// incident_type: incidentExists.incident_type,
|
||||
// title: incidentExists.title,
|
||||
// });
|
||||
// }
|
||||
|
||||
return await db.updateIncident(updateObject as IncidentRecord);
|
||||
};
|
||||
|
||||
@@ -402,11 +383,6 @@ export const AddIncidentMonitor = async (
|
||||
throw new Error(`Incident with id ${incident_id} does not exist`);
|
||||
}
|
||||
|
||||
// queueController.PushDataToQueue(incident_id, "insertIncidentMonitor", {
|
||||
// title: incidentExists.title,
|
||||
// message: `Monitor ${monitor_tag} added to ${incidentExists.incident_type}. Impact is ${monitor_impact}`,
|
||||
// incident_type: incidentExists.incident_type,
|
||||
// });
|
||||
return await db.insertIncidentMonitorWithMerge({
|
||||
incident_id,
|
||||
monitor_tag,
|
||||
@@ -429,11 +405,6 @@ export const UpdateCommentByID = async (
|
||||
if (!commentExists) {
|
||||
throw new Error(`Comment with id ${comment_id} does not exist`);
|
||||
}
|
||||
// queueController.PushDataToQueue(incident_id, "updateIncidentComment", {
|
||||
// title: incidentExists.title,
|
||||
// message: `${comment}`,
|
||||
// incident_type: incidentExists.incident_type,
|
||||
// });
|
||||
let c = await db.updateIncidentCommentByID(comment_id, comment, state, commented_at);
|
||||
if (c) {
|
||||
let incidentUpdate: IncidentUpdateInput = {
|
||||
@@ -450,6 +421,38 @@ export const UpdateCommentByID = async (
|
||||
}
|
||||
return c;
|
||||
};
|
||||
// Subscriber notifications are driven solely by incident comments: the comment
|
||||
// timeline is the incident's public communication channel, so posting a comment
|
||||
// is the one event that emails "incidents" subscribers — regardless of whether
|
||||
// the incident came from an alert, the dashboard, or the API. This is the single
|
||||
// choke point; alertingQueue must not push its own incident notifications, or
|
||||
// alert-driven incidents would notify twice.
|
||||
const notifySubscribersOfComment = async (
|
||||
incident: Pick<IncidentRecord, "id" | "title">,
|
||||
comment: IncidentCommentRecord,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const siteData = await GetAllSiteData();
|
||||
const siteUrl = siteDataToVariables(siteData).site_url;
|
||||
const variables: SubscriptionVariableMap = {
|
||||
title: incident.title,
|
||||
cta_url: `${siteUrl}incidents/${incident.id}`,
|
||||
cta_text: "View Incident",
|
||||
update_text: mdToHTML(comment.comment),
|
||||
update_subject: `[#${incident.id}:${comment.state}] ${incident.title}`,
|
||||
update_id: String(comment.id),
|
||||
event_type: "incidents",
|
||||
};
|
||||
// Stable dedup id per comment so a retried/double push notifies once — without
|
||||
// it subscriberQueue falls back to a Date.now()-suffixed id that never dedupes.
|
||||
await subscriberQueue.push(variables, {
|
||||
deduplication: { id: `subscriber-incidents-${comment.id}` },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error sending subscriber notification for incident ${incident.id}:`, err);
|
||||
}
|
||||
};
|
||||
|
||||
export const AddIncidentComment = async (
|
||||
incident_id: number,
|
||||
comment: string,
|
||||
@@ -480,6 +483,7 @@ export const AddIncidentComment = async (
|
||||
}
|
||||
}
|
||||
await UpdateIncident(incident_id, incidentUpdate);
|
||||
await notifySubscribersOfComment(incidentExists, c);
|
||||
}
|
||||
|
||||
return c;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { maintenanceToVariables, siteDataToVariables } from "../notification/not
|
||||
import { GetAllSiteData } from "./controller.js";
|
||||
import subscriberQueue from "../queues/subscriberQueue.js";
|
||||
import GC from "../../global-constants";
|
||||
import seedSiteData from "../db/seedSiteData.js";
|
||||
|
||||
// ============ Input Interfaces ============
|
||||
|
||||
@@ -78,6 +79,7 @@ export interface MaintenanceWithEvents extends MaintenanceWithMonitors {
|
||||
export function determineEventStatus(
|
||||
eventStartTimestamp: number,
|
||||
eventEndTimestamp: number,
|
||||
reminderBufferSeconds: number = 3600,
|
||||
): "SCHEDULED" | "READY" | "ONGOING" | "COMPLETED" | "CANCELLED" {
|
||||
const nowTimestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
@@ -87,38 +89,90 @@ export function determineEventStatus(
|
||||
if (nowTimestamp >= eventStartTimestamp) {
|
||||
return "ONGOING";
|
||||
}
|
||||
// 60 minutes = 3600 seconds
|
||||
if (eventStartTimestamp - nowTimestamp <= 3600) {
|
||||
if (eventStartTimestamp - nowTimestamp <= reminderBufferSeconds) {
|
||||
return "READY";
|
||||
}
|
||||
return "SCHEDULED";
|
||||
}
|
||||
|
||||
// ============ Helper to create a maintenance event with notification ============
|
||||
|
||||
export const CreateMaintenanceEventWithNotification = async (
|
||||
maintenance_id: number,
|
||||
start_date_time: number,
|
||||
end_date_time: number,
|
||||
title: string,
|
||||
description: string | null,
|
||||
): Promise<MaintenanceEventRecord> => {
|
||||
const siteData = await GetAllSiteData();
|
||||
const notificationSettings =
|
||||
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
|
||||
const reminderBufferSeconds = notificationSettings.reminder_buffer_hours * 3600;
|
||||
|
||||
const event = await db.createMaintenanceEvent({
|
||||
maintenance_id,
|
||||
start_date_time,
|
||||
end_date_time,
|
||||
status: determineEventStatus(start_date_time, end_date_time, reminderBufferSeconds),
|
||||
});
|
||||
|
||||
try {
|
||||
if (notificationSettings.event_types.created) {
|
||||
const siteVars = siteDataToVariables(siteData);
|
||||
const siteUrl = siteVars.site_url;
|
||||
const monitors = await db.getMonitorsByMaintenanceId(maintenance_id);
|
||||
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
|
||||
const eventDetailed: MaintenanceEventRecordDetailed = {
|
||||
id: event.id,
|
||||
maintenance_id,
|
||||
start_date_time,
|
||||
end_date_time,
|
||||
status: event.status as MaintenanceEventRecordDetailed["status"],
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
title,
|
||||
description,
|
||||
};
|
||||
const update = maintenanceToVariables(
|
||||
eventDetailed,
|
||||
monitorNames,
|
||||
"**has been created**",
|
||||
"created",
|
||||
"Maintenance Created",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error sending created notification for maintenance event ${event.id}:`, err);
|
||||
}
|
||||
|
||||
return event;
|
||||
};
|
||||
|
||||
// ============ Helper to generate upcoming events from RRULE ============
|
||||
|
||||
/**
|
||||
* Generate maintenance events for the next N days based on the RRULE
|
||||
* Generate maintenance events based on the RRULE
|
||||
* @param maintenance_id - The maintenance record ID
|
||||
* @param start_date_time - Unix timestamp for the DTSTART
|
||||
* @param rrule - The RRULE string (e.g., FREQ=WEEKLY;BYDAY=SU)
|
||||
* @param duration_seconds - Duration of each maintenance window
|
||||
* @param daysAhead - Number of days to look ahead (default 7)
|
||||
* @param count - Maximum number of events to create (default 1)
|
||||
*/
|
||||
export const GenerateMaintenanceEvents = async (
|
||||
maintenance_id: number,
|
||||
start_date_time: number,
|
||||
rrule: string,
|
||||
duration_seconds: number,
|
||||
daysAhead: number = 7,
|
||||
count: number = 1,
|
||||
): Promise<MaintenanceEventRecord[]> => {
|
||||
const createdEvents: MaintenanceEventRecord[] = [];
|
||||
|
||||
// Convert start timestamp to Date (UTC)
|
||||
const dtstart = new Date(start_date_time * 1000);
|
||||
|
||||
// Define the window to generate events
|
||||
const now = new Date();
|
||||
const windowEnd = addDays(now, daysAhead);
|
||||
|
||||
try {
|
||||
// Build the full RRULE string with DTSTART
|
||||
@@ -127,22 +181,30 @@ export const GenerateMaintenanceEvents = async (
|
||||
// Parse the RRULE
|
||||
const rule = rrulestr(fullRrule);
|
||||
|
||||
// Get occurrences between now and window end
|
||||
// For one-time (COUNT=1), we use dtstart as the reference
|
||||
let occurrences: Date[];
|
||||
// Get occurrences based on count
|
||||
let occurrences: Date[] = [];
|
||||
|
||||
if (rrule.includes("COUNT=1")) {
|
||||
// One-time maintenance: only create event if start_date_time is in the future or within window
|
||||
if (dtstart >= now || (dtstart <= windowEnd && dtstart >= addDays(now, -1))) {
|
||||
// One-time maintenance: only create event if start_date_time is recent or in the future
|
||||
if (dtstart >= now || dtstart >= addDays(now, -1)) {
|
||||
occurrences = [dtstart];
|
||||
} else {
|
||||
occurrences = [];
|
||||
}
|
||||
} else {
|
||||
// Recurring: get all occurrences in the window
|
||||
occurrences = rule.between(now, windowEnd, true);
|
||||
// Recurring: get the next `count` occurrences from now
|
||||
let searchFrom = now;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const next = rule.after(searchFrom, i === 0);
|
||||
if (!next) break;
|
||||
occurrences.push(next);
|
||||
searchFrom = new Date(next.getTime() + 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch maintenance info for notifications
|
||||
const maintenance = await db.getMaintenanceById(maintenance_id);
|
||||
const maintenanceTitle = maintenance?.title || "";
|
||||
const maintenanceDescription = maintenance?.description || null;
|
||||
|
||||
// Create events for each occurrence
|
||||
for (const occurrence of occurrences) {
|
||||
const eventStart = Math.floor(occurrence.getTime() / 1000);
|
||||
@@ -153,12 +215,13 @@ export const GenerateMaintenanceEvents = async (
|
||||
const alreadyExists = existing.some((e) => e.start_date_time === eventStart);
|
||||
|
||||
if (!alreadyExists) {
|
||||
const event = await db.createMaintenanceEvent({
|
||||
const event = await CreateMaintenanceEventWithNotification(
|
||||
maintenance_id,
|
||||
start_date_time: eventStart,
|
||||
end_date_time: eventEnd,
|
||||
status: determineEventStatus(eventStart, eventEnd),
|
||||
});
|
||||
eventStart,
|
||||
eventEnd,
|
||||
maintenanceTitle,
|
||||
maintenanceDescription,
|
||||
);
|
||||
createdEvents.push(event);
|
||||
}
|
||||
}
|
||||
@@ -202,8 +265,8 @@ export const CreateMaintenance = async (data: CreateMaintenanceInput): Promise<{
|
||||
await db.addMonitorsToMaintenanceWithStatus(maintenance.id, data.monitors);
|
||||
}
|
||||
|
||||
// Generate initial events for the next 7 days
|
||||
await GenerateMaintenanceEvents(maintenance.id, data.start_date_time, data.rrule, data.duration_seconds, 7);
|
||||
// Generate initial events
|
||||
await GenerateMaintenanceEvents(maintenance.id, data.start_date_time, data.rrule, data.duration_seconds, 1);
|
||||
|
||||
return {
|
||||
maintenance_id: maintenance.id,
|
||||
@@ -336,7 +399,7 @@ export const UpdateMaintenance = async (id: number, data: UpdateMaintenanceInput
|
||||
}
|
||||
}
|
||||
// Regenerate the event
|
||||
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 7);
|
||||
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 1);
|
||||
} else {
|
||||
// For recurring maintenances: delete future SCHEDULED events and regenerate
|
||||
for (const event of events) {
|
||||
@@ -345,8 +408,8 @@ export const UpdateMaintenance = async (id: number, data: UpdateMaintenanceInput
|
||||
await db.deleteMaintenanceEvent(event.id);
|
||||
}
|
||||
}
|
||||
// Regenerate events for the next 7 days
|
||||
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 7);
|
||||
// Regenerate events
|
||||
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,11 +440,16 @@ export const CreateMaintenanceEvent = async (data: CreateMaintenanceEventInput):
|
||||
throw new Error("End date/time must be after start date/time");
|
||||
}
|
||||
|
||||
const siteData = await GetAllSiteData();
|
||||
const notificationSettings =
|
||||
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
|
||||
const reminderBufferSeconds = notificationSettings.reminder_buffer_hours * 3600;
|
||||
|
||||
const event = await db.createMaintenanceEvent({
|
||||
maintenance_id: data.maintenance_id,
|
||||
start_date_time: data.start_date_time,
|
||||
end_date_time: data.end_date_time,
|
||||
status: determineEventStatus(data.start_date_time, data.end_date_time),
|
||||
status: determineEventStatus(data.start_date_time, data.end_date_time, reminderBufferSeconds),
|
||||
});
|
||||
|
||||
return event;
|
||||
@@ -418,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> => {
|
||||
@@ -534,20 +659,23 @@ export const formatDurationSeconds = (seconds: number): string => {
|
||||
|
||||
/**
|
||||
* Update maintenance event statuses based on current time:
|
||||
* 1. SCHEDULED events starting within 60 minutes → READY
|
||||
* 1. SCHEDULED events starting within the reminder buffer → READY
|
||||
* 2. READY events where current time is within start/end → ONGOING
|
||||
* 3. ONGOING events where end_date_time has passed → COMPLETED
|
||||
*/
|
||||
export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
|
||||
const currentTimestamp = GetMinuteStartNowTimestampUTC();
|
||||
const sixtyMinutesInSeconds = 60 * 60;
|
||||
const siteData = await GetAllSiteData();
|
||||
const siteVars = siteDataToVariables(siteData);
|
||||
const siteUrl = siteVars.site_url;
|
||||
//get global maintenance notification settings
|
||||
const notificationSettings =
|
||||
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
|
||||
|
||||
const reminderBufferSeconds = notificationSettings.reminder_buffer_hours * 3600;
|
||||
try {
|
||||
// 1. Mark SCHEDULED events starting within 60 minutes as READY
|
||||
const scheduledEvents = await db.getScheduledEventsStartingSoon(currentTimestamp, sixtyMinutesInSeconds);
|
||||
// 1. Mark SCHEDULED events starting within the reminder buffer as READY
|
||||
const scheduledEvents = await db.getScheduledEventsStartingSoon(currentTimestamp, reminderBufferSeconds);
|
||||
for (const event of scheduledEvents) {
|
||||
await db.updateMaintenanceEventStatus(event.id, GC.READY);
|
||||
console.log(`Maintenance event ${event.id} marked as READY (starts at ${event.start_date_time})`);
|
||||
@@ -557,15 +685,17 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
|
||||
new Date(event.start_date_time * 1000),
|
||||
new Date(currentTimestamp * 1000),
|
||||
);
|
||||
const update = maintenanceToVariables(
|
||||
event,
|
||||
monitorNames,
|
||||
`**is starting in ${timeUntilStart}**`,
|
||||
"starting_soon",
|
||||
"Maintenance Starting Soon",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
if (notificationSettings.event_types.reminder) {
|
||||
const update = maintenanceToVariables(
|
||||
event,
|
||||
monitorNames,
|
||||
`**is starting in ${timeUntilStart}**`,
|
||||
"starting_soon",
|
||||
"Maintenance Starting Soon",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Catch-up: SCHEDULED events that missed the READY window and already started → ONGOING
|
||||
@@ -575,15 +705,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
|
||||
console.log(`Maintenance event ${event.id} marked as ONGOING (catch-up from SCHEDULED)`);
|
||||
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
|
||||
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
|
||||
const update = maintenanceToVariables(
|
||||
event,
|
||||
monitorNames,
|
||||
"**is now in progress**",
|
||||
"ongoing",
|
||||
"Maintenance In Progress",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
|
||||
if (notificationSettings.event_types.started) {
|
||||
const update = maintenanceToVariables(
|
||||
event,
|
||||
monitorNames,
|
||||
"**is now in progress**",
|
||||
"ongoing",
|
||||
"Maintenance In Progress",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Mark READY events that are now in progress as ONGOING
|
||||
@@ -593,15 +726,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
|
||||
console.log(`Maintenance event ${event.id} marked as ONGOING`);
|
||||
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
|
||||
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
|
||||
const update = maintenanceToVariables(
|
||||
event,
|
||||
monitorNames,
|
||||
"**is now in progress**",
|
||||
"ongoing",
|
||||
"Maintenance In Progress",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
|
||||
if (notificationSettings.event_types.started) {
|
||||
const update = maintenanceToVariables(
|
||||
event,
|
||||
monitorNames,
|
||||
"**is now in progress**",
|
||||
"ongoing",
|
||||
"Maintenance In Progress",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Mark ONGOING events that have ended as COMPLETED
|
||||
@@ -611,15 +747,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
|
||||
console.log(`Maintenance event ${event.id} marked as COMPLETED`);
|
||||
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
|
||||
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
|
||||
const update = maintenanceToVariables(
|
||||
event,
|
||||
monitorNames,
|
||||
"**has been completed**",
|
||||
"completed",
|
||||
"Maintenance Completed",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
|
||||
if (notificationSettings.event_types.ended) {
|
||||
const update = maintenanceToVariables(
|
||||
event,
|
||||
monitorNames,
|
||||
"**has been completed**",
|
||||
"completed",
|
||||
"Maintenance Completed",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating maintenance event statuses:", error);
|
||||
|
||||
@@ -127,19 +127,20 @@ export async function CreateMonitorAlertConfig(
|
||||
// Validate input
|
||||
validateMonitorAlertConfigInput(data);
|
||||
|
||||
if (!data.monitor_tag) {
|
||||
throw new Error("monitor_tag is required");
|
||||
if (!data.monitor_tags || data.monitor_tags.length === 0) {
|
||||
throw new Error("At least one monitor is required");
|
||||
}
|
||||
|
||||
// Check if monitor exists
|
||||
const monitor = await db.getMonitorByTag(data.monitor_tag);
|
||||
if (!monitor) {
|
||||
throw new Error(`Monitor with tag '${data.monitor_tag}' not found`);
|
||||
// Check if all monitors exist
|
||||
for (const tag of data.monitor_tags) {
|
||||
const monitor = await db.getMonitorByTag(tag);
|
||||
if (!monitor) {
|
||||
throw new Error(`Monitor with tag '${tag}' not found`);
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare insert data
|
||||
const insertData: MonitorAlertConfigInsert = {
|
||||
monitor_tag: data.monitor_tag,
|
||||
alert_for: data.alert_for,
|
||||
alert_value: data.alert_value,
|
||||
failure_threshold: data.failure_threshold,
|
||||
@@ -153,6 +154,9 @@ export async function CreateMonitorAlertConfig(
|
||||
// Insert alert config
|
||||
const id = await db.insertMonitorAlertConfig(insertData);
|
||||
|
||||
// Add monitors to junction table
|
||||
await db.addMonitorsToAlertConfig(id, data.monitor_tags);
|
||||
|
||||
// Add triggers if provided
|
||||
if (data.trigger_ids && data.trigger_ids.length > 0) {
|
||||
await db.addTriggersToMonitorAlertConfig(id, data.trigger_ids);
|
||||
@@ -194,6 +198,19 @@ export async function UpdateMonitorAlertConfig(
|
||||
validateAlertValue(alertFor, data.alert_value);
|
||||
}
|
||||
|
||||
// Validate monitor_tags if provided
|
||||
if (data.monitor_tags !== undefined) {
|
||||
if (data.monitor_tags.length === 0) {
|
||||
throw new Error("At least one monitor is required");
|
||||
}
|
||||
for (const tag of data.monitor_tags) {
|
||||
const monitor = await db.getMonitorByTag(tag);
|
||||
if (!monitor) {
|
||||
throw new Error(`Monitor with tag '${tag}' not found`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: MonitorAlertConfigUpdate = {};
|
||||
if (data.alert_for !== undefined) updateData.alert_for = data.alert_for;
|
||||
@@ -210,6 +227,11 @@ export async function UpdateMonitorAlertConfig(
|
||||
await db.updateMonitorAlertConfig(data.id, updateData);
|
||||
}
|
||||
|
||||
// Update monitors if provided
|
||||
if (data.monitor_tags !== undefined) {
|
||||
await db.replaceAlertConfigMonitors(data.id, data.monitor_tags);
|
||||
}
|
||||
|
||||
// Update triggers if provided
|
||||
if (data.trigger_ids !== undefined) {
|
||||
await db.replaceMonitorAlertConfigTriggers(data.id, data.trigger_ids);
|
||||
@@ -306,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;
|
||||
}
|
||||
@@ -420,6 +443,7 @@ function validateAlertStatus(value: string): asserts value is MonitorAlertStatus
|
||||
*/
|
||||
export async function CreateMonitorAlertV2(
|
||||
configId: number,
|
||||
monitorTag?: string | null,
|
||||
incidentId?: number | null,
|
||||
): Promise<MonitorAlertV2Record> {
|
||||
// Check if config exists
|
||||
@@ -438,6 +462,7 @@ export async function CreateMonitorAlertV2(
|
||||
|
||||
const insertData: MonitorAlertV2Insert = {
|
||||
config_id: configId,
|
||||
monitor_tag: monitorTag || null,
|
||||
incident_id: incidentId || null,
|
||||
alert_status: "TRIGGERED",
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
SiteDateTimeFormat,
|
||||
SiteSubscriptionsSettings,
|
||||
SitemapXMLConfig,
|
||||
GlobalMaintenanceNotificationSettings,
|
||||
} from "../../types/site.js";
|
||||
|
||||
export interface SiteDataTransformed {
|
||||
@@ -66,6 +67,7 @@ export interface SiteDataTransformed {
|
||||
metaSiteTitle?: string;
|
||||
metaSiteDescription?: string;
|
||||
sitemap?: SitemapXMLConfig;
|
||||
globalMaintenanceNotificationSettings?: GlobalMaintenanceNotificationSettings;
|
||||
}
|
||||
|
||||
export function InsertKeyValue(key: string, value: string): Promise<number[]> {
|
||||
@@ -106,6 +108,22 @@ export const GetLocaleFromCookie = (site: SiteDataTransformed, cookies: Cookies)
|
||||
return selectedLang;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the site URL used for building absolute public URLs, without a trailing slash.
|
||||
* Prefers the configured siteURL and falls back to the ORIGIN env var; only absolute
|
||||
* http(s) values are returned. Returns an empty string when neither is usable, in which
|
||||
* case callers degrade to a relative path.
|
||||
*/
|
||||
export const GetSiteURL = async (): Promise<string> => {
|
||||
const siteURL = await GetSiteDataByKey("siteURL");
|
||||
for (const candidate of [siteURL, process.env.ORIGIN]) {
|
||||
if (typeof candidate === "string" && /^https?:\/\//i.test(candidate)) {
|
||||
return candidate.replace(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
export const GetSiteLogoURL = async (siteURL: string, logo: string, base: string): Promise<string> => {
|
||||
if (logo.startsWith("http")) {
|
||||
return logo;
|
||||
@@ -136,14 +154,17 @@ export const GetSiteDataByKey = async (key: string): Promise<unknown> => {
|
||||
return data.value;
|
||||
};
|
||||
|
||||
/** Checks the env vars required for setup, without touching the database. */
|
||||
export const HasRequiredEnv = (): boolean => {
|
||||
return (
|
||||
process.env.KENER_SECRET_KEY !== undefined &&
|
||||
process.env.ORIGIN !== undefined &&
|
||||
process.env.REDIS_URL !== undefined
|
||||
);
|
||||
};
|
||||
|
||||
export const IsSetupComplete = async (): Promise<boolean> => {
|
||||
if (process.env.KENER_SECRET_KEY === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (process.env.ORIGIN === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (process.env.REDIS_URL === undefined) {
|
||||
if (!HasRequiredEnv()) {
|
||||
return false;
|
||||
}
|
||||
let data = await db.getAllSiteData();
|
||||
|
||||
@@ -43,12 +43,12 @@ export const siteDataKeys: SiteDataKey[] = [
|
||||
},
|
||||
{
|
||||
key: "favicon",
|
||||
isValid: (value) => typeof value === "string" && value.trim().length > 0,
|
||||
isValid: (value) => typeof value === "string",
|
||||
data_type: "string",
|
||||
},
|
||||
{
|
||||
key: "logo",
|
||||
isValid: (value) => typeof value === "string" && value.trim().length > 0,
|
||||
isValid: (value) => typeof value === "string",
|
||||
data_type: "string",
|
||||
},
|
||||
{
|
||||
@@ -291,4 +291,9 @@ export const siteDataKeys: SiteDataKey[] = [
|
||||
isValid: IsValidJSONString,
|
||||
data_type: "object",
|
||||
},
|
||||
{
|
||||
key: "globalMaintenanceNotificationSettings",
|
||||
isValid: IsValidJSONString,
|
||||
data_type: "object",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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"];
|
||||
@@ -292,6 +317,12 @@ class DbImpl {
|
||||
isTriggerUsedInMonitorAlertConfig!: MonitorAlertConfigRepository["isTriggerUsedInMonitorAlertConfig"];
|
||||
getMonitorAlertConfigsByTriggerId!: MonitorAlertConfigRepository["getMonitorAlertConfigsByTriggerId"];
|
||||
|
||||
// ============ Monitor Alert Config Monitors ============
|
||||
addMonitorsToAlertConfig!: MonitorAlertConfigRepository["addMonitorsToAlertConfig"];
|
||||
removeAllMonitorsFromAlertConfig!: MonitorAlertConfigRepository["removeAllMonitorsFromAlertConfig"];
|
||||
replaceAlertConfigMonitors!: MonitorAlertConfigRepository["replaceAlertConfigMonitors"];
|
||||
getAlertConfigMonitorTags!: MonitorAlertConfigRepository["getAlertConfigMonitorTags"];
|
||||
|
||||
// ============ Monitor Alerts V2 ============
|
||||
insertMonitorAlertV2!: MonitorAlertConfigRepository["insertMonitorAlertV2"];
|
||||
updateMonitorAlertV2!: MonitorAlertConfigRepository["updateMonitorAlertV2"];
|
||||
@@ -347,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);
|
||||
@@ -384,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);
|
||||
@@ -401,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);
|
||||
@@ -408,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 {
|
||||
@@ -454,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);
|
||||
@@ -463,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 {
|
||||
@@ -692,6 +747,14 @@ class DbImpl {
|
||||
this.monitorAlertConfig,
|
||||
);
|
||||
|
||||
// Monitor Alert Config Monitors
|
||||
this.addMonitorsToAlertConfig = this.monitorAlertConfig.addMonitorsToAlertConfig.bind(this.monitorAlertConfig);
|
||||
this.removeAllMonitorsFromAlertConfig = this.monitorAlertConfig.removeAllMonitorsFromAlertConfig.bind(
|
||||
this.monitorAlertConfig,
|
||||
);
|
||||
this.replaceAlertConfigMonitors = this.monitorAlertConfig.replaceAlertConfigMonitors.bind(this.monitorAlertConfig);
|
||||
this.getAlertConfigMonitorTags = this.monitorAlertConfig.getAlertConfigMonitorTags.bind(this.monitorAlertConfig);
|
||||
|
||||
// Monitor Alerts V2
|
||||
this.insertMonitorAlertV2 = this.monitorAlertConfig.insertMonitorAlertV2.bind(this.monitorAlertConfig);
|
||||
this.updateMonitorAlertV2 = this.monitorAlertConfig.updateMonitorAlertV2.bind(this.monitorAlertConfig);
|
||||
@@ -784,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: [],
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
MonitorAlertConfigTriggerRecord,
|
||||
MonitorAlertConfigTriggerInsert,
|
||||
MonitorAlertConfigWithTriggers,
|
||||
MonitorAlertConfigMonitorRecord,
|
||||
MonitorAlertConfigMonitorInsert,
|
||||
TriggerRecord,
|
||||
MonitorAlertV2Record,
|
||||
MonitorAlertV2Insert,
|
||||
@@ -29,7 +31,7 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
async insertMonitorAlertConfig(data: MonitorAlertConfigInsert): Promise<number> {
|
||||
const dbType = GetDbType();
|
||||
const insertData = {
|
||||
monitor_tag: data.monitor_tag,
|
||||
monitor_tag: data.monitor_tag || null,
|
||||
alert_for: data.alert_for,
|
||||
alert_value: data.alert_value,
|
||||
failure_threshold: data.failure_threshold,
|
||||
@@ -88,29 +90,35 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
* Get monitor alert configs with optional filtering
|
||||
*/
|
||||
async getMonitorAlertConfigs(filter: MonitorAlertConfigFilter): Promise<MonitorAlertConfigRecord[]> {
|
||||
let query = this.knex("monitor_alerts_config").whereRaw("1=1");
|
||||
let query = this.knex("monitor_alerts_config as mac").select("mac.*").whereRaw("1=1");
|
||||
|
||||
if (filter.id !== undefined) {
|
||||
query = query.andWhere("id", filter.id);
|
||||
query = query.andWhere("mac.id", filter.id);
|
||||
}
|
||||
if (filter.monitor_tag !== undefined) {
|
||||
query = query.andWhere("monitor_tag", filter.monitor_tag);
|
||||
query = query
|
||||
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
|
||||
.andWhere("macm.monitor_tag", filter.monitor_tag);
|
||||
}
|
||||
if (filter.alert_for !== undefined) {
|
||||
query = query.andWhere("alert_for", filter.alert_for);
|
||||
query = query.andWhere("mac.alert_for", filter.alert_for);
|
||||
}
|
||||
if (filter.is_active !== undefined) {
|
||||
query = query.andWhere("is_active", filter.is_active);
|
||||
query = query.andWhere("mac.is_active", filter.is_active);
|
||||
}
|
||||
|
||||
return await query.orderBy("id", "desc");
|
||||
return await query.orderBy("mac.id", "desc");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all monitor alert configs for a specific monitor tag
|
||||
*/
|
||||
async getMonitorAlertConfigsByMonitorTag(monitorTag: string): Promise<MonitorAlertConfigRecord[]> {
|
||||
return await this.knex("monitor_alerts_config").where({ monitor_tag: monitorTag }).orderBy("id", "desc");
|
||||
return await this.knex("monitor_alerts_config as mac")
|
||||
.select("mac.*")
|
||||
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
|
||||
.where("macm.monitor_tag", monitorTag)
|
||||
.orderBy("mac.id", "desc");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,15 +132,26 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
* Get all active monitor alert configs for a specific monitor
|
||||
*/
|
||||
async getActiveMonitorAlertConfigsByMonitorTag(monitorTag: string): Promise<MonitorAlertConfigRecord[]> {
|
||||
return await this.knex("monitor_alerts_config")
|
||||
.where({ monitor_tag: monitorTag, is_active: "YES" })
|
||||
.orderBy("id", "desc");
|
||||
return await this.knex("monitor_alerts_config as mac")
|
||||
.select("mac.*")
|
||||
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
|
||||
.where("macm.monitor_tag", monitorTag)
|
||||
.andWhere("mac.is_active", "YES")
|
||||
.orderBy("mac.id", "desc");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
@@ -140,26 +159,55 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
* Delete all monitor alert configs for a specific monitor tag
|
||||
*/
|
||||
async deleteMonitorAlertConfigsByMonitorTag(monitorTag: string): Promise<number> {
|
||||
return await this.knex("monitor_alerts_config").where({ monitor_tag: monitorTag }).del();
|
||||
// Find all config IDs that have this monitor tag in the junction table
|
||||
const configIds = await this.knex("monitor_alerts_config_monitors")
|
||||
.select("monitor_alerts_id")
|
||||
.where({ monitor_tag: monitorTag });
|
||||
|
||||
if (configIds.length === 0) return 0;
|
||||
|
||||
// Remove the monitor from the junction table, 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
|
||||
const ids = configIds.map((r: { monitor_alerts_id: number }) => r.monitor_alerts_id);
|
||||
let deletedCount = 0;
|
||||
for (const id of ids) {
|
||||
const remainingMonitors = await this.knex("monitor_alerts_config_monitors")
|
||||
.count("* as count")
|
||||
.where({ monitor_alerts_id: id })
|
||||
.first<CountResult>();
|
||||
if (Number(remainingMonitors?.count) === 0) {
|
||||
await this.deleteMonitorAlertConfig(id);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count monitor alert configs with optional filtering
|
||||
*/
|
||||
async getMonitorAlertConfigsCount(filter: MonitorAlertConfigFilter): Promise<CountResult | undefined> {
|
||||
let query = this.knex("monitor_alerts_config").count("* as count");
|
||||
let query = this.knex("monitor_alerts_config as mac").count("* as count");
|
||||
|
||||
if (filter.id !== undefined) {
|
||||
query = query.andWhere("id", filter.id);
|
||||
query = query.andWhere("mac.id", filter.id);
|
||||
}
|
||||
if (filter.monitor_tag !== undefined) {
|
||||
query = query.andWhere("monitor_tag", filter.monitor_tag);
|
||||
query = query
|
||||
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
|
||||
.andWhere("macm.monitor_tag", filter.monitor_tag);
|
||||
}
|
||||
if (filter.alert_for !== undefined) {
|
||||
query = query.andWhere("alert_for", filter.alert_for);
|
||||
query = query.andWhere("mac.alert_for", filter.alert_for);
|
||||
}
|
||||
if (filter.is_active !== undefined) {
|
||||
query = query.andWhere("is_active", filter.is_active);
|
||||
query = query.andWhere("mac.is_active", filter.is_active);
|
||||
}
|
||||
|
||||
return await query.first<CountResult>();
|
||||
@@ -174,29 +222,33 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
filter?: MonitorAlertConfigFilter,
|
||||
): Promise<{ configs: MonitorAlertConfigRecord[]; total: number }> {
|
||||
// Build count query
|
||||
let countQuery = this.knex("monitor_alerts_config").count("* as count");
|
||||
let countQuery = this.knex("monitor_alerts_config as mac").count("* as count");
|
||||
if (filter?.monitor_tag) {
|
||||
countQuery = countQuery.where("monitor_tag", filter.monitor_tag);
|
||||
countQuery = countQuery
|
||||
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
|
||||
.where("macm.monitor_tag", filter.monitor_tag);
|
||||
}
|
||||
if (filter?.is_active) {
|
||||
countQuery = countQuery.andWhere("is_active", filter.is_active);
|
||||
countQuery = countQuery.andWhere("mac.is_active", filter.is_active);
|
||||
}
|
||||
if (filter?.alert_for) {
|
||||
countQuery = countQuery.andWhere("alert_for", filter.alert_for);
|
||||
countQuery = countQuery.andWhere("mac.alert_for", filter.alert_for);
|
||||
}
|
||||
const totalResult = await countQuery.first<CountResult>();
|
||||
const total = totalResult ? Number(totalResult.count) : 0;
|
||||
|
||||
// Build paginated query
|
||||
let query = this.knex("monitor_alerts_config").orderBy("id", "desc");
|
||||
let query = this.knex("monitor_alerts_config as mac").select("mac.*").orderBy("mac.id", "desc");
|
||||
if (filter?.monitor_tag) {
|
||||
query = query.where("monitor_tag", filter.monitor_tag);
|
||||
query = query
|
||||
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
|
||||
.where("macm.monitor_tag", filter.monitor_tag);
|
||||
}
|
||||
if (filter?.is_active) {
|
||||
query = query.andWhere("is_active", filter.is_active);
|
||||
query = query.andWhere("mac.is_active", filter.is_active);
|
||||
}
|
||||
if (filter?.alert_for) {
|
||||
query = query.andWhere("alert_for", filter.alert_for);
|
||||
query = query.andWhere("mac.alert_for", filter.alert_for);
|
||||
}
|
||||
const configs = await query.limit(limit).offset((page - 1) * limit);
|
||||
|
||||
@@ -278,6 +330,51 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Monitor Alert Config Monitors CRUD ============
|
||||
|
||||
/**
|
||||
* Add multiple monitors to an alert config
|
||||
*/
|
||||
async addMonitorsToAlertConfig(alertConfigId: number, monitorTags: string[]): Promise<void> {
|
||||
if (monitorTags.length === 0) return;
|
||||
|
||||
const inserts = monitorTags.map((monitorTag) => ({
|
||||
monitor_alerts_id: alertConfigId,
|
||||
monitor_tag: monitorTag,
|
||||
created_at: this.knex.fn.now(),
|
||||
updated_at: this.knex.fn.now(),
|
||||
}));
|
||||
|
||||
await this.knex("monitor_alerts_config_monitors").insert(inserts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all monitors from an alert config
|
||||
*/
|
||||
async removeAllMonitorsFromAlertConfig(alertConfigId: number): Promise<number> {
|
||||
return await this.knex("monitor_alerts_config_monitors").where({ monitor_alerts_id: alertConfigId }).del();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all monitors for an alert config (remove old, add new)
|
||||
*/
|
||||
async replaceAlertConfigMonitors(alertConfigId: number, monitorTags: string[]): Promise<void> {
|
||||
await this.removeAllMonitorsFromAlertConfig(alertConfigId);
|
||||
if (monitorTags.length > 0) {
|
||||
await this.addMonitorsToAlertConfig(alertConfigId, monitorTags);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get monitor tags for an alert config
|
||||
*/
|
||||
async getAlertConfigMonitorTags(alertConfigId: number): Promise<string[]> {
|
||||
const records = await this.knex("monitor_alerts_config_monitors")
|
||||
.select("monitor_tag")
|
||||
.where({ monitor_alerts_id: alertConfigId });
|
||||
return records.map((r: { monitor_tag: string }) => r.monitor_tag);
|
||||
}
|
||||
|
||||
// ============ Composite / Join Operations ============
|
||||
|
||||
/**
|
||||
@@ -292,9 +389,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
.select("t.*")
|
||||
.where("mact.monitor_alerts_id", id);
|
||||
|
||||
const monitorTags = await this.getAlertConfigMonitorTags(id);
|
||||
|
||||
return {
|
||||
...config,
|
||||
triggers: triggerRecords as TriggerRecord[],
|
||||
monitor_tags: monitorTags,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -311,9 +411,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
.select("t.*")
|
||||
.where("mact.monitor_alerts_id", config.id);
|
||||
|
||||
const monitorTags = await this.getAlertConfigMonitorTags(config.id);
|
||||
|
||||
result.push({
|
||||
...config,
|
||||
triggers: triggerRecords as TriggerRecord[],
|
||||
monitor_tags: monitorTags,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -333,9 +436,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
.select("t.*")
|
||||
.where("mact.monitor_alerts_id", config.id);
|
||||
|
||||
const monitorTags = await this.getAlertConfigMonitorTags(config.id);
|
||||
|
||||
result.push({
|
||||
...config,
|
||||
triggers: triggerRecords as TriggerRecord[],
|
||||
monitor_tags: monitorTags,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -373,6 +479,7 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
const dbType = GetDbType();
|
||||
const insertData: Record<string, unknown> = {
|
||||
config_id: data.config_id,
|
||||
monitor_tag: data.monitor_tag || null,
|
||||
incident_id: data.incident_id || null,
|
||||
alert_status: data.alert_status,
|
||||
created_at: this.knex.fn.now(),
|
||||
@@ -432,6 +539,9 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
if (filter.config_id !== undefined) {
|
||||
query = query.andWhere("config_id", filter.config_id);
|
||||
}
|
||||
if (filter.monitor_tag !== undefined) {
|
||||
query = query.andWhere("monitor_tag", filter.monitor_tag);
|
||||
}
|
||||
if (filter.incident_id !== undefined) {
|
||||
query = query.andWhere("incident_id", filter.incident_id);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
@@ -168,6 +170,15 @@ const seedSiteData = {
|
||||
mode: "off",
|
||||
urls: [],
|
||||
},
|
||||
globalMaintenanceNotificationSettings: {
|
||||
event_types: {
|
||||
created: false,
|
||||
reminder: true,
|
||||
started: true,
|
||||
ended: true,
|
||||
},
|
||||
reminder_buffer_hours: 1,
|
||||
},
|
||||
};
|
||||
|
||||
export default seedSiteData;
|
||||
|
||||
+146
-14
@@ -1,7 +1,8 @@
|
||||
import dns2 from "dns2";
|
||||
import dgram, { type Socket } from "dgram";
|
||||
import tls from "tls";
|
||||
import { Resolver as NodeResolver } from "node:dns/promises";
|
||||
import { AllRecordTypes } from "../clientTools";
|
||||
import { AllRecordTypes, IsValidHost } from "../clientTools";
|
||||
|
||||
interface DNSAnswer {
|
||||
name: string;
|
||||
@@ -25,13 +26,32 @@ interface DNSRecordResult {
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface DnsQueryOptions {
|
||||
transport?: "UDP" | "TLS";
|
||||
nameserverOverride?: string;
|
||||
tlsPort?: number;
|
||||
tlsServername?: string;
|
||||
allowSelfSignedCert?: boolean;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_DOT_PORT = 853;
|
||||
const DEFAULT_QUERY_TIMEOUT_MS = 3000;
|
||||
|
||||
class DNSResolver {
|
||||
nameserver: string;
|
||||
socket: Socket;
|
||||
socket: Socket | null;
|
||||
|
||||
constructor() {
|
||||
this.nameserver = "8.8.8.8";
|
||||
this.socket = dgram.createSocket("udp4");
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
private getUdpSocket(): Socket {
|
||||
if (!this.socket) {
|
||||
this.socket = dgram.createSocket("udp4");
|
||||
}
|
||||
return this.socket;
|
||||
}
|
||||
|
||||
createQuery(domain: string, type: string): InstanceType<typeof dns2.Packet> {
|
||||
@@ -49,36 +69,127 @@ class DNSResolver {
|
||||
return packet;
|
||||
}
|
||||
|
||||
private resolveTlsServername(nameserver: string, tlsServername?: string): string | undefined {
|
||||
const configuredServername = tlsServername?.trim();
|
||||
if (configuredServername) {
|
||||
return configuredServername;
|
||||
}
|
||||
if (IsValidHost(nameserver)) {
|
||||
return nameserver;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async query(domain: string, recordType: string, nameserverOverride?: string): Promise<DNSResponse> {
|
||||
const socket = this.getUdpSocket();
|
||||
return new Promise((resolve, reject) => {
|
||||
const query = this.createQuery(domain, recordType);
|
||||
const buffer = query.toBuffer();
|
||||
const targetNameserver = nameserverOverride || this.nameserver;
|
||||
const timeoutMs = DEFAULT_QUERY_TIMEOUT_MS;
|
||||
|
||||
const onMessage = (message: Buffer) => {
|
||||
clearTimeout(timeoutId);
|
||||
// @ts-expect-error dns2 types are incomplete
|
||||
const response = dns2.Packet.parse(message) as DNSResponse;
|
||||
resolve(response);
|
||||
socket.removeListener("message", onMessage);
|
||||
try {
|
||||
// @ts-expect-error dns2 types are incomplete
|
||||
const response = dns2.Packet.parse(message) as DNSResponse;
|
||||
resolve(response);
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.socket.removeListener("message", onMessage);
|
||||
socket.removeListener("message", onMessage);
|
||||
socket.close();
|
||||
if (this.socket === socket) {
|
||||
this.socket = null;
|
||||
}
|
||||
reject(new Error(`DNS query timed out for ${domain} (${recordType}) via ${targetNameserver}`));
|
||||
}, 3000);
|
||||
}, timeoutMs);
|
||||
|
||||
this.socket.once("message", onMessage);
|
||||
socket.once("message", onMessage);
|
||||
|
||||
this.socket.send(buffer, 0, buffer.length, 53, targetNameserver, (err: Error | null) => {
|
||||
socket.send(buffer, 0, buffer.length, 53, targetNameserver, (err: Error | null) => {
|
||||
if (err) {
|
||||
clearTimeout(timeoutId);
|
||||
this.socket.removeListener("message", onMessage);
|
||||
socket.removeListener("message", onMessage);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async queryOverTls(
|
||||
domain: string,
|
||||
recordType: string,
|
||||
nameserver: string,
|
||||
options: Pick<DnsQueryOptions, "tlsPort" | "tlsServername" | "allowSelfSignedCert" | "timeoutMs"> = {},
|
||||
): Promise<DNSResponse> {
|
||||
const query = this.createQuery(domain, recordType);
|
||||
const buffer = query.toBuffer();
|
||||
const lengthPrefix = Buffer.alloc(2);
|
||||
lengthPrefix.writeUInt16BE(buffer.length, 0);
|
||||
const message = Buffer.concat([lengthPrefix, buffer]);
|
||||
const port = options.tlsPort ?? DEFAULT_DOT_PORT;
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS;
|
||||
const servername = this.resolveTlsServername(nameserver, options.tlsServername);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let responseBuffer = Buffer.alloc(0);
|
||||
let expectedLength: number | null = null;
|
||||
|
||||
const socket = tls.connect({
|
||||
host: nameserver,
|
||||
port,
|
||||
servername,
|
||||
rejectUnauthorized: !options.allowSelfSignedCert,
|
||||
});
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new Error(`DNS-over-TLS query timed out for ${domain} (${recordType}) via ${nameserver}:${port}`));
|
||||
}, timeoutMs);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
|
||||
socket.on("error", (err) => {
|
||||
cleanup();
|
||||
socket.destroy();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
socket.on("data", (chunk: Buffer) => {
|
||||
responseBuffer = Buffer.concat([responseBuffer, chunk]);
|
||||
|
||||
if (expectedLength === null && responseBuffer.length >= 2) {
|
||||
expectedLength = responseBuffer.readUInt16BE(0);
|
||||
}
|
||||
|
||||
if (expectedLength !== null && responseBuffer.length >= expectedLength + 2) {
|
||||
cleanup();
|
||||
const responseData = responseBuffer.subarray(2, 2 + expectedLength);
|
||||
try {
|
||||
// @ts-expect-error dns2 types are incomplete
|
||||
const response = dns2.Packet.parse(responseData) as DNSResponse;
|
||||
socket.destroy();
|
||||
resolve(response);
|
||||
} catch (error) {
|
||||
socket.destroy();
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("secureConnect", () => {
|
||||
socket.write(message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getAuthoritativeNameServers(domain: string, resolverNameserver?: string): Promise<string[]> {
|
||||
const resolver = new NodeResolver();
|
||||
resolver.setServers([resolverNameserver || this.nameserver]);
|
||||
@@ -140,12 +251,30 @@ class DNSResolver {
|
||||
async getRecord(
|
||||
domain: string,
|
||||
recordType: string,
|
||||
nameserverOverride?: string,
|
||||
options: DnsQueryOptions = {},
|
||||
): Promise<Record<string, DNSRecordResult[]>> {
|
||||
const results: Record<string, DNSRecordResult[]> = {};
|
||||
const transport = options.transport ?? "UDP";
|
||||
const nameserverOverride = options.nameserverOverride?.trim() || undefined;
|
||||
|
||||
try {
|
||||
const response = await this.queryAuthoritativeRecord(domain, recordType, nameserverOverride);
|
||||
let response: DNSResponse;
|
||||
|
||||
if (transport === "TLS") {
|
||||
if (!nameserverOverride) {
|
||||
throw new Error("Name server is required for DNS-over-TLS queries");
|
||||
}
|
||||
|
||||
response = await this.queryOverTls(domain, recordType, nameserverOverride, {
|
||||
tlsPort: options.tlsPort,
|
||||
tlsServername: options.tlsServername,
|
||||
allowSelfSignedCert: options.allowSelfSignedCert,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
} else {
|
||||
response = await this.queryAuthoritativeRecord(domain, recordType, nameserverOverride);
|
||||
}
|
||||
|
||||
results[recordType] = response.answers.map((answer: DNSAnswer) => ({
|
||||
name: answer.name,
|
||||
type: recordType,
|
||||
@@ -157,7 +286,10 @@ class DNSResolver {
|
||||
console.error("Error querying DNS records:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.socket.close();
|
||||
if (this.socket) {
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ export function alertToVariables(
|
||||
config: MonitorAlertConfigRecord,
|
||||
alert: MonitorAlertV2Record,
|
||||
siteVars: SiteDataForNotification,
|
||||
monitorTag?: string,
|
||||
): AlertVariableMap {
|
||||
const createdAtDate = alert.created_at instanceof Date ? alert.created_at : new Date(alert.created_at);
|
||||
const alert_name = `Alert ${config.monitor_tag} for ${config.alert_for} ${config.alert_value} ${alert.alert_status} at ${createdAtDate.toISOString()}`;
|
||||
const effectiveMonitorTag = monitorTag || config.monitor_tag || "unknown";
|
||||
const alert_name = `Alert ${effectiveMonitorTag} for ${config.alert_for} ${config.alert_value} ${alert.alert_status} at ${createdAtDate.toISOString()}`;
|
||||
|
||||
return {
|
||||
alert_id: alert.id,
|
||||
@@ -24,7 +26,7 @@ export function alertToVariables(
|
||||
alert_message: config.alert_description || "",
|
||||
alert_source: GC.ALERT,
|
||||
alert_timestamp: createdAtDate.toISOString(),
|
||||
alert_cta_url: siteVars.site_url + "monitors/" + config.monitor_tag,
|
||||
alert_cta_url: siteVars.site_url + "monitors/" + effectiveMonitorTag,
|
||||
alert_cta_text: "Open Alert Details",
|
||||
alert_incident_id: alert.incident_id ? alert.incident_id : undefined,
|
||||
alert_incident_url: alert.incident_id ? siteVars.site_url + "incidents/" + alert.incident_id : undefined,
|
||||
|
||||
@@ -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,11 +33,8 @@ 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";
|
||||
import subscriberQueue from "./subscriberQueue.js";
|
||||
import type { SiteDataForNotification } from "../notification/types.js";
|
||||
let alertingQueue: Queue | null = null;
|
||||
|
||||
let worker: Worker | null = null;
|
||||
@@ -65,7 +61,6 @@ async function createNewIncident(
|
||||
config: MonitorAlertConfigRecord,
|
||||
monitorName: string,
|
||||
monitorTag: string,
|
||||
siteUrl: string = "",
|
||||
): Promise<{ incident_id: number }> {
|
||||
let startDateTime = getUnixTime(new Date(alert.created_at));
|
||||
let incidentInput: IncidentInput = {
|
||||
@@ -74,6 +69,8 @@ async function createNewIncident(
|
||||
incident_source: "ALERT",
|
||||
};
|
||||
|
||||
// Subscriber notification comes from AddIncidentComment (via
|
||||
// CreateNewIncidentWithCommentAndMonitor) — do not push here too.
|
||||
let update = IncidentCreateAlertMarkdown(alert, config, monitorName, monitorTag, GC.TRIGGERED);
|
||||
let incidentCreated = await CreateNewIncidentWithCommentAndMonitor(
|
||||
incidentInput,
|
||||
@@ -82,23 +79,7 @@ async function createNewIncident(
|
||||
config.alert_value,
|
||||
);
|
||||
|
||||
const updateVariables: SubscriptionVariableMap = {
|
||||
title: incidentInput.title,
|
||||
cta_url: siteUrl + "incidents/" + incidentCreated.incident_id,
|
||||
cta_text: "View Incident",
|
||||
update_text: mdToHTML(update),
|
||||
update_subject: `[#${incidentCreated.incident_id}:${GC.TRIGGERED}] ${incidentInput.title}`,
|
||||
update_id: String(incidentCreated.incident_id),
|
||||
event_type: "incidents",
|
||||
};
|
||||
subscriberQueue.push(updateVariables);
|
||||
return incidentCreated;
|
||||
|
||||
/*
|
||||
|
||||
|
||||
|
||||
subscriberQueue.push(updateVariables);*/
|
||||
}
|
||||
|
||||
async function closeIncident(
|
||||
@@ -106,7 +87,6 @@ async function closeIncident(
|
||||
config: MonitorAlertConfigRecord,
|
||||
monitorName: string,
|
||||
monitorTag: string,
|
||||
siteUrl: string = "",
|
||||
): Promise<void> {
|
||||
//check if incident is already resolved
|
||||
if (!alert.incident_id) {
|
||||
@@ -124,16 +104,7 @@ async function closeIncident(
|
||||
let incident_id = alert.incident_id;
|
||||
const comment = ClosureCommentAlertMarkdown(alert, config, monitorName, monitorTag, GC.RESOLVED);
|
||||
const updatedAt = getUnixTime(new Date(alert.updated_at));
|
||||
const updateMessage: SubscriptionVariableMap = {
|
||||
title: incident.title,
|
||||
cta_url: `${siteUrl}incidents/${incident_id}`,
|
||||
cta_text: "View Incident",
|
||||
update_text: mdToHTML(comment),
|
||||
update_subject: `[#${incident.id}:${GC.RESOLVED}] ${incident.title}`,
|
||||
update_id: String(incident_id),
|
||||
event_type: "incidents",
|
||||
};
|
||||
subscriberQueue.push(updateMessage);
|
||||
// Subscriber notification comes from AddIncidentComment — do not push here too.
|
||||
await AddIncidentComment(incident_id, comment, GC.RESOLVED, updatedAt);
|
||||
}
|
||||
|
||||
@@ -141,8 +112,9 @@ async function sendAlertNotifications(
|
||||
activeAlert: MonitorAlertV2Record,
|
||||
monitor_alerts_configured: MonitorAlertConfigRecord,
|
||||
templateSiteVars: SiteDataForNotification,
|
||||
monitorTag?: string,
|
||||
): Promise<void> {
|
||||
const templateAlertVars = alertToVariables(monitor_alerts_configured, activeAlert, templateSiteVars);
|
||||
const templateAlertVars = alertToVariables(monitor_alerts_configured, activeAlert, templateSiteVars, monitorTag);
|
||||
const triggers = await GetTriggersByMonitorAlertConfigId(monitor_alerts_configured.id);
|
||||
|
||||
for (let i = 0; i < triggers.length; i++) {
|
||||
@@ -233,9 +205,10 @@ const addWorker = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get existing alerts
|
||||
// Get existing alerts for this specific monitor + config combination
|
||||
let alertsExisting = await GetMonitorAlertsV2({
|
||||
config_id: monitor_alerts_configured.id,
|
||||
monitor_tag: monitor_tag,
|
||||
alert_status: GC.TRIGGERED,
|
||||
});
|
||||
let activeAlert = null;
|
||||
@@ -244,16 +217,15 @@ const addWorker = () => {
|
||||
}
|
||||
|
||||
if (isAffected) {
|
||||
// Trigger alert if not already active
|
||||
// Trigger alert if not already active for this monitor
|
||||
if (!activeAlert) {
|
||||
activeAlert = await CreateMonitorAlertV2(monitor_alerts_configured.id);
|
||||
activeAlert = await CreateMonitorAlertV2(monitor_alerts_configured.id, monitor_tag);
|
||||
if (monitor_alerts_configured.create_incident === GC.YES) {
|
||||
let newIncidentNumber = await createNewIncident(
|
||||
activeAlert,
|
||||
monitor_alerts_configured,
|
||||
monitor_name,
|
||||
monitor_tag,
|
||||
templateSiteVars.site_url,
|
||||
);
|
||||
//update alert with incident number
|
||||
if (newIncidentNumber && newIncidentNumber.incident_id > 0) {
|
||||
@@ -261,7 +233,7 @@ const addWorker = () => {
|
||||
}
|
||||
}
|
||||
// Send triggered alert notifications
|
||||
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars);
|
||||
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars, monitor_tag);
|
||||
}
|
||||
} else {
|
||||
// Resolve any existing alert
|
||||
@@ -292,17 +264,11 @@ const addWorker = () => {
|
||||
|
||||
// If alert has an incident, add closure comment
|
||||
if (activeAlert.incident_id) {
|
||||
await closeIncident(
|
||||
activeAlert,
|
||||
monitor_alerts_configured,
|
||||
monitor_name,
|
||||
monitor_tag,
|
||||
templateSiteVars.site_url,
|
||||
);
|
||||
await closeIncident(activeAlert, monitor_alerts_configured, monitor_name, monitor_tag);
|
||||
}
|
||||
|
||||
// Send resolution notifications
|
||||
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars);
|
||||
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars, monitor_tag);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -348,6 +314,7 @@ export const push = async (monitor_tag: string, ts: number, status: string, opti
|
||||
monitor_tag: monitor.tag,
|
||||
is_active: GC.YES,
|
||||
});
|
||||
|
||||
if (monitorAlertsConfigurations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ const addWorker = () => {
|
||||
fromEmail,
|
||||
templateTextBody,
|
||||
);
|
||||
console.log(`📧 Email sent to ${toEmails}`);
|
||||
// console.log(`📧 Email sent to ${toEmails}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to send email to ${toEmails}:`, error);
|
||||
throw error; // Re-throw to trigger retry
|
||||
|
||||
@@ -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,9 +40,11 @@ const addWorker = () => {
|
||||
latency: latency,
|
||||
type: type,
|
||||
error_message: error_message,
|
||||
raw_status: raw_status,
|
||||
});
|
||||
|
||||
if (!dbRes) {
|
||||
console.error("Failed to insert monitoring data for monitorTag:", monitorTag, "timestamp:", ts);
|
||||
throw new Error("Failed to insert monitoring data");
|
||||
}
|
||||
|
||||
@@ -52,7 +55,6 @@ const addWorker = () => {
|
||||
latency: latency,
|
||||
type: type,
|
||||
});
|
||||
|
||||
alertingQueue.push(monitorTag, ts, status);
|
||||
|
||||
return 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,
|
||||
|
||||
@@ -4,7 +4,7 @@ import db from "../db/db.js";
|
||||
import { rrulestr } from "rrule";
|
||||
import { addDays } from "date-fns";
|
||||
import type { MaintenanceRecord, MaintenanceEventRecord } from "../types/db.js";
|
||||
import { determineEventStatus } from "../controllers/maintenanceController.js";
|
||||
import { determineEventStatus, CreateMaintenanceEventWithNotification } from "../controllers/maintenanceController.js";
|
||||
|
||||
let maintenanceSchedulerQueue: Queue | null = null;
|
||||
let worker: Worker | null = null;
|
||||
@@ -58,12 +58,13 @@ const generateEventsForMaintenance = async (maintenance: MaintenanceRecord): Pro
|
||||
|
||||
// Check if event already exists for this start time
|
||||
if (!existingStartTimes.has(eventStart)) {
|
||||
await db.createMaintenanceEvent({
|
||||
maintenance_id: maintenance.id,
|
||||
start_date_time: eventStart,
|
||||
end_date_time: eventEnd,
|
||||
status: determineEventStatus(eventStart, eventEnd),
|
||||
});
|
||||
await CreateMaintenanceEventWithNotification(
|
||||
maintenance.id,
|
||||
eventStart,
|
||||
eventEnd,
|
||||
maintenance.title,
|
||||
maintenance.description || null,
|
||||
);
|
||||
eventsCreated++;
|
||||
console.log(
|
||||
`Created maintenance event for "${maintenance.title}" at ${new Date(eventStart * 1000).toISOString()}`,
|
||||
|
||||
@@ -64,13 +64,18 @@ class ApiCall {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
const followRedirects =
|
||||
this.monitor.type_data.follow_redirects ?? true;
|
||||
|
||||
const maxRedirects =
|
||||
this.monitor.type_data.max_redirects ?? 5;
|
||||
|
||||
const options: AxiosRequestConfig = {
|
||||
method: method,
|
||||
headers: axiosHeaders,
|
||||
timeout: timeout,
|
||||
transformResponse: (r: string) => r,
|
||||
maxRedirects: 5,
|
||||
maxRedirects: followRedirects ? maxRedirects : 0,
|
||||
validateStatus: () => true,
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity,
|
||||
@@ -154,25 +159,32 @@ class ApiCall {
|
||||
}
|
||||
}
|
||||
|
||||
if (!!!evalResp) {
|
||||
if (!evalResp || typeof evalResp !== "object") {
|
||||
evalResp = {
|
||||
status: GC.DOWN,
|
||||
latency: latency,
|
||||
type: GC.ERROR,
|
||||
};
|
||||
} else if (
|
||||
!!!evalResp.status ||
|
||||
([GC.UP, GC.DOWN, GC.DEGRADED, GC.MAINTENANCE] as string[]).indexOf(evalResp.status) === -1
|
||||
) {
|
||||
errorMessage += " | Eval must return an object with 'status' and 'latency' fields, got no response";
|
||||
} else if (evalResp.status === undefined) {
|
||||
evalResp = {
|
||||
status: GC.DOWN,
|
||||
latency: latency,
|
||||
type: GC.ERROR,
|
||||
};
|
||||
errorMessage += ` | Eval must return an object with a 'status' field (one of: ${GC.UP}, ${GC.DOWN}, ${GC.DEGRADED}, ${GC.MAINTENANCE}), but 'status' was missing`;
|
||||
} else if (([GC.UP, GC.DOWN, GC.DEGRADED, GC.MAINTENANCE] as string[]).indexOf(evalResp.status) === -1) {
|
||||
evalResp = {
|
||||
status: GC.DOWN,
|
||||
latency: latency,
|
||||
type: GC.ERROR,
|
||||
};
|
||||
errorMessage += ` | Eval returned invalid 'status' value "${evalResp.status}". Must be one of: ${GC.UP}, ${GC.DOWN}, ${GC.DEGRADED}, ${GC.MAINTENANCE}`;
|
||||
} else {
|
||||
evalResp.type = GC.REALTIME;
|
||||
// Ensure latency is a valid number
|
||||
// Ensure latency is a valid number; fall back to measured latency
|
||||
if (typeof evalResp.latency !== "number" || isNaN(evalResp.latency)) {
|
||||
errorMessage += ` | Eval 'latency' must be a number, got ${JSON.stringify(evalResp.latency)}. Using measured latency instead`;
|
||||
evalResp.latency = latency;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -32,10 +32,18 @@ class DnsCall {
|
||||
let matchType = this.monitor.type_data.matchType;
|
||||
let values = this.monitor.type_data.values;
|
||||
const configuredNameServer = this.monitor.type_data.nameServer?.trim() || undefined;
|
||||
const transport = this.monitor.type_data.transport ?? "UDP";
|
||||
const transportLabel = transport === "TLS" ? "DNS-over-TLS" : "DNS";
|
||||
const queryStartTime = performance.now();
|
||||
|
||||
try {
|
||||
let dnsRes = await dnsResolver.getRecord(host, recordType, configuredNameServer);
|
||||
let dnsRes = await dnsResolver.getRecord(host, recordType, {
|
||||
transport,
|
||||
nameserverOverride: configuredNameServer,
|
||||
tlsPort: this.monitor.type_data.tlsPort,
|
||||
tlsServername: this.monitor.type_data.tlsServername,
|
||||
allowSelfSignedCert: this.monitor.type_data.allowSelfSignedCert,
|
||||
});
|
||||
let latency = Math.round(performance.now() - queryStartTime);
|
||||
|
||||
if (dnsRes[recordType] === undefined) {
|
||||
@@ -43,7 +51,7 @@ class DnsCall {
|
||||
status: GC.DOWN,
|
||||
latency: latency,
|
||||
type: GC.REALTIME,
|
||||
error_message: `No DNS ${recordType} response found for ${host}`,
|
||||
error_message: `No ${transportLabel} ${recordType} response found for ${host}`,
|
||||
};
|
||||
}
|
||||
let data = dnsRes[recordType];
|
||||
@@ -55,7 +63,7 @@ class DnsCall {
|
||||
status: GC.DOWN,
|
||||
latency: latency,
|
||||
type: GC.REALTIME,
|
||||
error_message: `No DNS ${recordType} records returned for ${host}`,
|
||||
error_message: `No ${transportLabel} ${recordType} records returned for ${host}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,7 +74,7 @@ class DnsCall {
|
||||
status: GC.DOWN,
|
||||
latency: latency,
|
||||
type: GC.REALTIME,
|
||||
error_message: `DNS ${recordType} mismatch for ${host}. Missing: ${missingValues.join(", ")}`,
|
||||
error_message: `${transportLabel} ${recordType} mismatch for ${host}. Missing: ${missingValues.join(", ")}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -88,7 +96,7 @@ class DnsCall {
|
||||
status: GC.DOWN,
|
||||
latency: latency,
|
||||
type: GC.REALTIME,
|
||||
error_message: `DNS ${recordType} mismatch for ${host}. Got: ${dnsData.join(", ")}`,
|
||||
error_message: `${transportLabel} ${recordType} mismatch for ${host}. Got: ${dnsData.join(", ")}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -98,14 +106,14 @@ class DnsCall {
|
||||
status: GC.DOWN,
|
||||
latency,
|
||||
type: GC.REALTIME,
|
||||
error_message: `DNS query failed for ${host} (${recordType}): ${message}`,
|
||||
error_message: `${transportLabel} query failed for ${host} (${recordType}): ${message}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: GC.DOWN,
|
||||
latency: Math.round(performance.now() - queryStartTime),
|
||||
type: GC.REALTIME,
|
||||
error_message: `DNS ${recordType} check did not return a definitive result for ${host}`,
|
||||
error_message: `${transportLabel} ${recordType} check did not return a definitive result for ${host}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ class GrpcCall {
|
||||
}
|
||||
|
||||
async execute(): Promise<MonitoringResult> {
|
||||
const { host, port, service, tls, timeout } = this.monitor.type_data;
|
||||
const { host, port, service, tls, insecure, timeout } = this.monitor.type_data;
|
||||
const timeoutMs = timeout || 10000;
|
||||
const target = `${host}:${port}`;
|
||||
|
||||
@@ -72,7 +72,16 @@ class GrpcCall {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const healthService = (proto.grpc as any).health.v1.Health;
|
||||
|
||||
const credentials = tls ? grpc.credentials.createSsl() : grpc.credentials.createInsecure();
|
||||
const credentials =
|
||||
tls && insecure
|
||||
? grpc.credentials.createSsl(null, null, null, {
|
||||
rejectUnauthorized: false,
|
||||
checkServerIdentity: () => undefined,
|
||||
})
|
||||
: tls
|
||||
? grpc.credentials.createSsl()
|
||||
: grpc.credentials.createInsecure();
|
||||
|
||||
const client = new healthService(target, credentials);
|
||||
const deadline = new Date(Date.now() + timeoutMs);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ class TcpCall {
|
||||
error_message: `Error in tcpEval: ${message}`,
|
||||
};
|
||||
}
|
||||
if (!!!evalResp) {
|
||||
if (!evalResp) {
|
||||
const message = "tcpEval did not return a valid response.";
|
||||
console.log(`Error in tcpEval for ${tag}:`, message);
|
||||
return {
|
||||
@@ -65,6 +65,19 @@ class TcpCall {
|
||||
error_message: `Error in tcpEval: ${message}`,
|
||||
};
|
||||
}
|
||||
|
||||
//evalResp to be an object with status and latency
|
||||
if (!("status" in evalResp) || !("latency" in evalResp)) {
|
||||
const message = "tcpEval did not return status or latency.";
|
||||
console.log(`Error in tcpEval for ${tag}:`, message);
|
||||
return {
|
||||
status: GC.DOWN,
|
||||
latency: 0,
|
||||
type: GC.ERROR,
|
||||
error_message: `Error in tcpEval: ${message}`,
|
||||
};
|
||||
}
|
||||
|
||||
//reduce to get the status
|
||||
return {
|
||||
status: evalResp?.status || GC.DOWN,
|
||||
|
||||
@@ -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;
|
||||
@@ -628,7 +660,7 @@ export type YesNoType = "YES" | "NO";
|
||||
|
||||
export interface MonitorAlertConfigRecord {
|
||||
id: number;
|
||||
monitor_tag: string;
|
||||
monitor_tag: string | null;
|
||||
alert_for: AlertForType;
|
||||
alert_value: string;
|
||||
failure_threshold: number;
|
||||
@@ -642,7 +674,7 @@ export interface MonitorAlertConfigRecord {
|
||||
}
|
||||
|
||||
export interface MonitorAlertConfigInsert {
|
||||
monitor_tag: string;
|
||||
monitor_tag?: string | null;
|
||||
alert_for: AlertForType;
|
||||
alert_value: string;
|
||||
failure_threshold: number;
|
||||
@@ -684,13 +716,27 @@ export interface MonitorAlertConfigTriggerInsert {
|
||||
trigger_id: number;
|
||||
}
|
||||
|
||||
// ============ monitor_alerts_config_monitors table ============
|
||||
export interface MonitorAlertConfigMonitorRecord {
|
||||
monitor_alerts_id: number;
|
||||
monitor_tag: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface MonitorAlertConfigMonitorInsert {
|
||||
monitor_alerts_id: number;
|
||||
monitor_tag: string;
|
||||
}
|
||||
|
||||
// ============ Composite types for monitor_alerts_config ============
|
||||
export interface MonitorAlertConfigWithTriggers extends MonitorAlertConfigRecord {
|
||||
triggers: TriggerRecord[];
|
||||
monitor_tags: string[];
|
||||
}
|
||||
|
||||
export interface MonitorAlertConfigCreateInput {
|
||||
monitor_tag: string;
|
||||
monitor_tags: string[];
|
||||
alert_for: AlertForType;
|
||||
alert_value: string;
|
||||
failure_threshold: number;
|
||||
@@ -704,6 +750,7 @@ export interface MonitorAlertConfigCreateInput {
|
||||
|
||||
export interface MonitorAlertConfigUpdateInput {
|
||||
id: number;
|
||||
monitor_tags?: string[];
|
||||
alert_for?: AlertForType;
|
||||
alert_value?: string;
|
||||
failure_threshold?: number;
|
||||
@@ -721,6 +768,7 @@ export type MonitorAlertStatusType = "TRIGGERED" | "RESOLVED";
|
||||
export interface MonitorAlertV2Record {
|
||||
id: number;
|
||||
config_id: number;
|
||||
monitor_tag: string | null;
|
||||
incident_id: number | null;
|
||||
alert_status: MonitorAlertStatusType;
|
||||
created_at: Date;
|
||||
@@ -729,11 +777,13 @@ export interface MonitorAlertV2Record {
|
||||
|
||||
export interface MonitorAlertV2Insert {
|
||||
config_id: number;
|
||||
monitor_tag?: string | null;
|
||||
incident_id?: number | null;
|
||||
alert_status: MonitorAlertStatusType;
|
||||
}
|
||||
|
||||
export interface MonitorAlertV2Update {
|
||||
monitor_tag?: string | null;
|
||||
incident_id?: number | null;
|
||||
alert_status?: MonitorAlertStatusType;
|
||||
}
|
||||
@@ -741,6 +791,7 @@ export interface MonitorAlertV2Update {
|
||||
export interface MonitorAlertV2Filter {
|
||||
id?: number;
|
||||
config_id?: number;
|
||||
monitor_tag?: string;
|
||||
incident_id?: number;
|
||||
alert_status?: MonitorAlertStatusType;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -23,6 +26,8 @@ export interface ApiMonitorTypeData {
|
||||
timeout?: number;
|
||||
eval?: string;
|
||||
allowSelfSignedCert?: boolean;
|
||||
follow_redirects?: boolean;
|
||||
max_redirects?: number;
|
||||
}
|
||||
|
||||
export interface DnsMonitorTypeData {
|
||||
@@ -31,6 +36,10 @@ export interface DnsMonitorTypeData {
|
||||
lookupRecord: string;
|
||||
matchType: "ALL" | "ANY";
|
||||
values: string[];
|
||||
transport?: "UDP" | "TLS";
|
||||
tlsPort?: number;
|
||||
tlsServername?: string;
|
||||
allowSelfSignedCert?: boolean;
|
||||
}
|
||||
|
||||
export type { PingHost, PingMonitorTypeData };
|
||||
@@ -83,6 +92,7 @@ export interface GrpcMonitorTypeData {
|
||||
port: number;
|
||||
service?: string;
|
||||
tls?: boolean;
|
||||
insecure?: boolean;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,12 @@
|
||||
}
|
||||
gtag("js", new Date());
|
||||
|
||||
gtag("config", "{{id}}");
|
||||
var gtagConfig = {};
|
||||
var transportUrl = "{{transport_url}}";
|
||||
if (!!transportUrl) {
|
||||
gtagConfig.transport_url = transportUrl;
|
||||
}
|
||||
gtag("config", "{{id}}", gtagConfig);
|
||||
|
||||
window.addEventListener("analyticsEvent", function (e) {
|
||||
// Extract event name and data from the custom event
|
||||
@@ -43,6 +48,7 @@
|
||||
|
||||
//on dom ready
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
loadJS("https://www.googletagmanager.com/gtag/js?id={{id}}", initJS, document.getElementsByTagName("head")[0]);
|
||||
var scriptHost = "{{script_host}}" || "https://www.googletagmanager.com/gtag/js?id={{id}}";
|
||||
loadJS(scriptHost, initJS, document.getElementsByTagName("head")[0]);
|
||||
});
|
||||
})();
|
||||
|
||||
+70
-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,7 +119,9 @@ export interface CreateMonitorRequest {
|
||||
type_data?: MonitorTypeData | null;
|
||||
include_degraded_in_downtime?: string;
|
||||
is_hidden?: string;
|
||||
confirmation_threshold?: number | null;
|
||||
monitor_settings_json?: MonitorSettings | null;
|
||||
external_url?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateMonitorResponse {
|
||||
@@ -136,13 +140,19 @@ export interface UpdateMonitorRequest {
|
||||
type_data?: MonitorTypeData | null;
|
||||
include_degraded_in_downtime?: string;
|
||||
is_hidden?: string;
|
||||
confirmation_threshold?: number | null;
|
||||
monitor_settings_json?: MonitorSettings | null;
|
||||
external_url?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateMonitorResponse {
|
||||
monitor: MonitorRecordTyped;
|
||||
}
|
||||
|
||||
export interface DeleteMonitorResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Monitoring Data API types
|
||||
export interface MonitoringDataPoint {
|
||||
monitor_tag: string;
|
||||
@@ -198,6 +208,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 +310,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 +363,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 +378,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 +414,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 +466,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 +508,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 +538,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 +552,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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user