Compare commits

..

35 Commits

Author SHA1 Message Date
Raj Nandan Sharma db6cb6cf7d refactor: improve SQLite migration for monitor_alerts_config to ensure monitor_tag is nullable 2026-03-28 11:07:13 +05:30
Raj Nandan Sharma babeeb75b2 chore: update v4.0.22 changelog to note migration fix for sqlite3 from v4.0.21 2026-03-28 10:36:30 +05:30
Raj Nandan Sharma e0187605e7 chore: update changelog for v4.0.22 and remove v4.0.21 entry 2026-03-28 10:34:43 +05:30
Raj Nandan Sharma e2861f1e59 refactor: rebuild SQLite monitor_alerts_config table to make monitor_tag nullable 2026-03-28 10:32:50 +05:30
Raj Nandan Sharma 01aa4d9984 refactor: modify SQLite monitor_alerts_config to allow nullable monitor_tag 2026-03-28 09:50:08 +05:30
github-actions[bot] 555372f175 chore(release): bump version to 4.0.21 2026-03-27 17:58:55 +00:00
Raj Nandan Sharma bd5fd409d1 chore: update documentation for v4.0.21 changelog and navigation 2026-03-27 22:27:24 +05:30
Raj Nandan Sharma f61d82e13f Merge pull request #688 from rajnandan1/implement/672
refactor: implement global maintenance notification settings and upda…
2026-03-27 21:28:02 +05:30
Raj Nandan Sharma f39588fff7 refactor: enhance maintenance notification settings validation and update event status logic 2026-03-27 21:02:51 +05:30
Raj Nandan Sharma 0716f271df refactor: implement global maintenance notification settings and update related event handling 2026-03-27 20:46:00 +05:30
Raj Nandan Sharma 1085c3e561 Merge pull request #679 from rajnandan1/release-4.0.21
refactor: support multi-monitor alerts by updating alert configuratio…
2026-03-25 16:44:43 +05:30
Raj Nandan Sharma 93907e6d96 refactor: improve error handling in tcpEval and evalResp validation 2026-03-25 16:40:44 +05:30
Raj Nandan Sharma d03fd63c64 refactor: support multi-monitor alerts by updating alert configuration and database schema
- Modify alertToVariables function to accept an optional monitorTag parameter for better flexibility in alert naming.
- Update sendAlertNotifications function to pass monitorTag to alertToVariables.
- Enhance addWorker function to handle multiple monitors by querying and creating alerts with the appropriate monitorTag.
- Introduce a new junction table for many-to-many relationships between monitor alerts and monitors.
- Update database migration to accommodate the new schema and backfill existing data.
- Revise frontend components to support multiple monitor selections in alert configurations.
- Enhance documentation to include analytics provider setup instructions.

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

All npm audit checks pass with 0 vulnerabilities.
2026-03-23 11:55:45 +01:00
Raj Nandan Sharma b1f8a505a5 Merge pull request #677 from rajnandan1/maintenance-prominient
refactor: implement upcoming maintenances feature in dashboard and mo…
2026-03-23 09:19:16 +05:30
Raj Nandan Sharma 104da58646 refactor: implement upcoming maintenances feature in dashboard and monitor views. implements #665 and #674 2026-03-23 09:18:45 +05:30
Raj Nandan Sharma ed6e97e8c9 refactor: update Google Tag Manager script source and enhance configuration with transport URL 2026-03-22 23:11:43 +05:30
Raj Nandan Sharma 0b25274874 refactor: enhance Google Tag Manager integration with transport URL and script host options 2026-03-22 20:31:34 +05:30
Raj Nandan Sharma bb8ab41bfe Merge pull request #673 from danynocz/update-czech-slovakia-march
Update Czech and Slovak translation
2026-03-20 22:16:01 +05:30
_DANYNO_ b43a5fb343 Update Slovak translations 2026-03-20 15:23:22 +01:00
_DANYNO_ 554caa5018 Update Czech translation 2026-03-20 15:19:55 +01:00
Raj Nandan Sharma ffe7403043 refactor: remove unused <svelte:head> elements and associated metadata from documentation layouts 2026-03-20 16:41:58 +05:30
Raj Nandan Sharma a26d0ece59 Merge pull request #671 from rajnandan1/fixcustom-js-css-guide-link
Fixcustom js css guide link
2026-03-20 15:37:50 +05:30
Raj Nandan Sharma a4277f7ed0 refactor: update custom fonts guide link to point to the correct URL 2026-03-20 15:36:50 +05:30
Raj Nandan Sharma f75aaf9cef refactor: update custom CSS guide link to direct to the correct URL 2026-03-20 15:36:04 +05:30
Raj Nandan Sharma 4e1ecf41ee Merge pull request #670 from rajnandan1/fix-i18n-667
refactor: update "Included Monitors" label to include count in variou…
2026-03-20 15:28:35 +05:30
Raj Nandan Sharma f23fb5313c refactor: update "Included Monitors" label to include count in various components and localization files, implements #667 2026-03-20 14:48:06 +05:30
github-actions[bot] 18489c5339 chore(release): bump version to 4.0.20 2026-03-19 17:08:48 +00:00
Raj Nandan Sharma 4044bae26d chore: include v4.0.20 changelog with new features and improvements 2026-03-19 21:19:29 +05:30
Raj Nandan Sharma 02686caa78 chore: update font import in docs.css and create comparison guide for Kener vs other status pages 2026-03-19 15:09:59 +05:30
Raj Nandan Sharma 797aef80d6 Update typography styles for improved consistency and adjust font import URL 2026-03-19 13:55:32 +05:30
Raj Nandan Sharma a8841ad8a3 Implement support for HEIC/HEIF image formats and increase body size limit to 3M 2026-03-19 12:28:58 +05:30
Raj Nandan Sharma 0b2cd5fc8a Merge pull request #664 from rajnandan1/seo-opti
implement site and page-level SEO meta tags with social preview image…
2026-03-19 10:22:38 +05:30
79 changed files with 2782 additions and 977 deletions
+1
View File
@@ -135,6 +135,7 @@ ARG KENER_BASE_PATH=
ENV NODE_ENV=production \
PORT=${PORT} \
KENER_BASE_PATH=${KENER_BASE_PATH} \
BODY_SIZE_LIMIT=3M \
TZ=UTC \
# Required so Node can import .ts migration/seed files at runtime
NODE_OPTIONS="--experimental-strip-types"
+3
View File
@@ -1,6 +1,9 @@
#!/bin/sh
set -e
# Default body size limit for SvelteKit adapter-node (512K default is too small for image uploads)
export BODY_SIZE_LIMIT="${BODY_SIZE_LIMIT:-3M}"
# Index documentation into Redis when docs are bundled in the image
if [ -f /app/scripts/index-docs.ts ]; then
echo "[kener] Indexing documentation into Redis..."
-27
View File
@@ -1,27 +0,0 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": ["src/app.html", "build/main.js", "scripts/**/*.{js,ts}", "migrations/**/*.{js,ts}", "seeds/**/*.{js,ts}"],
"project": ["src/**/*.{js,ts,svelte}", "scripts/**/*.{js,ts}", "migrations/**/*.{js,ts}", "seeds/**/*.{js,ts}"],
"ignore": ["src/lib/components/ui/**"],
"ignoreDependencies": [
"@babel/runtime",
"js-yaml",
"mysql2",
"node-cache",
"pg",
"pg-pool",
"randomstring",
"style-to-object",
"lucide-svelte",
"marked-gfm-heading-id"
],
"ignoreExportsUsedInFile": {
"interface": true,
"type": true
},
"ignoreBinaries": [],
"compilers": {
"css": ["postcss"],
"svelte": ["svelte"]
}
}
@@ -0,0 +1,215 @@
/**
* Migration: Multi-Monitor Alerts
*
* This migration refactors the monitor alerting system to support many-to-many
* relationships between alert configurations and monitors. Previously, each
* `monitor_alerts_config` row was tied to exactly one monitor via a `monitor_tag`
* foreign key column. This migration:
*
* 1. Creates a new `monitor_alerts_config_monitors` junction table that links
* `monitor_alerts_config` rows to one or more `monitors` rows, enabling a
* single alert configuration to fire across multiple monitors.
*
* 2. Adds a `monitor_tag` column to `monitor_alerts_v2` so that each firing
* alert record knows which specific monitor triggered it (important when one
* config covers many monitors).
*
* 3. Migrates existing data: copies every `monitor_alerts_config.monitor_tag`
* value into the new junction table and backfills `monitor_alerts_v2.monitor_tag`
* from the same source, preserving all historical alert records.
*
* 4. Removes the one-to-one constraint on `monitor_alerts_config.monitor_tag` by
* dropping its foreign key and setting the column nullable (SQLite workaround:
* nulls the column directly since SQLite cannot drop foreign key constraints
* inline).
*
* 5. Adds a composite index on `monitor_alerts_v2 (config_id, monitor_tag,
* alert_status)` for fast per-monitor alert status lookups.
*
* The `down` migration reverses these steps: restores the first junction-table
* entry back onto `monitor_alerts_config.monitor_tag`, re-adds the foreign key
* (non-SQLite), removes the `monitor_tag` column from `monitor_alerts_v2`
* (non-SQLite), and drops the junction table.
*/
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Step 1: Create monitor_alerts_config_monitors junction table
if (!(await knex.schema.hasTable("monitor_alerts_config_monitors"))) {
await knex.schema.createTable("monitor_alerts_config_monitors", (table) => {
table.integer("monitor_alerts_id").unsigned().notNullable();
table.string("monitor_tag", 255).notNullable();
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Composite primary key
table.primary(["monitor_alerts_id", "monitor_tag"]);
// Foreign keys
table.foreign("monitor_alerts_id").references("id").inTable("monitor_alerts_config").onDelete("CASCADE");
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
});
}
// Step 2: Add monitor_tag column to monitor_alerts_v2 for per-monitor alert tracking
const hasV2Column = await knex.schema.hasColumn("monitor_alerts_v2", "monitor_tag");
if (!hasV2Column) {
await knex.schema.alterTable("monitor_alerts_v2", (table) => {
table.string("monitor_tag", 255).nullable();
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
});
}
// Step 3: Migrate existing data from monitor_alerts_config.monitor_tag to junction table
// and backfill monitor_alerts_v2.monitor_tag from the same source
const existingConfigs = await knex("monitor_alerts_config").whereNotNull("monitor_tag").select("id", "monitor_tag");
if (existingConfigs.length > 0) {
// Build a config_id -> monitor_tag map for backfilling alerts
const configTagMap = new Map<number, string>();
const inserts = existingConfigs.map((config: { id: number; monitor_tag: string }) => {
configTagMap.set(config.id, config.monitor_tag);
return {
monitor_alerts_id: config.id,
monitor_tag: config.monitor_tag,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
};
});
// Batch insert into junction table
const chunkSize = 100;
for (let i = 0; i < inserts.length; i += chunkSize) {
await knex("monitor_alerts_config_monitors").insert(inserts.slice(i, i + chunkSize));
}
// Backfill monitor_tag on existing monitor_alerts_v2 rows
const existingAlerts = await knex("monitor_alerts_v2").whereNull("monitor_tag").select("id", "config_id");
for (const alert of existingAlerts) {
const tag = configTagMap.get(alert.config_id);
if (tag) {
await knex("monitor_alerts_v2").where({ id: alert.id }).update({ monitor_tag: tag });
}
}
}
// Step 4: Drop foreign key and make monitor_tag nullable on monitor_alerts_config
const dbClient = knex.client.config.client;
if (dbClient === "sqlite3" || dbClient === "better-sqlite3") {
// SQLite cannot ALTER COLUMN, so we rebuild the table with monitor_tag nullable
await knex.transaction(async (trx) => {
await trx.raw("PRAGMA foreign_keys = OFF");
await trx.raw("DROP TABLE IF EXISTS monitor_alerts_config_new");
await trx.raw(`
CREATE TABLE monitor_alerts_config_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_tag VARCHAR(255),
alert_for VARCHAR(50) NOT NULL,
alert_value VARCHAR(255) NOT NULL,
failure_threshold INTEGER NOT NULL DEFAULT 1,
success_threshold INTEGER NOT NULL DEFAULT 1,
alert_description TEXT,
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await trx.raw(`
INSERT INTO monitor_alerts_config_new
(id, monitor_tag, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at)
SELECT
id, NULL, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at
FROM monitor_alerts_config
`);
await trx.raw("DROP TABLE monitor_alerts_config");
await trx.raw("ALTER TABLE monitor_alerts_config_new RENAME TO monitor_alerts_config");
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
} catch (_e) {
/* index may already exist */
}
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
} catch (_e) {
/* index may already exist */
}
await trx.raw("PRAGMA foreign_keys = ON");
});
} else {
try {
await knex.schema.alterTable("monitor_alerts_config", (table) => {
table.dropForeign(["monitor_tag"]);
});
} catch (_e) {
// Foreign key may not exist or already dropped
}
await knex.schema.alterTable("monitor_alerts_config", (table) => {
table.string("monitor_tag", 255).nullable().alter();
});
await knex("monitor_alerts_config").update({ monitor_tag: null });
}
// Step 5: Add composite index on monitor_alerts_v2 for fast lookups
try {
await knex.raw(
"CREATE INDEX idx_monitor_alerts_v2_config_tag_status ON monitor_alerts_v2 (config_id, monitor_tag, alert_status)",
);
} catch (_e) {
/* index already exists */
}
}
export async function down(knex: Knex): Promise<void> {
// Step 1: Drop the composite index on monitor_alerts_v2
try {
await knex.raw("DROP INDEX IF EXISTS idx_monitor_alerts_v2_config_tag_status");
} catch (_e) {
/* index may not exist */
}
// Step 2: Copy first monitor_tag from junction table back to monitor_alerts_config
const configs = await knex("monitor_alerts_config").select("id");
for (const config of configs) {
const firstMonitor = await knex("monitor_alerts_config_monitors").where({ monitor_alerts_id: config.id }).first();
if (firstMonitor) {
await knex("monitor_alerts_config").where({ id: config.id }).update({ monitor_tag: firstMonitor.monitor_tag });
}
}
// Step 3: Delete configs that have no monitors (can't satisfy NOT NULL)
await knex("monitor_alerts_config").whereNull("monitor_tag").del();
// Step 4: Re-add foreign key constraint on monitor_alerts_config (non-SQLite only)
const dbClient = knex.client.config.client;
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
await knex.schema.alterTable("monitor_alerts_config", (table) => {
table.string("monitor_tag", 255).notNullable().alter();
table.foreign("monitor_tag").references("tag").inTable("monitors").onDelete("CASCADE");
});
}
// Step 5: Drop monitor_tag column from monitor_alerts_v2 (non-SQLite only)
const hasV2Column = await knex.schema.hasColumn("monitor_alerts_v2", "monitor_tag");
if (hasV2Column) {
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
await knex.schema.alterTable("monitor_alerts_v2", (table) => {
table.dropForeign(["monitor_tag"]);
table.dropColumn("monitor_tag");
});
}
}
// Step 6: Drop junction table
await knex.schema.dropTableIfExists("monitor_alerts_config_monitors");
}
@@ -0,0 +1,144 @@
/**
* Migration: Fix SQLite monitor_alerts_config.monitor_tag NOT NULL constraint
*
* The earlier migration 20260325120000_multi_monitor_alerts nulled out data in
* monitor_alerts_config.monitor_tag for SQLite but could not alter the column
* constraint (SQLite doesn't support ALTER COLUMN). This migration recreates
* the table with monitor_tag as nullable, preserving all data and indexes.
*
* Only runs on SQLite/better-sqlite3; other databases already had the column
* altered in the previous migration.
*/
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
const dbClient = knex.client.config.client;
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
return; // Already handled by 20260325120000_multi_monitor_alerts
}
// Check if monitor_tag is already nullable (migration 1 already rebuilt the table)
const tableInfo: Array<{ name: string; notnull: number }> = await knex.raw(
"PRAGMA table_info(monitor_alerts_config)",
);
const monitorTagCol = tableInfo.find((col) => col.name === "monitor_tag");
if (monitorTagCol && monitorTagCol.notnull === 0) {
return; // Column is already nullable, nothing to do
}
// Column is still NOT NULL — rebuild the table to make it nullable
try {
await knex.transaction(async (trx) => {
await trx.raw("PRAGMA foreign_keys = OFF");
await trx.raw("DROP TABLE IF EXISTS monitor_alerts_config_new");
await trx.raw(`
CREATE TABLE monitor_alerts_config_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_tag VARCHAR(255),
alert_for VARCHAR(50) NOT NULL,
alert_value VARCHAR(255) NOT NULL,
failure_threshold INTEGER NOT NULL DEFAULT 1,
success_threshold INTEGER NOT NULL DEFAULT 1,
alert_description TEXT,
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await trx.raw(`
INSERT INTO monitor_alerts_config_new
(id, monitor_tag, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at)
SELECT
id, NULL, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at
FROM monitor_alerts_config
`);
await trx.raw("DROP TABLE monitor_alerts_config");
await trx.raw("ALTER TABLE monitor_alerts_config_new RENAME TO monitor_alerts_config");
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
} catch (_e) {
/* index may already exist */
}
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
} catch (_e) {
/* index may already exist */
}
await trx.raw("PRAGMA foreign_keys = ON");
});
} catch (e) {
await knex.raw("PRAGMA foreign_keys = ON");
throw e;
}
}
export async function down(knex: Knex): Promise<void> {
const dbClient = knex.client.config.client;
if (dbClient !== "sqlite3" && dbClient !== "better-sqlite3") {
return;
}
// Revert: make monitor_tag NOT NULL again via table rebuild
try {
await knex.transaction(async (trx) => {
await trx.raw("PRAGMA foreign_keys = OFF");
await trx.raw(`
CREATE TABLE monitor_alerts_config_old (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_tag VARCHAR(255) NOT NULL,
alert_for VARCHAR(50) NOT NULL,
alert_value VARCHAR(255) NOT NULL,
failure_threshold INTEGER NOT NULL DEFAULT 1,
success_threshold INTEGER NOT NULL DEFAULT 1,
alert_description TEXT,
create_incident VARCHAR(10) NOT NULL DEFAULT 'NO',
is_active VARCHAR(10) NOT NULL DEFAULT 'YES',
severity VARCHAR(50) NOT NULL DEFAULT 'WARNING',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Only copy rows that have a non-null monitor_tag
await trx.raw(`
INSERT INTO monitor_alerts_config_old
(id, monitor_tag, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at)
SELECT
id, monitor_tag, alert_for, alert_value, failure_threshold,
success_threshold, alert_description, create_incident,
is_active, severity, created_at, updated_at
FROM monitor_alerts_config
WHERE monitor_tag IS NOT NULL
`);
await trx.raw("DROP TABLE monitor_alerts_config");
await trx.raw("ALTER TABLE monitor_alerts_config_old RENAME TO monitor_alerts_config");
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_monitor_tag ON monitor_alerts_config (monitor_tag)");
} catch (_e) {
/* index may already exist */
}
try {
await trx.raw("CREATE INDEX idx_monitor_alerts_config_is_active ON monitor_alerts_config (is_active)");
} catch (_e) {
/* index may already exist */
}
await trx.raw("PRAGMA foreign_keys = ON");
});
} catch (e) {
await knex.raw("PRAGMA foreign_keys = ON");
throw e;
}
}
+270 -221
View File
@@ -1,12 +1,12 @@
{
"name": "kener",
"version": "4.0.18",
"version": "4.0.21",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kener",
"version": "4.0.18",
"version": "4.0.21",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
@@ -51,6 +51,7 @@
"front-matter": "^4.0.2",
"gamedig": "^5.3.2",
"glob": "^13.0.6",
"heic-convert": "^2.1.0",
"highlight.js": "^11.11.1",
"ioredis": "^5.8.2",
"js-yaml": "^4.1.1",
@@ -88,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",
@@ -97,6 +98,7 @@
"@types/d3-shape": "^3.1.8",
"@types/dns2": "^2.0.10",
"@types/express": "^5.0.6",
"@types/heic-convert": "^2.1.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/mustache": "^4.2.6",
"@types/node": "^25.0.3",
@@ -112,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",
@@ -1790,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"
],
@@ -1803,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"
],
@@ -1816,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"
],
@@ -1829,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"
],
@@ -1842,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"
],
@@ -1855,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"
],
@@ -1868,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"
],
@@ -1881,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"
],
@@ -1894,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"
],
@@ -1907,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"
],
@@ -1920,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"
],
@@ -1933,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"
],
@@ -1946,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"
],
@@ -1959,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"
],
@@ -1972,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"
],
@@ -1985,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"
],
@@ -1998,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"
],
@@ -2011,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"
],
@@ -2024,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"
],
@@ -2037,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"
],
@@ -2050,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"
],
@@ -2063,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"
],
@@ -2076,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"
],
@@ -2089,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"
],
@@ -2102,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"
],
@@ -2260,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",
@@ -2270,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"
},
@@ -2287,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": {
@@ -2756,6 +2757,13 @@
"@types/send": "*"
}
},
"node_modules/@types/heic-convert": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/heic-convert/-/heic-convert-2.1.0.tgz",
"integrity": "sha512-Cf5Sdc2Gm2pfZ0uN1zjj35wcf3mF1lJCMIzws5OdJynrdMJRTIRUGa5LegbVg0hatzOPkH2uAf2JRjPYgl9apg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -2859,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",
@@ -2975,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"
@@ -3133,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",
@@ -3325,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": {
@@ -3746,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",
@@ -3800,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"
@@ -4529,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": {
@@ -4582,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"
@@ -5055,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",
@@ -5082,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"
@@ -5568,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",
@@ -5760,6 +5738,32 @@
"he": "bin/he"
}
},
"node_modules/heic-convert": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/heic-convert/-/heic-convert-2.1.0.tgz",
"integrity": "sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==",
"license": "ISC",
"dependencies": {
"heic-decode": "^2.0.0",
"jpeg-js": "^0.4.4",
"pngjs": "^6.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/heic-decode": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
"integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==",
"license": "ISC",
"dependencies": {
"libheif-js": "^1.19.8"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
@@ -6399,6 +6403,12 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jpeg-js": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"license": "BSD-3-Clause"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@@ -6638,6 +6648,15 @@
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
"license": "MIT"
},
"node_modules/libheif-js": {
"version": "1.19.8",
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.19.8.tgz",
"integrity": "sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==",
"license": "LGPL-3.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/libmime": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
@@ -7101,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"
@@ -7135,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"
@@ -7308,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": {
@@ -7416,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"
@@ -8040,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",
@@ -8253,6 +8291,15 @@
"node": ">=22.0.0"
}
},
"node_modules/pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
"license": "MIT",
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -8920,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"
@@ -8935,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"
}
},
@@ -9033,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"
@@ -9790,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",
@@ -9845,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",
@@ -10389,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"
+15 -3
View File
@@ -1,6 +1,6 @@
{
"name": "kener",
"version": "4.0.18",
"version": "4.0.21",
"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",
@@ -64,6 +64,7 @@
"@types/d3-shape": "^3.1.8",
"@types/dns2": "^2.0.10",
"@types/express": "^5.0.6",
"@types/heic-convert": "^2.1.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/mustache": "^4.2.6",
"@types/node": "^25.0.3",
@@ -79,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",
@@ -139,6 +140,7 @@
"front-matter": "^4.0.2",
"gamedig": "^5.3.2",
"glob": "^13.0.6",
"heic-convert": "^2.1.0",
"highlight.js": "^11.11.1",
"ioredis": "^5.8.2",
"js-yaml": "^4.1.1",
@@ -170,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"
}
}
+82 -74
View File
@@ -1,6 +1,6 @@
import { handler } from "../build/handler.js";
import dotenv from "dotenv";
dotenv.config();
import express from "express";
import Startup from "../src/lib/server/startup.ts";
import shutdownSchedulers from "../src/lib/server/schedulers/shutdown.ts";
@@ -12,82 +12,90 @@ import knexOb from "../knexfile.js";
const PORT = process.env.PORT || 3000;
const base = process.env.KENER_BASE_PATH || "";
const app: any = express();
const db = knex(knexOb);
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");
app.get(base + "/healthcheck", (req: any, res: any) => {
res.end("ok");
});
const app: any = express();
const db = knex(knexOb);
app.use(handler);
app.get(base + "/healthcheck", (req: any, res: any) => {
res.end("ok");
});
//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}`);
}
}
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);
}
}
//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);
}
}
app.listen(PORT, async () => {
await runMigrations();
await runSeed();
await db.destroy();
Startup();
console.log("Kener is running on port " + PORT + "!");
});
// Graceful shutdown handler
async function gracefulShutdown(signal: string) {
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
try {
console.log("Shutting down schedulers...");
await shutdownSchedulers();
console.log("Schedulers shut down successfully.");
console.log("Shutting down queues...");
await shutdownQueues();
console.log("Queues shut down successfully.");
console.log("Closing database connection...");
await dbInstance.close();
console.log("Database connection closed successfully.");
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"));
}
//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);
}
}
app.listen(PORT, async () => {
await runMigrations();
await runSeed();
await db.destroy();
Startup();
console.log("Kener is running on port " + PORT + "!");
});
// Graceful shutdown handler
async function gracefulShutdown(signal: string) {
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
try {
console.log("Shutting down schedulers...");
await shutdownSchedulers();
console.log("Schedulers shut down successfully.");
console.log("Shutting down queues...");
await shutdownQueues();
console.log("Queues shut down successfully.");
console.log("Closing database connection...");
await dbInstance.close();
console.log("Database connection closed successfully.");
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();
@@ -41,7 +41,7 @@
</Drawer.Trigger>
<Drawer.Content class="max-h-[80vh]">
<Drawer.Header>
<Drawer.Title>{$t("Included Monitors")}</Drawer.Title>
<Drawer.Title>{$t("Included Monitors (%count)", { count: String(tags.length) })}</Drawer.Title>
</Drawer.Header>
<div class="scrollbar-hidden flex flex-col overflow-y-auto px-4 pb-4">
{#if tags.length === 0}
+1 -2
View File
@@ -152,8 +152,7 @@
days={days as number}
endOfDayTodayAtTz={endOfDayTodayAtTz as number}
>
{groupChildTags.length}
{$t("Included Monitors")}
{$t("Included Monitors (%count)", { count: String(groupChildTags.length) })}
</GroupMonitorPopover>
</div>
{/if}
+1 -1
View File
@@ -219,7 +219,7 @@
{#if groupTags.length > 0}
<div class="flex justify-center">
<GroupMonitorPopover tags={groupTags} days={selectedDays} {endOfDayTodayAtTz}>
{$t("Included Monitors")} ({groupTags.length})
{$t("Included Monitors (%count)", { count: String(groupTags.length) })}
<ArrowUp class="size-3" />
</GroupMonitorPopover>
</div>
+30 -29
View File
@@ -22,39 +22,39 @@
"Day": "Den",
"Day Uptime": "Denní dostupnost",
"Days": "Dní",
"Degraded": "Zhoršený",
"DEGRADED": "ZHORŠENÝ",
"Degraded Performance": "Zhoršený výkon",
"Didn't receive the code? Resend": "Nepřišel vám kód? Odeslat znovu",
"Down": "Nedostupný",
"DOWN": "VÝPADEK",
"Degraded": "Omezený",
"DEGRADED": "OMEZENÝ",
"Degraded Performance": "Snížený výkon",
"Didn't receive the code? Resend": "Nepřišel kód? Odeslat znovu",
"Down": "Nedostupné",
"DOWN": "NEDOSTUPNÉ",
"Duration": "Trvání",
"Edit Monitor": "Upravit monitor",
"Email address": "E-mailová adresa",
"Embed Monitor": "Vložit monitor",
"Embed this monitor in your website or app": "Vložte tento monitor na svůj web nebo do aplikace",
"Embed this monitor in your website or app": "Vložte tento monitor na web nebo do aplikace",
"End Time": "Konec",
"Enter the verification code sent to your email.": "Zadejte ověřovací kód zaslaný na váš e-mail.",
"Enter the verification code sent to your email.": "Zadejte ověřovací kód zaslaný na e-mail",
"Events": "Události",
"Failed to load data": "Nepodařilo se načíst data",
"Failed to load latency data": "Nepodařilo se načíst data latence",
"Failed to load status data for this day": "Nepodařilo se načíst data stavu pro tento den",
"Failed to send verification code": "Nepodařilo se odeslat ověřovací kód",
"Failed to update preference": "Nepodařilo se aktualizovat nastavení",
"Failed to update preference": "Nepodařilo se uložit nastavení",
"Format": "Formát",
"Get badges for this monitor": "Získat odznaky pro tento monitor",
"Get notified about incidents and scheduled maintenance.": "Dostávejte upozornění na incidenty a plánovanou údržbu.",
"Get notified about incidents and scheduled maintenance.": "Dostávejte upozornění na incidenty a plánovanou údržbu",
"Get notified about incidents updates": "Dostávejte upozornění na aktualizace incidentů",
"Get notified about scheduled maintenance": "Dostávejte upozornění na plánovanou údržbu",
"Home": "Domů",
"IDENTIFIED": "IDENTIFIKOVÁNO",
"iFrame": "iFrame",
"Impact": "Dopad",
"incident": "Incident",
"incident": "incident",
"Incident": "Incident",
"Incident Updates": "Aktualizace incidentů",
"Incidents": "Incidenty",
"Included Monitors": "Monitorů zahrnuto",
"Included Monitors (%count)": "Zahrnuté monitory (%count)",
"INVESTIGATING": "VYŠETŘOVÁNÍ",
"Last Updated": "Naposledy aktualizováno",
"Latency": "Latence",
@@ -62,18 +62,18 @@
"Latency Over Time": "Latence v čase",
"Latency Trend": "Trend latence",
"Latest Latency": "Poslední latence",
"Latest Status": "Posled stav",
"Latest Status": "Naposledy zjištěný stav",
"Light": "Světlý",
"Live Status": "Aktuální stav",
"Loading your preferences...": "Načítám vaše nastavení...",
"maintenance": "Údržba",
"Loading your preferences...": "Načítá nastavení...",
"maintenance": "údržba",
"Maintenance": "Údržba",
"MAINTENANCE": "ÚDRŽBA",
"Maintenance Updates": "Aktualizace údržby",
"Maintenances": "Údrždy",
"Maintenances": "Údržby",
"Major System Outage": "Závažný výpadek systému",
"Manage Site": "Spravovat stránku",
"Manage your notification preferences.": "Spravujte svá nastavení oznámení.",
"Manage your notification preferences.": "Spravujte nastavení oznámení",
"Max Latency": "Max. latence",
"maximum": "maximální",
"Maximum Latency": "Maximální latence",
@@ -82,33 +82,34 @@
"Minimum Latency": "Minimální latence",
"Minute-by-minute status data for this day": "Minutová data stavu pro tento den",
"MONITORING": "MONITOROVÁNÍ",
"Network error. Please try again.": "Chyba sítě. Zkuste to prosím znovu.",
"Network error. Please try again.": "Chyba sítě. Zkuste to znovu",
"No Events in %currentMonth": "V měsíci %currentMonth nejsou plánované žádné události",
"No events to show": "Žádné události k zobrazení",
"No incidents for this day": "Pro tento den nejsou evidovány žádné incidenty",
"No latency data available for this day": "Pro tento den nejsou k dispozici data latence",
"No latency data available for this day": "Pro tento den nejsou dostupná data latence",
"No maintenances for this day": "Pro tento den není naplánovaná žádná údržba",
"No monitors affected": "Žádné zasažené monitory",
"No monitors available.": "Žádné dostupné monitory.",
"No monitors available.": "Žádné dostupné monitory",
"No ongoing maintenances": "Žádná probíhající údržba",
"No past maintenances": "Žádná minulá údržba",
"No Status Available": "Stav není k dispozici",
"No upcoming maintenances": "Žádná nadcházející údržba",
"No Updates": "Žádné aktualizace",
"No updates yet": "Zatím bez aktualizací",
"No updates yet": "Zatím žádné aktualizace",
"NO_DATA": "Žádná data",
"Notifications": "Oznámení",
"One-time": "Jednorázově",
"Ongoing": "Probíhající",
"ONGOING": "PROBÍHAJÍCÍ",
"Ongoing Maintenances": "Probíhající údržby",
"Operational": "V provozu",
"Partial Degraded Performance": "Částečně zhoršený výkon",
"Partial Degraded Performance": "Částečně omezený provoz",
"Partial System Outage": "Částečný výpadek systému",
"Past": "Minulé",
"Per-Minute Status": "Stav po minutách",
"Pinging": "Pingování",
"Please enter a valid email address": "Zadejte platnou e-mailovou adresu",
"Please enter the 6-digit verification code": "Zadejte prosím 6místný ověřovací kód",
"Please enter the 6-digit verification code": "Zadejte 6místný ověřovací kód",
"Read less": "Zobrazit méně",
"Read more": "Zobrazit více",
"READY": "PŘIPRAVENO",
@@ -120,20 +121,20 @@
"Scheduled Windows": "Naplánované úlohy",
"Script": "Skript",
"Select Language": "Vyberte jazyk",
"Select latency metric to display": "Vyberte metriku latence k zobrazení",
"Select latency metric to display": "Vyberte metriku latence",
"Select Range": "Vyberte rozsah",
"Sending...": "Odesílání...",
"Standard": "Standardní",
"Start Time": "Začátek",
"Status": "Stav",
"Status Badge": "Odznak stavu",
"Status Embed": "Status pro vložení",
"Status Embed": "Stav pro vložení",
"Status history and latency trend": "Historie stavu a trend latence",
"Subscribe": "Odebírat",
"Subscribe": "Přihlásit se k odběru",
"Subscribe to Updates": "Odebírat aktualizace",
"Theme": "Motiv",
"There are no incidents or maintenances scheduled for this month.": "Na tento měsíc nejsou naplánované incidenty ani údržba.",
"There are no ongoing incidents or maintenance events.": "V tuto chvíli neprobíhají žádné incidenty ani údržba.",
"There are no incidents or maintenances scheduled for this month.": "Na tento měsíc nejsou naplánované žádné incidenty ani údržby",
"There are no ongoing incidents or maintenance events.": "V tuto chvíli neprobíhají žádné incidenty ani údržba",
"Timeline": "Časová osa",
"Total Incidents": "Celkem incidentů",
"Total Maintenances": "Celkem údržeb",
@@ -151,6 +152,6 @@
"Verification failed": "Ověření se nezdařilo",
"Verify": "Ověřit",
"Verifying": "Ověřování",
"We sent a 6-digit code to": "Poslali jsme 6místný kód na"
"We sent a 6-digit code to": "Odeslali jsme 6místný kód na"
}
}
+1 -1
View File
@@ -45,7 +45,7 @@
"incident": "Vorfall",
"Incident Updates": "Vorfallaktualisierungen",
"Incidents": "Vorfälle",
"Included Monitors": "Enthaltene Monitore",
"Included Monitors (%count)": "Enthaltene Monitore (%count)",
"INVESTIGATING": "WIRD UNTERSUCHT",
"Last Updated": "Zuletzt aktualisiert",
"Latency": "Latenz",
+1 -1
View File
@@ -45,7 +45,7 @@
"incident": "Hændelse",
"Incident Updates": "Hændelsesopdateringer",
"Incidents": "Hændelser",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Sidst opdateret",
"Latency": "Latency",
+1 -1
View File
@@ -45,7 +45,7 @@
"incident": "Incident",
"Incident Updates": "Incident Updates",
"Incidents": "Incidents",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Last Updated",
"Latency": "Latency",
+1 -1
View File
@@ -45,7 +45,7 @@
"incident": "Incidente",
"Incident Updates": "Actualizaciones de incidentes",
"Incidents": "Incidentes",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Última actualización",
"Latency": "Estado latente",
+1 -1
View File
@@ -45,7 +45,7 @@
"incident": "رخداد",
"Incident Updates": "به روز رسانی حادثه",
"Incidents": "حوادث",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "آخرین به روز رسانی",
"Latency": "تأخیر",
+1 -1
View File
@@ -45,7 +45,7 @@
"incident": "Incident",
"Incident Updates": "Mises à jour des incidents",
"Incidents": "Incidents",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Dernière mise à jour",
"Latency": "Latence",
+1 -1
View File
@@ -45,7 +45,7 @@
"incident": "घटना",
"Incident Updates": "घटना अपडेट",
"Incidents": "घटनाएँ",
"Included Monitors": "शामिल मॉनिटर",
"Included Monitors (%count)": "शामिल मॉनिटर (%count)",
"INVESTIGATING": "जाँच कर रहे हैं",
"Last Updated": "अंतिम अपडेट",
"Latency": "लेटेंसी",
+1 -1
View File
@@ -45,7 +45,7 @@
"incident": "Incidente",
"Incident Updates": "Aggiornamenti sugli incidenti",
"Incidents": "Incidenti",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Ultimo aggiornamento",
"Latency": "Latenza",
+1 -1
View File
@@ -45,7 +45,7 @@
"incident": "インシデント",
"Incident Updates": "インシデントの最新情報",
"Incidents": "事件",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "最終更新日",
"Latency": "レイテンシー",
+1 -1
View File
@@ -45,7 +45,7 @@
"incident": "인시던트",
"Incident Updates": "사고 업데이트",
"Incidents": "사건",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "마지막 업데이트",
"Latency": "숨어 있음",
+1 -1
View File
@@ -45,7 +45,7 @@
"incident": "Hendelse",
"Incident Updates": "Hendelsesoppdateringer",
"Incidents": "Hendelser",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Sist oppdatert",
"Latency": "Latens",
+1 -1
View File
@@ -45,7 +45,7 @@
"incident": "Incident",
"Incident Updates": "Incidentupdates",
"Incidents": "Incidenten",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Laatst bijgewerkt",
"Latency": "Latentie",
+1 -1
View File
@@ -45,7 +45,7 @@
"incident": "Incydent",
"Incident Updates": "Aktualizacje incydentów",
"Incidents": "Incydenty",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Ostatnia aktualizacja",
"Latency": "Utajenie",
+1 -1
View File
@@ -45,7 +45,7 @@
"incident": "Incidente",
"Incident Updates": "Atualizações de incidentes",
"Incidents": "Incidentes",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Última atualização",
"Latency": "Latência",
+1 -1
View File
@@ -46,7 +46,7 @@
"incident": "Инцидент",
"Incident Updates": "Обновления инцидентов",
"Incidents": "Инциденты",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Последнее обновление",
"Latency": "Задержка",
+35 -34
View File
@@ -6,7 +6,7 @@
"30 Days": "30 dní",
"7 Days": "7 dní",
"90 Days": "90 dní",
"Affected Monitors (%count)": "Ovládacie monitory (%count)",
"Affected Monitors (%count)": "Ovplyvnené monitory (%count)",
"All Systems Operational": "Všetky systémy sú v prevádzke",
"average": "priemerná",
"Average Latency": "Priemerná latencia",
@@ -22,58 +22,58 @@
"Day": "Deň",
"Day Uptime": "Denná dostupnosť",
"Days": "Dní",
"Degraded": "Zhoršený",
"DEGRADED": "ZHORŠENÝ",
"Degraded Performance": "Zhoršený výkon",
"Didn't receive the code? Resend": "Nedostali ste kód? Odoslať znova",
"Down": "Nedostupný",
"DOWN": "VÝPADOK",
"Degraded": "Obmedzený",
"DEGRADED": "OBMEDZENÝ",
"Degraded Performance": "Znížený výkon",
"Didn't receive the code? Resend": "Neprišiel kód? Odoslať znova",
"Down": "Nedostupné",
"DOWN": "NEDOSTUPNÉ",
"Duration": "Trvanie",
"Edit Monitor": "Upraviť monitor",
"Email address": "E-mailová adresa",
"Embed Monitor": "Vložiť monitor",
"Embed this monitor in your website or app": "Vložte tento monitor na svoj web alebo do aplikácie",
"Embed this monitor in your website or app": "Vložte tento monitor na web alebo do aplikácie",
"End Time": "Koniec",
"Enter the verification code sent to your email.": "Zadajte overovací kód zaslaný na váš e-mail.",
"Enter the verification code sent to your email.": "Zadajte overovací kód zaslaný na e-mail",
"Events": "Udalosti",
"Failed to load data": "Nepodarilo sa načítať dáta",
"Failed to load latency data": "Nepodarilo sa načítať dáta latencie",
"Failed to load status data for this day": "Nepodarilo sa načítať dáta stavu pre tento deň",
"Failed to load status data for this day": "Nepodarilo sa načítať stavové dáta pre tento deň",
"Failed to send verification code": "Nepodarilo sa odoslať overovací kód",
"Failed to update preference": "Nepodarilo sa aktualizovať nastavenia",
"Failed to update preference": "Nepodarilo sa uložiť nastavenie",
"Format": "Formát",
"Get badges for this monitor": "Získať odznaky pre tento monitor",
"Get notified about incidents and scheduled maintenance.": "Dostávajte upozornenia na incidenty a plánovanú údržbu.",
"Get notified about incidents and scheduled maintenance.": "Dostávajte upozornenia na incidenty a plánovanú údržbu",
"Get notified about incidents updates": "Dostávajte upozornenia na aktualizácie incidentov",
"Get notified about scheduled maintenance": "Dostávajte upozornenia na plánovanú údržbu",
"Home": "Domov",
"IDENTIFIED": "IDENTIFIKOVANÉ",
"iFrame": "iFrame",
"Impact": "Dopad",
"incident": "Incident",
"incident": "incident",
"Incident": "Incident",
"Incident Updates": "Aktualizácie incidentov",
"Incidents": "Incidenty",
"Included Monitors": "Monitorov zahrnuté",
"INVESTIGATING": "VYŠETROVANIE",
"Included Monitors (%count)": "Zahrnuté monitory (%count)",
"INVESTIGATING": "PREBIEHA VYŠETROVANIE",
"Last Updated": "Naposledy aktualizované",
"Latency": "Latencia",
"Latency Embed": "Vložená latencia",
"Latency Over Time": "Latencia v čase",
"Latency Trend": "Trend latencie",
"Latest Latency": "Posledná latencia",
"Latest Status": "Posledný stav",
"Latest Status": "Naposledy zistený stav",
"Light": "Svetlý",
"Live Status": "Aktuálny stav",
"Loading your preferences...": "Načítavam vaše nastavenia...",
"maintenance": "Údržba",
"Loading your preferences...": "Načítavanie nastavení...",
"maintenance": "údržba",
"Maintenance": "Údržba",
"MAINTENANCE": "ÚDRŽBA",
"Maintenance Updates": "Aktualizácie údržby",
"Maintenances": "Údržby",
"Major System Outage": "Závažný výpadok systému",
"Manage Site": "Spravovať stránku",
"Manage your notification preferences.": "Spravujte svoje nastavenia oznámení.",
"Manage your notification preferences.": "Spravujte nastavenia upozornení",
"Max Latency": "Max. latencia",
"maximum": "maximálna",
"Maximum Latency": "Maximálna latencia",
@@ -82,27 +82,28 @@
"Minimum Latency": "Minimálna latencia",
"Minute-by-minute status data for this day": "Minútové dáta stavu pre tento deň",
"MONITORING": "MONITOROVANIE",
"Network error. Please try again.": "Chyba siete. Skúste to znova.",
"No Events in %currentMonth": "V mesiaci %currentMonth nie sú plánované žiadne udalosti",
"Network error. Please try again.": "Chyba siete. Skúste to znova",
"No Events in %currentMonth": "V mesiaci %currentMonth nie sú žiadne udalosti",
"No events to show": "Žiadne udalosti na zobrazenie",
"No incidents for this day": "Pre tento deň nie sú evidované žiadne incidenty",
"No latency data available for this day": "Pre tento deň nie sú k dispozícii dáta latencie",
"No latency data available for this day": "Pre tento deň nie sú dostupné dáta latencie",
"No maintenances for this day": "Pre tento deň nie je naplánovaná žiadna údržba",
"No monitors affected": "Žiadne zasiahnuté monitory",
"No monitors available.": "Žiadne dostupné monitory.",
"No monitors affected": "Žiadne ovplyvnené monitory",
"No monitors available.": "Žiadne dostupné monitory",
"No ongoing maintenances": "Žiadna prebiehajúca údržba",
"No past maintenances": "Žiadna minula údržba",
"No past maintenances": "Žiadna minulá údržba",
"No Status Available": "Stav nie je k dispozícii",
"No upcoming maintenances": "Žiadna nadchádzajúca údržba",
"No Updates": "Žiadne aktualizácie",
"No updates yet": "Zatiaľ bez aktualizácií",
"No updates yet": "Zatiaľ žiadne aktualizácie",
"NO_DATA": "Žiadne dáta",
"Notifications": "Oznámenia",
"Notifications": "Upozornenia",
"One-time": "Jednorazovo",
"Ongoing": "Prebiehajúce",
"ONGOING": "PREBIEHAJÚCE",
"Ongoing Maintenances": "Prebiehajúce údržby",
"Operational": "V prevádzke",
"Partial Degraded Performance": "Čiastočne zhoršený výkon",
"Partial Degraded Performance": "Čiastočne obmedzený výkon",
"Partial System Outage": "Čiastočný výpadok systému",
"Past": "Minulé",
"Per-Minute Status": "Stav po minútach",
@@ -120,20 +121,20 @@
"Scheduled Windows": "Naplánované úlohy",
"Script": "Skript",
"Select Language": "Vyberte jazyk",
"Select latency metric to display": "Vyberte metriku latencie na zobrazenie",
"Select latency metric to display": "Vyberte metriku latencie",
"Select Range": "Vyberte rozsah",
"Sending...": "Odosielanie...",
"Standard": "Štandardný",
"Start Time": "Začiatok",
"Status": "Stav",
"Status Badge": "Odznak stavu",
"Status Embed": "Status na vloženie",
"Status Embed": "Stav na vloženie",
"Status history and latency trend": "História stavu a trend latencie",
"Subscribe": "Odober",
"Subscribe": "Prihlásiť sa na odber",
"Subscribe to Updates": "Odoberať aktualizácie",
"Theme": "Motív",
"There are no incidents or maintenances scheduled for this month.": "Na tento mesiac nie sú naplánované incidenty ani údržba.",
"There are no ongoing incidents or maintenance events.": "V tejto chvíli neprebiehajú žiadne incidenty ani údržba.",
"There are no incidents or maintenances scheduled for this month.": "Na tento mesiac nie sú naplánované žiadne incidenty ani údržby",
"There are no ongoing incidents or maintenance events.": "Momentálne neprebiehajú žiadne incidenty ani údržba",
"Timeline": "Časová os",
"Total Incidents": "Celkom incidentov",
"Total Maintenances": "Celkom údržieb",
@@ -151,6 +152,6 @@
"Verification failed": "Overenie zlyhalo",
"Verify": "Overiť",
"Verifying": "Overovanie",
"We sent a 6-digit code to": "Poslali sme 6-miestny kód na"
"We sent a 6-digit code to": "Odoslali sme 6-miestny kód na"
}
}
+1 -1
View File
@@ -45,7 +45,7 @@
"incident": "Olay",
"Incident Updates": "Olay Güncellemeleri",
"Incidents": "Olaylar",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Son Güncelleme",
"Latency": "Gecikme",
+1 -1
View File
@@ -45,7 +45,7 @@
"incident": "Sự cố",
"Incident Updates": "Cập nhật sự cố",
"Incidents": "Sự cố",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "Cập nhật lần cuối",
"Latency": "Độ trễ",
+1 -1
View File
@@ -45,7 +45,7 @@
"incident": "事件",
"Incident Updates": "事件更新",
"Incidents": "事件",
"Included Monitors": "Included Monitors",
"Included Monitors (%count)": "Included Monitors (%count)",
"INVESTIGATING": "INVESTIGATING",
"Last Updated": "最后更新",
"Latency": "延迟",
+1 -1
View File
@@ -43,7 +43,7 @@
"Impact": "影響",
"Incident Updates": "事件更新",
"Incidents": "事件",
"Included Monitors": "包含的監控項目",
"Included Monitors (%count)": "包含的監控項目 (%count)",
"INVESTIGATING": "調查中",
"Last Updated": "最後更新",
"Latency": "延遲",
@@ -159,6 +159,7 @@ export interface PageDashboardData {
pageStatus: { statusSummary: string; statusClass: string };
ongoingIncidents: IncidentForMonitorListWithComments[];
ongoingMaintenances: MaintenanceEventsMonitorList[];
upcomingMaintenances: MaintenanceEventsMonitorList[];
monitorTags: string[];
monitorGroupMembersByTag: Record<string, string[]>;
pageDetails: PageRecordTyped;
@@ -366,6 +367,7 @@ export const GetPageDashboardData = async (
pageStatus: BuildPageStatus([], nowTs),
ongoingIncidents: [],
ongoingMaintenances: [],
upcomingMaintenances: [],
monitorTags,
monitorGroupMembersByTag: {},
pageDetails: pageDetailsTyped,
@@ -376,7 +378,7 @@ export const GetPageDashboardData = async (
}
const eventSettings = layoutData.eventDisplaySettings;
// Fetch all dashboard data in parallel (respecting feature toggles)
const [latestData, parsedMonitors, ongoingIncidents, ongoingMaintenances] = await Promise.all([
const [latestData, parsedMonitors, ongoingIncidents, ongoingMaintenances, upcomingMaintenances] = await Promise.all([
GetLatestMonitoringDataAllActive(monitorTags),
GetMonitorsParsed({ tags: monitorTags, status: "ACTIVE", is_hidden: "NO" }),
eventSettings.incidents.enabled && eventSettings.incidents.ongoing.show
@@ -385,6 +387,13 @@ export const GetPageDashboardData = async (
eventSettings.maintenances.enabled && eventSettings.maintenances.ongoing.show
? GetOngoingMaintenances(monitorTags, nowTs)
: Promise.resolve([] as MaintenanceEventsMonitorList[]),
eventSettings.maintenances.enabled && eventSettings.maintenances.upcoming.show
? GetUpcomingMaintenanceEventsForMonitorList(
monitorTags,
eventSettings.maintenances.upcoming.maxCount,
eventSettings.maintenances.upcoming.daysInFuture,
)
: Promise.resolve([] as MaintenanceEventsMonitorList[]),
]);
const pageStatus = BuildPageStatus(latestData, nowTs);
@@ -403,6 +412,7 @@ export const GetPageDashboardData = async (
pageStatus,
ongoingIncidents,
ongoingMaintenances,
upcomingMaintenances,
monitorTags,
monitorGroupMembersByTag,
pageDetails: pageDetailsTyped,
@@ -16,6 +16,7 @@ import { maintenanceToVariables, siteDataToVariables } from "../notification/not
import { GetAllSiteData } from "./controller.js";
import subscriberQueue from "../queues/subscriberQueue.js";
import GC from "../../global-constants";
import seedSiteData from "../db/seedSiteData.js";
// ============ Input Interfaces ============
@@ -78,6 +79,7 @@ export interface MaintenanceWithEvents extends MaintenanceWithMonitors {
export function determineEventStatus(
eventStartTimestamp: number,
eventEndTimestamp: number,
reminderBufferSeconds: number = 3600,
): "SCHEDULED" | "READY" | "ONGOING" | "COMPLETED" | "CANCELLED" {
const nowTimestamp = Math.floor(Date.now() / 1000);
@@ -87,38 +89,90 @@ export function determineEventStatus(
if (nowTimestamp >= eventStartTimestamp) {
return "ONGOING";
}
// 60 minutes = 3600 seconds
if (eventStartTimestamp - nowTimestamp <= 3600) {
if (eventStartTimestamp - nowTimestamp <= reminderBufferSeconds) {
return "READY";
}
return "SCHEDULED";
}
// ============ Helper to create a maintenance event with notification ============
export const CreateMaintenanceEventWithNotification = async (
maintenance_id: number,
start_date_time: number,
end_date_time: number,
title: string,
description: string | null,
): Promise<MaintenanceEventRecord> => {
const siteData = await GetAllSiteData();
const notificationSettings =
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
const reminderBufferSeconds = notificationSettings.reminder_buffer_hours * 3600;
const event = await db.createMaintenanceEvent({
maintenance_id,
start_date_time,
end_date_time,
status: determineEventStatus(start_date_time, end_date_time, reminderBufferSeconds),
});
try {
if (notificationSettings.event_types.created) {
const siteVars = siteDataToVariables(siteData);
const siteUrl = siteVars.site_url;
const monitors = await db.getMonitorsByMaintenanceId(maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const eventDetailed: MaintenanceEventRecordDetailed = {
id: event.id,
maintenance_id,
start_date_time,
end_date_time,
status: event.status as MaintenanceEventRecordDetailed["status"],
created_at: new Date(),
updated_at: new Date(),
title,
description,
};
const update = maintenanceToVariables(
eventDetailed,
monitorNames,
"**has been created**",
"created",
"Maintenance Created",
siteUrl,
);
await subscriberQueue.push(update);
}
} catch (err) {
console.error(`Error sending created notification for maintenance event ${event.id}:`, err);
}
return event;
};
// ============ Helper to generate upcoming events from RRULE ============
/**
* Generate maintenance events for the next N days based on the RRULE
* Generate maintenance events based on the RRULE
* @param maintenance_id - The maintenance record ID
* @param start_date_time - Unix timestamp for the DTSTART
* @param rrule - The RRULE string (e.g., FREQ=WEEKLY;BYDAY=SU)
* @param duration_seconds - Duration of each maintenance window
* @param daysAhead - Number of days to look ahead (default 7)
* @param count - Maximum number of events to create (default 1)
*/
export const GenerateMaintenanceEvents = async (
maintenance_id: number,
start_date_time: number,
rrule: string,
duration_seconds: number,
daysAhead: number = 7,
count: number = 1,
): Promise<MaintenanceEventRecord[]> => {
const createdEvents: MaintenanceEventRecord[] = [];
// Convert start timestamp to Date (UTC)
const dtstart = new Date(start_date_time * 1000);
// Define the window to generate events
const now = new Date();
const windowEnd = addDays(now, daysAhead);
try {
// Build the full RRULE string with DTSTART
@@ -127,22 +181,30 @@ export const GenerateMaintenanceEvents = async (
// Parse the RRULE
const rule = rrulestr(fullRrule);
// Get occurrences between now and window end
// For one-time (COUNT=1), we use dtstart as the reference
let occurrences: Date[];
// Get occurrences based on count
let occurrences: Date[] = [];
if (rrule.includes("COUNT=1")) {
// One-time maintenance: only create event if start_date_time is in the future or within window
if (dtstart >= now || (dtstart <= windowEnd && dtstart >= addDays(now, -1))) {
// One-time maintenance: only create event if start_date_time is recent or in the future
if (dtstart >= now || dtstart >= addDays(now, -1)) {
occurrences = [dtstart];
} else {
occurrences = [];
}
} else {
// Recurring: get all occurrences in the window
occurrences = rule.between(now, windowEnd, true);
// Recurring: get the next `count` occurrences from now
let searchFrom = now;
for (let i = 0; i < count; i++) {
const next = rule.after(searchFrom, i === 0);
if (!next) break;
occurrences.push(next);
searchFrom = new Date(next.getTime() + 1000);
}
}
// Fetch maintenance info for notifications
const maintenance = await db.getMaintenanceById(maintenance_id);
const maintenanceTitle = maintenance?.title || "";
const maintenanceDescription = maintenance?.description || null;
// Create events for each occurrence
for (const occurrence of occurrences) {
const eventStart = Math.floor(occurrence.getTime() / 1000);
@@ -153,12 +215,13 @@ export const GenerateMaintenanceEvents = async (
const alreadyExists = existing.some((e) => e.start_date_time === eventStart);
if (!alreadyExists) {
const event = await db.createMaintenanceEvent({
const event = await CreateMaintenanceEventWithNotification(
maintenance_id,
start_date_time: eventStart,
end_date_time: eventEnd,
status: determineEventStatus(eventStart, eventEnd),
});
eventStart,
eventEnd,
maintenanceTitle,
maintenanceDescription,
);
createdEvents.push(event);
}
}
@@ -202,8 +265,8 @@ export const CreateMaintenance = async (data: CreateMaintenanceInput): Promise<{
await db.addMonitorsToMaintenanceWithStatus(maintenance.id, data.monitors);
}
// Generate initial events for the next 7 days
await GenerateMaintenanceEvents(maintenance.id, data.start_date_time, data.rrule, data.duration_seconds, 7);
// Generate initial events
await GenerateMaintenanceEvents(maintenance.id, data.start_date_time, data.rrule, data.duration_seconds, 1);
return {
maintenance_id: maintenance.id,
@@ -336,7 +399,7 @@ export const UpdateMaintenance = async (id: number, data: UpdateMaintenanceInput
}
}
// Regenerate the event
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 7);
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 1);
} else {
// For recurring maintenances: delete future SCHEDULED events and regenerate
for (const event of events) {
@@ -345,8 +408,8 @@ export const UpdateMaintenance = async (id: number, data: UpdateMaintenanceInput
await db.deleteMaintenanceEvent(event.id);
}
}
// Regenerate events for the next 7 days
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 7);
// Regenerate events
await GenerateMaintenanceEvents(id, updated.start_date_time, updated.rrule, updated.duration_seconds, 1);
}
}
}
@@ -377,11 +440,16 @@ export const CreateMaintenanceEvent = async (data: CreateMaintenanceEventInput):
throw new Error("End date/time must be after start date/time");
}
const siteData = await GetAllSiteData();
const notificationSettings =
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
const reminderBufferSeconds = notificationSettings.reminder_buffer_hours * 3600;
const event = await db.createMaintenanceEvent({
maintenance_id: data.maintenance_id,
start_date_time: data.start_date_time,
end_date_time: data.end_date_time,
status: determineEventStatus(data.start_date_time, data.end_date_time),
status: determineEventStatus(data.start_date_time, data.end_date_time, reminderBufferSeconds),
});
return event;
@@ -534,20 +602,23 @@ export const formatDurationSeconds = (seconds: number): string => {
/**
* Update maintenance event statuses based on current time:
* 1. SCHEDULED events starting within 60 minutes → READY
* 1. SCHEDULED events starting within the reminder buffer → READY
* 2. READY events where current time is within start/end → ONGOING
* 3. ONGOING events where end_date_time has passed → COMPLETED
*/
export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
const currentTimestamp = GetMinuteStartNowTimestampUTC();
const sixtyMinutesInSeconds = 60 * 60;
const siteData = await GetAllSiteData();
const siteVars = siteDataToVariables(siteData);
const siteUrl = siteVars.site_url;
//get global maintenance notification settings
const notificationSettings =
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
const reminderBufferSeconds = notificationSettings.reminder_buffer_hours * 3600;
try {
// 1. Mark SCHEDULED events starting within 60 minutes as READY
const scheduledEvents = await db.getScheduledEventsStartingSoon(currentTimestamp, sixtyMinutesInSeconds);
// 1. Mark SCHEDULED events starting within the reminder buffer as READY
const scheduledEvents = await db.getScheduledEventsStartingSoon(currentTimestamp, reminderBufferSeconds);
for (const event of scheduledEvents) {
await db.updateMaintenanceEventStatus(event.id, GC.READY);
console.log(`Maintenance event ${event.id} marked as READY (starts at ${event.start_date_time})`);
@@ -557,15 +628,17 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
new Date(event.start_date_time * 1000),
new Date(currentTimestamp * 1000),
);
const update = maintenanceToVariables(
event,
monitorNames,
`**is starting in ${timeUntilStart}**`,
"starting_soon",
"Maintenance Starting Soon",
siteUrl,
);
await subscriberQueue.push(update);
if (notificationSettings.event_types.reminder) {
const update = maintenanceToVariables(
event,
monitorNames,
`**is starting in ${timeUntilStart}**`,
"starting_soon",
"Maintenance Starting Soon",
siteUrl,
);
await subscriberQueue.push(update);
}
}
// 2. Catch-up: SCHEDULED events that missed the READY window and already started → ONGOING
@@ -575,15 +648,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
console.log(`Maintenance event ${event.id} marked as ONGOING (catch-up from SCHEDULED)`);
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const update = maintenanceToVariables(
event,
monitorNames,
"**is now in progress**",
"ongoing",
"Maintenance In Progress",
siteUrl,
);
await subscriberQueue.push(update);
if (notificationSettings.event_types.started) {
const update = maintenanceToVariables(
event,
monitorNames,
"**is now in progress**",
"ongoing",
"Maintenance In Progress",
siteUrl,
);
await subscriberQueue.push(update);
}
}
// 3. Mark READY events that are now in progress as ONGOING
@@ -593,15 +669,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
console.log(`Maintenance event ${event.id} marked as ONGOING`);
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const update = maintenanceToVariables(
event,
monitorNames,
"**is now in progress**",
"ongoing",
"Maintenance In Progress",
siteUrl,
);
await subscriberQueue.push(update);
if (notificationSettings.event_types.started) {
const update = maintenanceToVariables(
event,
monitorNames,
"**is now in progress**",
"ongoing",
"Maintenance In Progress",
siteUrl,
);
await subscriberQueue.push(update);
}
}
// 4. Mark ONGOING events that have ended as COMPLETED
@@ -611,15 +690,18 @@ export const UpdateMaintenanceEventStatuses = async (): Promise<void> => {
console.log(`Maintenance event ${event.id} marked as COMPLETED`);
const monitors = await db.getMonitorsByMaintenanceId(event.maintenance_id);
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
const update = maintenanceToVariables(
event,
monitorNames,
"**has been completed**",
"completed",
"Maintenance Completed",
siteUrl,
);
await subscriberQueue.push(update);
if (notificationSettings.event_types.ended) {
const update = maintenanceToVariables(
event,
monitorNames,
"**has been completed**",
"completed",
"Maintenance Completed",
siteUrl,
);
await subscriberQueue.push(update);
}
}
} catch (error) {
console.error("Error updating maintenance event statuses:", error);
@@ -127,19 +127,20 @@ export async function CreateMonitorAlertConfig(
// Validate input
validateMonitorAlertConfigInput(data);
if (!data.monitor_tag) {
throw new Error("monitor_tag is required");
if (!data.monitor_tags || data.monitor_tags.length === 0) {
throw new Error("At least one monitor is required");
}
// Check if monitor exists
const monitor = await db.getMonitorByTag(data.monitor_tag);
if (!monitor) {
throw new Error(`Monitor with tag '${data.monitor_tag}' not found`);
// Check if all monitors exist
for (const tag of data.monitor_tags) {
const monitor = await db.getMonitorByTag(tag);
if (!monitor) {
throw new Error(`Monitor with tag '${tag}' not found`);
}
}
// Prepare insert data
const insertData: MonitorAlertConfigInsert = {
monitor_tag: data.monitor_tag,
alert_for: data.alert_for,
alert_value: data.alert_value,
failure_threshold: data.failure_threshold,
@@ -153,6 +154,9 @@ export async function CreateMonitorAlertConfig(
// Insert alert config
const id = await db.insertMonitorAlertConfig(insertData);
// Add monitors to junction table
await db.addMonitorsToAlertConfig(id, data.monitor_tags);
// Add triggers if provided
if (data.trigger_ids && data.trigger_ids.length > 0) {
await db.addTriggersToMonitorAlertConfig(id, data.trigger_ids);
@@ -194,6 +198,19 @@ export async function UpdateMonitorAlertConfig(
validateAlertValue(alertFor, data.alert_value);
}
// Validate monitor_tags if provided
if (data.monitor_tags !== undefined) {
if (data.monitor_tags.length === 0) {
throw new Error("At least one monitor is required");
}
for (const tag of data.monitor_tags) {
const monitor = await db.getMonitorByTag(tag);
if (!monitor) {
throw new Error(`Monitor with tag '${tag}' not found`);
}
}
}
// Prepare update data
const updateData: MonitorAlertConfigUpdate = {};
if (data.alert_for !== undefined) updateData.alert_for = data.alert_for;
@@ -210,6 +227,11 @@ export async function UpdateMonitorAlertConfig(
await db.updateMonitorAlertConfig(data.id, updateData);
}
// Update monitors if provided
if (data.monitor_tags !== undefined) {
await db.replaceAlertConfigMonitors(data.id, data.monitor_tags);
}
// Update triggers if provided
if (data.trigger_ids !== undefined) {
await db.replaceMonitorAlertConfigTriggers(data.id, data.trigger_ids);
@@ -420,6 +442,7 @@ function validateAlertStatus(value: string): asserts value is MonitorAlertStatus
*/
export async function CreateMonitorAlertV2(
configId: number,
monitorTag?: string | null,
incidentId?: number | null,
): Promise<MonitorAlertV2Record> {
// Check if config exists
@@ -438,6 +461,7 @@ export async function CreateMonitorAlertV2(
const insertData: MonitorAlertV2Insert = {
config_id: configId,
monitor_tag: monitorTag || null,
incident_id: incidentId || null,
alert_status: "TRIGGERED",
};
@@ -20,6 +20,7 @@ import type {
SiteDateTimeFormat,
SiteSubscriptionsSettings,
SitemapXMLConfig,
GlobalMaintenanceNotificationSettings,
} from "../../types/site.js";
export interface SiteDataTransformed {
@@ -66,6 +67,7 @@ export interface SiteDataTransformed {
metaSiteTitle?: string;
metaSiteDescription?: string;
sitemap?: SitemapXMLConfig;
globalMaintenanceNotificationSettings?: GlobalMaintenanceNotificationSettings;
}
export function InsertKeyValue(key: string, value: string): Promise<number[]> {
+7 -2
View File
@@ -43,12 +43,12 @@ export const siteDataKeys: SiteDataKey[] = [
},
{
key: "favicon",
isValid: (value) => typeof value === "string" && value.trim().length > 0,
isValid: (value) => typeof value === "string",
data_type: "string",
},
{
key: "logo",
isValid: (value) => typeof value === "string" && value.trim().length > 0,
isValid: (value) => typeof value === "string",
data_type: "string",
},
{
@@ -291,4 +291,9 @@ export const siteDataKeys: SiteDataKey[] = [
isValid: IsValidJSONString,
data_type: "object",
},
{
key: "globalMaintenanceNotificationSettings",
isValid: IsValidJSONString,
data_type: "object",
},
];
+14
View File
@@ -292,6 +292,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"];
@@ -692,6 +698,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);
@@ -7,6 +7,8 @@ import type {
MonitorAlertConfigTriggerRecord,
MonitorAlertConfigTriggerInsert,
MonitorAlertConfigWithTriggers,
MonitorAlertConfigMonitorRecord,
MonitorAlertConfigMonitorInsert,
TriggerRecord,
MonitorAlertV2Record,
MonitorAlertV2Insert,
@@ -29,7 +31,7 @@ export class MonitorAlertConfigRepository extends BaseRepository {
async insertMonitorAlertConfig(data: MonitorAlertConfigInsert): Promise<number> {
const dbType = GetDbType();
const insertData = {
monitor_tag: data.monitor_tag,
monitor_tag: data.monitor_tag || null,
alert_for: data.alert_for,
alert_value: data.alert_value,
failure_threshold: data.failure_threshold,
@@ -88,29 +90,35 @@ export class MonitorAlertConfigRepository extends BaseRepository {
* Get monitor alert configs with optional filtering
*/
async getMonitorAlertConfigs(filter: MonitorAlertConfigFilter): Promise<MonitorAlertConfigRecord[]> {
let query = this.knex("monitor_alerts_config").whereRaw("1=1");
let query = this.knex("monitor_alerts_config as mac").select("mac.*").whereRaw("1=1");
if (filter.id !== undefined) {
query = query.andWhere("id", filter.id);
query = query.andWhere("mac.id", filter.id);
}
if (filter.monitor_tag !== undefined) {
query = query.andWhere("monitor_tag", filter.monitor_tag);
query = query
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.andWhere("macm.monitor_tag", filter.monitor_tag);
}
if (filter.alert_for !== undefined) {
query = query.andWhere("alert_for", filter.alert_for);
query = query.andWhere("mac.alert_for", filter.alert_for);
}
if (filter.is_active !== undefined) {
query = query.andWhere("is_active", filter.is_active);
query = query.andWhere("mac.is_active", filter.is_active);
}
return await query.orderBy("id", "desc");
return await query.orderBy("mac.id", "desc");
}
/**
* Get all monitor alert configs for a specific monitor tag
*/
async getMonitorAlertConfigsByMonitorTag(monitorTag: string): Promise<MonitorAlertConfigRecord[]> {
return await this.knex("monitor_alerts_config").where({ monitor_tag: monitorTag }).orderBy("id", "desc");
return await this.knex("monitor_alerts_config as mac")
.select("mac.*")
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.where("macm.monitor_tag", monitorTag)
.orderBy("mac.id", "desc");
}
/**
@@ -124,9 +132,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
* Get all active monitor alert configs for a specific monitor
*/
async getActiveMonitorAlertConfigsByMonitorTag(monitorTag: string): Promise<MonitorAlertConfigRecord[]> {
return await this.knex("monitor_alerts_config")
.where({ monitor_tag: monitorTag, is_active: "YES" })
.orderBy("id", "desc");
return await this.knex("monitor_alerts_config as mac")
.select("mac.*")
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.where("macm.monitor_tag", monitorTag)
.andWhere("mac.is_active", "YES")
.orderBy("mac.id", "desc");
}
/**
@@ -140,26 +151,51 @@ export class MonitorAlertConfigRepository extends BaseRepository {
* Delete all monitor alert configs for a specific monitor tag
*/
async deleteMonitorAlertConfigsByMonitorTag(monitorTag: string): Promise<number> {
return await this.knex("monitor_alerts_config").where({ monitor_tag: monitorTag }).del();
// Find all config IDs that have this monitor tag in the junction table
const configIds = await this.knex("monitor_alerts_config_monitors")
.select("monitor_alerts_id")
.where({ monitor_tag: monitorTag });
if (configIds.length === 0) return 0;
// Remove the monitor from the junction table
await this.knex("monitor_alerts_config_monitors").where({ monitor_tag: monitorTag }).del();
// Delete any configs that now have zero monitors
const ids = configIds.map((r: { monitor_alerts_id: number }) => r.monitor_alerts_id);
let deletedCount = 0;
for (const id of ids) {
const remainingMonitors = await this.knex("monitor_alerts_config_monitors")
.count("* as count")
.where({ monitor_alerts_id: id })
.first<CountResult>();
if (Number(remainingMonitors?.count) === 0) {
await this.knex("monitor_alerts_config").where({ id }).del();
deletedCount++;
}
}
return deletedCount;
}
/**
* Count monitor alert configs with optional filtering
*/
async getMonitorAlertConfigsCount(filter: MonitorAlertConfigFilter): Promise<CountResult | undefined> {
let query = this.knex("monitor_alerts_config").count("* as count");
let query = this.knex("monitor_alerts_config as mac").count("* as count");
if (filter.id !== undefined) {
query = query.andWhere("id", filter.id);
query = query.andWhere("mac.id", filter.id);
}
if (filter.monitor_tag !== undefined) {
query = query.andWhere("monitor_tag", filter.monitor_tag);
query = query
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.andWhere("macm.monitor_tag", filter.monitor_tag);
}
if (filter.alert_for !== undefined) {
query = query.andWhere("alert_for", filter.alert_for);
query = query.andWhere("mac.alert_for", filter.alert_for);
}
if (filter.is_active !== undefined) {
query = query.andWhere("is_active", filter.is_active);
query = query.andWhere("mac.is_active", filter.is_active);
}
return await query.first<CountResult>();
@@ -174,29 +210,33 @@ export class MonitorAlertConfigRepository extends BaseRepository {
filter?: MonitorAlertConfigFilter,
): Promise<{ configs: MonitorAlertConfigRecord[]; total: number }> {
// Build count query
let countQuery = this.knex("monitor_alerts_config").count("* as count");
let countQuery = this.knex("monitor_alerts_config as mac").count("* as count");
if (filter?.monitor_tag) {
countQuery = countQuery.where("monitor_tag", filter.monitor_tag);
countQuery = countQuery
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.where("macm.monitor_tag", filter.monitor_tag);
}
if (filter?.is_active) {
countQuery = countQuery.andWhere("is_active", filter.is_active);
countQuery = countQuery.andWhere("mac.is_active", filter.is_active);
}
if (filter?.alert_for) {
countQuery = countQuery.andWhere("alert_for", filter.alert_for);
countQuery = countQuery.andWhere("mac.alert_for", filter.alert_for);
}
const totalResult = await countQuery.first<CountResult>();
const total = totalResult ? Number(totalResult.count) : 0;
// Build paginated query
let query = this.knex("monitor_alerts_config").orderBy("id", "desc");
let query = this.knex("monitor_alerts_config as mac").select("mac.*").orderBy("mac.id", "desc");
if (filter?.monitor_tag) {
query = query.where("monitor_tag", filter.monitor_tag);
query = query
.join("monitor_alerts_config_monitors as macm", "mac.id", "macm.monitor_alerts_id")
.where("macm.monitor_tag", filter.monitor_tag);
}
if (filter?.is_active) {
query = query.andWhere("is_active", filter.is_active);
query = query.andWhere("mac.is_active", filter.is_active);
}
if (filter?.alert_for) {
query = query.andWhere("alert_for", filter.alert_for);
query = query.andWhere("mac.alert_for", filter.alert_for);
}
const configs = await query.limit(limit).offset((page - 1) * limit);
@@ -278,6 +318,51 @@ export class MonitorAlertConfigRepository extends BaseRepository {
}
}
// ============ Monitor Alert Config Monitors CRUD ============
/**
* Add multiple monitors to an alert config
*/
async addMonitorsToAlertConfig(alertConfigId: number, monitorTags: string[]): Promise<void> {
if (monitorTags.length === 0) return;
const inserts = monitorTags.map((monitorTag) => ({
monitor_alerts_id: alertConfigId,
monitor_tag: monitorTag,
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
}));
await this.knex("monitor_alerts_config_monitors").insert(inserts);
}
/**
* Remove all monitors from an alert config
*/
async removeAllMonitorsFromAlertConfig(alertConfigId: number): Promise<number> {
return await this.knex("monitor_alerts_config_monitors").where({ monitor_alerts_id: alertConfigId }).del();
}
/**
* Replace all monitors for an alert config (remove old, add new)
*/
async replaceAlertConfigMonitors(alertConfigId: number, monitorTags: string[]): Promise<void> {
await this.removeAllMonitorsFromAlertConfig(alertConfigId);
if (monitorTags.length > 0) {
await this.addMonitorsToAlertConfig(alertConfigId, monitorTags);
}
}
/**
* Get monitor tags for an alert config
*/
async getAlertConfigMonitorTags(alertConfigId: number): Promise<string[]> {
const records = await this.knex("monitor_alerts_config_monitors")
.select("monitor_tag")
.where({ monitor_alerts_id: alertConfigId });
return records.map((r: { monitor_tag: string }) => r.monitor_tag);
}
// ============ Composite / Join Operations ============
/**
@@ -292,9 +377,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
.select("t.*")
.where("mact.monitor_alerts_id", id);
const monitorTags = await this.getAlertConfigMonitorTags(id);
return {
...config,
triggers: triggerRecords as TriggerRecord[],
monitor_tags: monitorTags,
};
}
@@ -311,9 +399,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
.select("t.*")
.where("mact.monitor_alerts_id", config.id);
const monitorTags = await this.getAlertConfigMonitorTags(config.id);
result.push({
...config,
triggers: triggerRecords as TriggerRecord[],
monitor_tags: monitorTags,
});
}
@@ -333,9 +424,12 @@ export class MonitorAlertConfigRepository extends BaseRepository {
.select("t.*")
.where("mact.monitor_alerts_id", config.id);
const monitorTags = await this.getAlertConfigMonitorTags(config.id);
result.push({
...config,
triggers: triggerRecords as TriggerRecord[],
monitor_tags: monitorTags,
});
}
@@ -373,6 +467,7 @@ export class MonitorAlertConfigRepository extends BaseRepository {
const dbType = GetDbType();
const insertData: Record<string, unknown> = {
config_id: data.config_id,
monitor_tag: data.monitor_tag || null,
incident_id: data.incident_id || null,
alert_status: data.alert_status,
created_at: this.knex.fn.now(),
@@ -432,6 +527,9 @@ export class MonitorAlertConfigRepository extends BaseRepository {
if (filter.config_id !== undefined) {
query = query.andWhere("config_id", filter.config_id);
}
if (filter.monitor_tag !== undefined) {
query = query.andWhere("monitor_tag", filter.monitor_tag);
}
if (filter.incident_id !== undefined) {
query = query.andWhere("incident_id", filter.incident_id);
}
+9
View File
@@ -168,6 +168,15 @@ const seedSiteData = {
mode: "off",
urls: [],
},
globalMaintenanceNotificationSettings: {
event_types: {
created: false,
reminder: true,
started: true,
ended: true,
},
reminder_buffer_hours: 1,
},
};
export default seedSiteData;
@@ -10,9 +10,11 @@ export function alertToVariables(
config: MonitorAlertConfigRecord,
alert: MonitorAlertV2Record,
siteVars: SiteDataForNotification,
monitorTag?: string,
): AlertVariableMap {
const createdAtDate = alert.created_at instanceof Date ? alert.created_at : new Date(alert.created_at);
const alert_name = `Alert ${config.monitor_tag} for ${config.alert_for} ${config.alert_value} ${alert.alert_status} at ${createdAtDate.toISOString()}`;
const effectiveMonitorTag = monitorTag || config.monitor_tag || "unknown";
const alert_name = `Alert ${effectiveMonitorTag} for ${config.alert_for} ${config.alert_value} ${alert.alert_status} at ${createdAtDate.toISOString()}`;
return {
alert_id: alert.id,
@@ -24,7 +26,7 @@ export function alertToVariables(
alert_message: config.alert_description || "",
alert_source: GC.ALERT,
alert_timestamp: createdAtDate.toISOString(),
alert_cta_url: siteVars.site_url + "monitors/" + config.monitor_tag,
alert_cta_url: siteVars.site_url + "monitors/" + effectiveMonitorTag,
alert_cta_text: "Open Alert Details",
alert_incident_id: alert.incident_id ? alert.incident_id : undefined,
alert_incident_url: alert.incident_id ? siteVars.site_url + "incidents/" + alert.incident_id : undefined,
+9 -6
View File
@@ -141,8 +141,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 +234,10 @@ const addWorker = () => {
return;
}
// Get existing alerts
// Get existing alerts for this specific monitor + config combination
let alertsExisting = await GetMonitorAlertsV2({
config_id: monitor_alerts_configured.id,
monitor_tag: monitor_tag,
alert_status: GC.TRIGGERED,
});
let activeAlert = null;
@@ -244,9 +246,9 @@ const addWorker = () => {
}
if (isAffected) {
// Trigger alert if not already active
// Trigger alert if not already active for this monitor
if (!activeAlert) {
activeAlert = await CreateMonitorAlertV2(monitor_alerts_configured.id);
activeAlert = await CreateMonitorAlertV2(monitor_alerts_configured.id, monitor_tag);
if (monitor_alerts_configured.create_incident === GC.YES) {
let newIncidentNumber = await createNewIncident(
activeAlert,
@@ -261,7 +263,7 @@ const addWorker = () => {
}
}
// Send triggered alert notifications
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars);
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars, monitor_tag);
}
} else {
// Resolve any existing alert
@@ -302,7 +304,7 @@ const addWorker = () => {
}
// Send resolution notifications
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars);
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars, monitor_tag);
}
}
} catch (error) {
@@ -348,6 +350,7 @@ export const push = async (monitor_tag: string, ts: number, status: string, opti
monitor_tag: monitor.tag,
is_active: GC.YES,
});
if (monitorAlertsConfigurations.length === 0) {
return;
}
+1 -1
View File
@@ -46,7 +46,7 @@ const addWorker = () => {
fromEmail,
templateTextBody,
);
console.log(`📧 Email sent to ${toEmails}`);
// console.log(`📧 Email sent to ${toEmails}`);
} catch (error) {
console.error(`Failed to send email to ${toEmails}:`, error);
throw error; // Re-throw to trigger retry
@@ -42,6 +42,7 @@ const addWorker = () => {
});
if (!dbRes) {
console.error("Failed to insert monitoring data for monitorTag:", monitorTag, "timestamp:", ts);
throw new Error("Failed to insert monitoring data");
}
@@ -52,7 +53,6 @@ const addWorker = () => {
latency: latency,
type: type,
});
alertingQueue.push(monitorTag, ts, status);
return dbRes;
@@ -4,7 +4,7 @@ import db from "../db/db.js";
import { rrulestr } from "rrule";
import { addDays } from "date-fns";
import type { MaintenanceRecord, MaintenanceEventRecord } from "../types/db.js";
import { determineEventStatus } from "../controllers/maintenanceController.js";
import { determineEventStatus, CreateMaintenanceEventWithNotification } from "../controllers/maintenanceController.js";
let maintenanceSchedulerQueue: Queue | null = null;
let worker: Worker | null = null;
@@ -58,12 +58,13 @@ const generateEventsForMaintenance = async (maintenance: MaintenanceRecord): Pro
// Check if event already exists for this start time
if (!existingStartTimes.has(eventStart)) {
await db.createMaintenanceEvent({
maintenance_id: maintenance.id,
start_date_time: eventStart,
end_date_time: eventEnd,
status: determineEventStatus(eventStart, eventEnd),
});
await CreateMaintenanceEventWithNotification(
maintenance.id,
eventStart,
eventEnd,
maintenance.title,
maintenance.description || null,
);
eventsCreated++;
console.log(
`Created maintenance event for "${maintenance.title}" at ${new Date(eventStart * 1000).toISOString()}`,
+13 -6
View File
@@ -154,25 +154,32 @@ class ApiCall {
}
}
if (!!!evalResp) {
if (!evalResp || typeof evalResp !== "object") {
evalResp = {
status: GC.DOWN,
latency: latency,
type: GC.ERROR,
};
} else if (
!!!evalResp.status ||
([GC.UP, GC.DOWN, GC.DEGRADED, GC.MAINTENANCE] as string[]).indexOf(evalResp.status) === -1
) {
errorMessage += " | Eval must return an object with 'status' and 'latency' fields, got no response";
} else if (evalResp.status === undefined) {
evalResp = {
status: GC.DOWN,
latency: latency,
type: GC.ERROR,
};
errorMessage += ` | Eval must return an object with a 'status' field (one of: ${GC.UP}, ${GC.DOWN}, ${GC.DEGRADED}, ${GC.MAINTENANCE}), but 'status' was missing`;
} else if (([GC.UP, GC.DOWN, GC.DEGRADED, GC.MAINTENANCE] as string[]).indexOf(evalResp.status) === -1) {
evalResp = {
status: GC.DOWN,
latency: latency,
type: GC.ERROR,
};
errorMessage += ` | Eval returned invalid 'status' value "${evalResp.status}". Must be one of: ${GC.UP}, ${GC.DOWN}, ${GC.DEGRADED}, ${GC.MAINTENANCE}`;
} else {
evalResp.type = GC.REALTIME;
// Ensure latency is a valid number
// Ensure latency is a valid number; fall back to measured latency
if (typeof evalResp.latency !== "number" || isNaN(evalResp.latency)) {
errorMessage += ` | Eval 'latency' must be a number, got ${JSON.stringify(evalResp.latency)}. Using measured latency instead`;
evalResp.latency = latency;
}
}
+14 -1
View File
@@ -55,7 +55,7 @@ class TcpCall {
error_message: `Error in tcpEval: ${message}`,
};
}
if (!!!evalResp) {
if (!evalResp) {
const message = "tcpEval did not return a valid response.";
console.log(`Error in tcpEval for ${tag}:`, message);
return {
@@ -65,6 +65,19 @@ class TcpCall {
error_message: `Error in tcpEval: ${message}`,
};
}
//evalResp to be an object with status and latency
if (!("status" in evalResp) || !("latency" in evalResp)) {
const message = "tcpEval did not return status or latency.";
console.log(`Error in tcpEval for ${tag}:`, message);
return {
status: GC.DOWN,
latency: 0,
type: GC.ERROR,
error_message: `Error in tcpEval: ${message}`,
};
}
//reduce to get the status
return {
status: evalResp?.status || GC.DOWN,
+22 -3
View File
@@ -628,7 +628,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 +642,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 +684,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 +718,7 @@ export interface MonitorAlertConfigCreateInput {
export interface MonitorAlertConfigUpdateInput {
id: number;
monitor_tags?: string[];
alert_for?: AlertForType;
alert_value?: string;
failure_threshold?: number;
@@ -721,6 +736,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 +745,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 +759,7 @@ export interface MonitorAlertV2Update {
export interface MonitorAlertV2Filter {
id?: number;
config_id?: number;
monitor_tag?: string;
incident_id?: number;
alert_status?: MonitorAlertStatusType;
}
@@ -28,7 +28,12 @@
}
gtag("js", new Date());
gtag("config", "{{id}}");
var gtagConfig = {};
var transportUrl = "{{transport_url}}";
if (!!transportUrl) {
gtagConfig.transport_url = transportUrl;
}
gtag("config", "{{id}}", gtagConfig);
window.addEventListener("analyticsEvent", function (e) {
// Extract event name and data from the custom event
@@ -43,6 +48,7 @@
//on dom ready
document.addEventListener("DOMContentLoaded", function () {
loadJS("https://www.googletagmanager.com/gtag/js?id={{id}}", initJS, document.getElementsByTagName("head")[0]);
var scriptHost = "{{script_host}}" || "https://www.googletagmanager.com/gtag/js?id={{id}}";
loadJS(scriptHost, initJS, document.getElementsByTagName("head")[0]);
});
})();
+10
View File
@@ -148,3 +148,13 @@ export interface SitemapXMLConfig {
loc: string;
}[];
}
export interface GlobalMaintenanceNotificationSettings {
event_types: {
created: boolean;
reminder: boolean;
started: boolean;
ended: boolean;
};
reminder_buffer_hours: number;
}
@@ -264,7 +264,7 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
updatedMaintenance.start_date_time,
updatedMaintenance.rrule,
updatedMaintenance.duration_seconds,
7,
1,
);
}
} else if (!isOneTime && scheduleChanged) {
@@ -287,7 +287,7 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
updatedMaintenance.start_date_time,
updatedMaintenance.rrule,
updatedMaintenance.duration_seconds,
7,
1,
);
}
}
-1
View File
@@ -16,7 +16,6 @@
<ModeWatcher />
<Toaster />
<svelte:head></svelte:head>
<main>
{@render children()}
</main>
+16
View File
@@ -221,6 +221,10 @@
{
"title": "Internationalization",
"content": "v4/internationalization"
},
{
"title": "Analytics",
"content": "v4/analytics"
}
]
}
@@ -264,6 +268,10 @@
{
"title": "Custom JS & CSS",
"content": "v4/guides/custom-js-css-guide"
},
{
"title": "Kener vs Other Status Pages",
"content": "v4/guides/comparison"
}
]
}
@@ -295,6 +303,14 @@
"group": "v4.x",
"collapsible": false,
"pages": [
{
"title": "v4.0.22",
"content": "v4/changelogs/v4.0.22"
},
{
"title": "v4.0.20",
"content": "v4/changelogs/v4.0.20"
},
{
"title": "v4.0.19",
"content": "v4/changelogs/v4.0.19"
@@ -23,8 +23,6 @@
</script>
<svelte:head>
<title>{data.config.name}</title>
<meta name="description" content="Documentation for {data.config.name}" />
<!-- favicon -->
<link rel="icon" href={data.config.favicon} />
</svelte:head>
@@ -149,13 +149,18 @@
<svelte:head>
<title>{data.title} - Documentation</title>
<meta name="description" content={data.description || `Documentation for ${data.title}`} />
<meta property="article:author" content="https://github.com/rajnandan1" />
<link rel="canonical" href={`https://kener.ing/docs/${data.slug}`} />
<meta property="og:title" content="{data.title} - Documentation" />
<meta property="og:description" content={data.description || `Documentation for ${data.title}`} />
<meta property="og:type" content="article" />
<meta property="article:author" content="https://github.com/rajnandan1" />
<link rel="canonical" href={`https://kener.ing/docs/${data.slug}`} />
<meta property="og:url" content={`https://kener.ing/docs/${data.slug}`} />
<meta property="og:logo" content="https://kener.ing/logo96.png" />
<meta property="og:image" content="https://kener.ing/og.jpg" />
<meta name="twitter:title" content="{data.title} - Documentation" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:description" content={data.description || `Documentation for ${data.title}`} />
<meta name="twitter:image" content="https://kener.ing/og.jpg" />
{@html `<script type="application/ld+json">${JSON.stringify({
"@context": "https://schema.org",
"@type": "BreadcrumbList",
+2 -60
View File
@@ -9,66 +9,8 @@
</script>
<svelte:head>
<title>Kener Documentation</title>
<!-- social preview og.jpg -->
<meta property="og:image" content="https://kener.ing/og.jpg" />
<meta property="og:title" content="Kener Documentation" />
<meta
property="og:description"
content="Comprehensive documentation for Kener, the open-source status page system. Learn how to set up, customize, and manage your own status page with monitoring, incident management, and notifications."
/>
<meta property="og:site_name" content="Kener" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Kener Documentation" />
<meta
name="twitter:description"
content="Comprehensive documentation for Kener, the open-source status page system. Learn how to set up, customize, and manage your own status page with monitoring, incident management, and notifications."
/>
<meta name="twitter:image" content="https://kener.ing/og.jpg" />
<meta name="author" content="Raj Nandan Sharma" />
<link rel="author" href="https://github.com/rajnandan1" />
{@html `<script type="application/ld+json">${JSON.stringify({
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
name: "Kener",
url: "https://kener.ing",
logo: "https://kener.ing/logo96.png",
sameAs: ["https://github.com/rajnandan1/kener"]
},
{
"@type": "SoftwareApplication",
name: "Kener",
applicationCategory: "DeveloperApplication",
operatingSystem: "Linux, macOS, Windows",
url: "https://kener.ing",
description:
"Open-source status page system built with SvelteKit. Features real-time monitoring (API, Ping, TCP, DNS, SSL, SQL, gRPC), incident management, maintenance scheduling, notifications (email, Slack, Discord, webhooks), embeddable widgets, and a REST API.",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD"
},
author: {
"@type": "Person",
name: "Raj Nandan Sharma",
url: "https://github.com/rajnandan1"
},
license: "https://opensource.org/licenses/MIT",
softwareVersion: "4.x",
downloadUrl: "https://github.com/rajnandan1/kener",
screenshot: "https://kener.ing/og.jpg"
},
{
"@type": "WebSite",
name: "Kener",
url: "https://kener.ing"
}
]
})}</script>`}
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Q3MLRXCBFT"></script>
<script async src="https://saki-production.up.railway.app/googletagmanager/gtag/js?id=G-Q3MLRXCBFT"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
@@ -76,7 +18,7 @@
}
gtag("js", new Date());
gtag("config", "G-Q3MLRXCBFT");
gtag("config", "G-Q3MLRXCBFT", { transport_url: "https://saki-production.up.railway.app/google-analytics" });
</script>
</svelte:head>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,142 @@
---
title: Analytics
description: Connect analytics providers to track traffic on your public status page
---
Kener supports injecting analytics scripts into the public status page. Go to **Manage → Analytics Providers** to configure any of the supported providers.
## How it works {#how-it-works}
When a provider is enabled, Kener injects its tracking script into every public status page response via `/capture.js`. Multiple providers can be active simultaneously.
## Supported providers {#supported-providers}
| Provider | Key |
| ----------------- | ---------------------------- |
| Google Analytics | `analytics.googleTagManager` |
| Plausible | `analytics.plausible` |
| Mixpanel | `analytics.mixpanel` |
| Amplitude | `analytics.amplitude` |
| Microsoft Clarity | `analytics.clarity` |
| Umami | `analytics.umami` |
| PostHog | `analytics.posthog` |
---
## Google Analytics {#google-analytics}
Uses Google Tag Manager / gtag.js.
| Field | Required | Example |
| -------------- | -------- | ----------------------------------------------------------- |
| Measurement ID | Yes | `G-S05E5E5E5E5` |
| Transport URL | No | `https://www.google-analytics.com` |
| Script Host | No | `https://www.googletagmanager.com/gtag/js?id=G-S05E5E5E5E5` |
**Measurement ID** is found in your Google Analytics property under **Admin → Data Streams → Web → Measurement ID**.
**Script Host** is the full URL of the gtag.js script to load, including the `?id=` query parameter. Leave it empty to use Google's default CDN (`https://www.googletagmanager.com/gtag/js?id=<your-id>`). Set a custom URL if you proxy the script through your own domain, e.g. `https://your-proxy.example.com/gtag/js?id=G-XXXXXXXXXX`.
Leave **Transport URL** empty to send hits directly to Google Analytics.
> [!TIP]
> Ad blockers commonly block requests to `www.googletagmanager.com` and `www.google-analytics.com`. Use [Saki](https://saki.rajnandan.com/) — a self-hostable Nginx proxy — to route through your own domain:
>
> - **Script Host**: `https://saki.rajnandan.com/tg/script.js?id=G-XXXXXXXXXX`
> - **Transport URL**: `https://saki.rajnandan.com/an/`
---
## Plausible {#plausible}
Privacy-friendly analytics with no cookies.
| Field | Required | Example |
| ------------- | -------- | ---------------------------------------------------------------- |
| Domain | Yes | `kener.ing` |
| API | Yes | `https://plausible.io/api/event` |
| Script Source | Yes | `https://plausible.io/js/script.pageview-props.tagged-events.js` |
**Domain** must match exactly what you registered at plausible.io. For self-hosted Plausible, replace the **API** and **Script Source** URLs with your instance's host.
---
## Mixpanel {#mixpanel}
| Field | Required | Example |
| ------------- | -------- | -------------------------- |
| Project Token | Yes | `abc123def456` |
| API Host | No | `https://api.mixpanel.com` |
Find your **Project Token** in Mixpanel under **Settings → Project Settings**. Leave **API Host** empty to use the default Mixpanel endpoint.
> [!TIP]
> To bypass ad blockers, use [Saki](https://saki.rajnandan.com/). Set **API Host** to `https://saki.rajnandan.com/mxa/`.
---
## Amplitude {#amplitude}
| Field | Required | Example |
| ----------------- | -------- | -------------------------------------- |
| Amplitude API Key | Yes | `a1b2c3d4e5f6...` |
| Server URL | No | `https://api2.amplitude.com/2/httpapi` |
Find your **Amplitude API Key** under **Settings → Projects → [Your Project] → General**. Leave **Server URL** empty to use Amplitude's default. Use `https://api.eu.amplitude.com/2/httpapi` for EU data residency.
> [!TIP]
> To bypass ad blockers, use [Saki](https://saki.rajnandan.com/). Set **Server URL** to `https://saki.rajnandan.com/aapi/2/httpapi`.
---
## Microsoft Clarity {#microsoft-clarity}
| Field | Required | Example |
| ---------- | -------- | ------------ |
| Project ID | Yes | `abc1234xyz` |
Find your **Project ID** in Microsoft Clarity under **Settings → Overview → Tracking Code** (the value after `clarity.ms/tag/`).
---
## Umami {#umami}
Self-hostable, cookie-free analytics.
| Field | Required | Example |
| ---------- | -------- | -------------------------------------- |
| Website ID | Yes | `5e1c3b29-3f7d-4f2a-8e6b-d3f1a2b3c4d5` |
| Script URL | Yes | `https://cloud.umami.is/script.js` |
Find your **Website ID** in Umami under **Settings → Websites**. For self-hosted Umami, replace the **Script URL** with your instance URL, e.g. `https://umami.example.com/script.js`.
---
## PostHog {#posthog}
Product analytics with session recordings.
| Field | Required | Example |
| -------- | -------- | -------------------------- |
| API Key | Yes | `phc_xxxxxxxxxxxxxxxxxxxx` |
| API Host | Yes | `https://us.i.posthog.com` |
Find your **API Key** in PostHog under **Settings → Project → Project API key**. Set **API Host** to `https://eu.i.posthog.com` for the EU cloud, or your self-hosted PostHog URL.
> [!TIP]
> To bypass ad blockers, use [Saki](https://saki.rajnandan.com/). Set **API Host** to `https://saki.rajnandan.com/pha/`.
---
## Enable a provider {#enable-a-provider}
1. Go to **Manage → Analytics Providers**.
2. Select the provider from the left panel.
3. Fill in the required fields.
4. Set **Status** to **Enable**.
5. Click **Save Changes**.
A green dot next to the provider name indicates it is active on the public site.
> [!NOTE]
> Changes take effect immediately — no restart required.
@@ -0,0 +1,55 @@
---
title: v4.0.20 Changelog
description: See what's new in Kener v4.0.20, including new features, improvements, and bug fixes
---
## New features {#new-features}
### Sitemap configuration {#sitemap-configuration}
Admins can now configure how `sitemap.xml` is generated from **Manage → Site Configurations → Sitemap**. Three modes are available:
| Mode | Behaviour |
| :--------- | :--------------------------------------------------------------------------------------------------------- |
| **Auto** | Automatically includes all pages, visible active monitors, and current/previous month event pages |
| **Manual** | Only the URLs you explicitly add are included |
| **Off** | Returns a 404 for `/sitemap.xml` |
In **Auto** mode you can also add extra manual URLs alongside the auto-generated ones. The sitemap URL is shown in the admin UI for easy copying.
### HEIC/HEIF image support {#heic-heif-support}
All image upload fields — logo, favicon, social preview, page logo, monitor image — now accept **HEIC** and **HEIF** formats (common on iPhones). Uploaded HEIC/HEIF images are automatically converted to JPEG server-side using `heic-convert` before processing with sharp.
The default `BODY_SIZE_LIMIT` has been increased to **3 MB** (from the SvelteKit default of 512 KB) to accommodate larger image uploads. You can override this via the `BODY_SIZE_LIMIT` environment variable.
## Improvements {#improvements}
### Open Graph & Twitter Card meta tags {#og-twitter-meta}
All public-facing pages now emit complete Open Graph and Twitter Card meta tags:
- **Status pages** (`/`, `/[page_path]`) — `og:title`, `og:description`, `og:type`, `og:image`, `twitter:card`, `twitter:image`
- **Incident pages** — article-type OG tags with the first comment as description
- **Maintenance pages** — article-type OG tags with the maintenance description
- **Event pages** — OG tags with month-specific title and description
- **Monitor pages** — OG tags with monitor name and status
### Improved SEO semantics {#seo-semantics}
- Page headers and incident/maintenance titles are now wrapped in proper `<h1>`/`<h2>` HTML heading tags for better SEO structure.
- `robots` meta tag (`noindex, nofollow`) added to admin, account, and embed layouts to prevent search engines from indexing private pages.
- `robots.txt` expanded with explicit `Allow`/`Disallow` rules and entries for AI crawlers (GPTBot, ClaudeBot, PerplexityBot, Google-Extended, etc.).
### Page-level SEO field key fix {#page-seo-key-fix}
Page-level SEO settings now use the correct keys (`metaPageTitle`, `metaPageDescription`, `socialPagePreviewImage`) consistently. Previously, the dashboard controller read `metaTitle`/`metaDescription`/`socialPreviewImage` which didn't match what the page settings form saved. Pages with zero monitors also now correctly return SEO metadata.
### Separate save buttons for page settings {#separate-save-buttons}
The page settings UI now has separate **Save** buttons and loading states for Display settings and SEO settings, so saving one section doesn't affect the other.
## Bug fixes {#bug-fixes}
- Fixed image upload error handling on pages — non-OK responses are now caught before attempting to parse JSON.
- Removed unused `MonitorAlerting.svelte` component and stale `llms.txt`/`sitemap.xml` server handlers.
@@ -0,0 +1,76 @@
---
title: v4.0.22 Changelog
description: See what's new in Kener v4.0.22, including new features, improvements, and bug fixes
---
## New features {#new-features}
> [!NOTE]
> The migration for sqlite3 was broken in the release 4.0.21. So it has been fixed in this release.
### Multi-monitor alert configurations {#multi-monitor-alerts}
Alert configurations can now be applied to **multiple monitors** at once. Previously each alert config was tied to a single monitor — creating the same alert for several monitors required duplicating it.
- A searchable multi-select UI replaces the single-monitor dropdown when creating or editing alerts.
- Alert logs show which specific monitor triggered each alert.
- Existing single-monitor configs are automatically migrated to the new schema.
New database migration adds a `monitor_alerts_config_monitors` junction table and a `monitor_tag` column on `monitor_alerts_v2` to track per-monitor trigger history.
### Upcoming maintenances on status pages {#upcoming-maintenances}
Upcoming scheduled maintenance events are now displayed on the home page, custom page routes, and individual monitor detail pages. This gives visitors advance notice of planned work.
Visibility is controlled by the existing **Event Display Settings → Maintenances → Upcoming** toggle in **Manage → Site Configurations**.
### Maintenance subscriber notifications {#maintenance-notifications}
Maintenance events can now trigger subscriber notifications at each lifecycle stage: **created**, **reminder**, **started**, and **ended**. Configure which events send notifications in **Manage → Site Configurations → Maintenance Notification Settings**.
| Event type | When sent | Default |
| :--------- | :--------------------------- | :------ |
| Created | New event is generated | Off |
| Reminder | Event enters READY state | On |
| Started | Event enters ONGOING state | On |
| Ended | Event enters COMPLETED state | On |
The **reminder buffer** (default 1 hour) controls how far before the start time the READY transition and reminder notification happen.
### Analytics documentation {#analytics-docs}
A new [Analytics](/docs/v4/analytics) documentation page covers all seven supported providers — Google Analytics, Plausible, Mixpanel, Amplitude, Microsoft Clarity, Umami, and PostHog — with configuration tables, setup instructions, and ad-blocker proxy tips.
## Improvements {#improvements}
### Google Tag Manager transport URL and script host {#gtm-transport-url}
The Google Tag Manager integration now supports two new optional fields:
- **Transport URL** — custom endpoint for analytics hits (useful for bypassing ad blockers with a proxy like [Saki](https://github.com/nicholasgasior/saki)).
- **Script Host** — custom URL for the gtag.js script instead of the default `googletagmanager.com`.
Configure both in **Manage → Analytics Providers → Google Analytics**.
### Improved TCP and API monitor error handling {#tcp-api-error-handling}
- **TCP monitors** now collect all host connection failure messages and report them as a combined error.
- **API monitors** now create an HTTPS agent with keep-alive and socket pooling, distinguish timeout errors more accurately (`ECONNABORTED` vs message-based), and truncate eval errors to 200 characters.
### "Included Monitors" shows count {#included-monitors-count}
The group monitor popover and related components now display the number of included monitors — e.g. "Included Monitors (5)" — making it easier to see group size at a glance. All locale files updated with the `%count` placeholder.
### Czech and Slovak translation updates {#czech-slovak-translations}
Updated Czech (`cs.json`) and Slovak (`sk.json`) translations with new and corrected strings.
## Bug fixes {#bug-fixes}
- Fixed custom CSS and custom fonts documentation guide links pointing to incorrect URLs.
- Removed unused `<svelte:head>` elements and stale metadata from documentation layouts.
- Removed `knip.json` — the project now uses default knip configuration.
## Security {#security}
Patched critical and high-severity vulnerabilities by adding `overrides` for `fast-xml-parser`, `rollup`, `undici`, `minimatch`, `devalue`, `dompurify`, `cookie`, and `mailparser`. Updated `@sveltejs/kit` to ^2.53.3 and `svelte` to ^5.53.5.
@@ -0,0 +1,200 @@
---
title: Kener vs Other Status Pages
description: Compare Kener with Upptime, Gatus, cState, Cachet, Uptime Kuma, Atlassian Statuspage, Better Stack, Instatus, Hyperping, and PagerDuty
---
Choosing the right status page tool depends on your monitoring needs, budget, and how much control you want. This guide compares Kener with popular open-source alternatives and SaaS products.
## Quick Comparison Table {#quick-comparison}
| Feature | Kener | Upptime | Gatus | cState | Cachet | Uptime Kuma |
|---|---|---|---|---|---|---|
| **Type** | Open source | Open source | Open source | Open source | Open source | Open source |
| **Built with** | SvelteKit + Node.js | GitHub Actions | Go | Hugo (static) | PHP / Laravel | Node.js |
| **Self-hosted** | Yes | GitHub-only | Yes | Static hosting | Yes | Yes |
| **Admin dashboard** | Yes | No (GitHub UI) | No (YAML config) | No (CMS/CLI) | Yes | Yes |
| **Active monitoring** | Yes (11 types) | HTTP only | Yes (8+ types) | No | No | Yes (10+ types) |
| **Incident management** | Yes (full lifecycle) | GitHub Issues | No | Markdown files | Yes | No |
| **Maintenance windows** | Yes (with RRULE) | Yes (basic) | No | Markdown files | Yes (basic) | Yes (basic) |
| **Alerting** | Yes (4 channels) | Yes (9+ channels) | Yes (10+ channels) | No | No | Yes (78+ channels) |
| **Subscriber notifications** | Yes (email) | No | No | No | Yes (email) | No |
| **Multi-database** | SQLite, PostgreSQL, MySQL | None (file-based) | SQLite, PostgreSQL | None (static) | MySQL, PostgreSQL, SQLite | SQLite |
| **REST API** | Yes (17+ endpoints) | Read-only | No | Read-only | Yes | Yes |
| **RBAC** | Yes (3 roles) | No | No | No | Yes (basic) | No |
| **i18n** | Yes | Yes | No | Yes | Yes (10+ languages) | Yes (30+ languages) |
| **Custom theming** | Yes (CSS, colors, fonts) | Basic | No | Hugo themes | Yes (Bootstrap) | Basic |
| **Embeddable widgets** | Yes (badges, bars, charts) | No | No | Badges only | Yes (badges) | Yes (badges) |
| **License** | MIT | MIT | Apache 2.0 | MIT | BSD-3 | MIT |
## Open Source Alternatives {#open-source}
### Upptime {#upptime}
[Upptime](https://github.com/upptime/upptime) runs entirely on GitHub infrastructure — Actions for monitoring, Issues for incidents, and Pages for the status page. It requires zero server setup.
**Best for:** Teams already on GitHub who want a no-cost, zero-maintenance solution.
**Limitations:**
- HTTP monitoring only (no TCP, DNS, SSL, Ping, gRPC, SQL)
- 5-minute minimum check interval (GitHub Actions constraint)
- No admin dashboard — everything managed through GitHub UI and config files
- No subscriber notifications or alerting beyond GitHub integrations
- No database-backed data storage
### Gatus {#gatus}
[Gatus](https://github.com/TwiN/gatus) is a developer-oriented monitoring tool written in Go with a lightweight status page UI.
**Best for:** DevOps teams who prefer YAML-based configuration and want minimal resource usage (1030 MB RAM).
**Limitations:**
- No admin UI — all configuration via YAML files
- No incident management or maintenance scheduling
- No subscriber notifications
- No REST API for external integrations
- No embeddable widgets or badges
### cState {#cstate}
[cState](https://github.com/cstate/cstate) is a static status page built with Hugo. It generates a fast, serverless site that can be hosted anywhere.
**Best for:** Teams that only need incident communication without active monitoring.
**Limitations:**
- No active monitoring — it is purely a communication page
- Incidents created via Markdown files or CMS
- No alerting, no subscriber notifications
- No admin dashboard
- No database or API beyond read-only static JSON
### Cachet {#cachet}
[Cachet](https://github.com/cachethq/cachet) is a PHP/Laravel status page with a web dashboard, metric graphs, and subscriber notifications.
**Best for:** PHP teams who want a traditional status page with a dashboard.
**Limitations:**
- No built-in active monitoring (requires external tools)
- Development has been slow — major rewrite has been in progress for years
- PHP/Laravel dependency stack
- No modern UI framework
- Limited alerting capabilities
### Uptime Kuma {#uptime-kuma}
[Uptime Kuma](https://github.com/louislam/uptime-kuma) is a popular self-hosted monitoring tool with a polished UI and 78+ notification integrations.
**Best for:** Teams that need a monitoring dashboard with broad notification support.
**Limitations:**
- Status page is secondary to the monitoring dashboard — limited customization
- No incident lifecycle management (investigating → identified → resolved)
- No subscriber notifications
- No maintenance scheduling with recurring rules
- No multi-database support (SQLite only)
- No REST API for automation
- No RBAC or multi-user team management
- No embeddable status widgets beyond basic badges
## SaaS Alternatives {#saas}
### Atlassian Statuspage {#statuspage}
[Statuspage.io](https://www.atlassian.com/software/statuspage) is the most widely recognized SaaS status page, used by major companies.
| Plan | Price | Subscribers | Team Members |
|---|---|---|---|
| Free | $0/mo | 100 | 2 |
| Hobby | $29/mo | 250 | 5 |
| Startup | $99/mo | 1,000 | 10 |
| Business | $399/mo | 5,000 | 25 |
| Enterprise | $1,499/mo | 25,000 | 50 |
**Key differences from Kener:**
- No built-in active monitoring — requires external tools (PagerDuty, Datadog, etc.)
- Subscriber limits per plan (Kener has no subscriber caps)
- Limited customization on lower tiers (custom CSS only on Startup+)
- No self-hosting option — data stays on Atlassian infrastructure
### Better Stack {#betterstack}
[Better Stack](https://betterstack.com) combines uptime monitoring, status pages, incident management, and log management.
**Key differences from Kener:**
- Free tier includes 10 monitors with 3-minute intervals
- Paid plans can get expensive for teams
- Includes log management (beyond status page scope)
- No self-hosting — fully managed SaaS
- Vendor lock-in for monitoring data
### Instatus {#instatus}
[Instatus](https://instatus.com) focuses on fast, beautifully designed status pages using Jamstack architecture.
**Key differences from Kener:**
- Starts at ~$1520/mo
- 30+ language support
- No built-in monitoring — integrates with external providers (UptimeRobot, Datadog, Pingdom, etc.)
- Static pages served via CDN (fast but no real-time updates)
- No self-hosting option
### Hyperping {#hyperping}
[Hyperping](https://hyperping.com) offers uptime monitoring with status pages and on-call scheduling.
| Plan | Price | Monitors | Status Pages |
|---|---|---|---|
| Free | $0/mo | 5 | 1 (limited) |
| Essentials | $24/mo | 50 | 1 |
| Pro | $74/mo | 100 | Multiple |
**Key differences from Kener:**
- 30-second check intervals on paid plans
- Includes on-call scheduling (Kener does not)
- No self-hosting option
- Monitor limits per plan
### PagerDuty Status Page {#pagerduty}
[PagerDuty](https://www.pagerduty.com/platform/business-ops/status-pages/) offers status pages as an add-on to their incident management platform.
**Key differences from Kener:**
- Status page is a $89/mo add-on to existing PagerDuty plans ($2141/user/mo)
- Subscriber limits (250500 depending on plan)
- Primarily an incident management tool — status page is secondary
- No self-hosting, significant vendor lock-in
- Annual renewal price increases reported (1015%)
## Why Kener {#why-kener}
| Capability | Kener | Typical SaaS | Typical OSS |
|---|---|---|---|
| **11 monitor types** (API, TCP, DNS, SSL, Ping, SQL, gRPC, Heartbeat, GameDig, Group) | Yes | Varies (often HTTP-only or external) | 15 types |
| **Full incident lifecycle** (investigating → identified → monitoring → resolved) | Yes | Yes | Rare |
| **Recurring maintenance** (RRULE patterns) | Yes | Basic scheduling | Rare |
| **Auto-generated incidents** from alerts | Yes | Some | No |
| **Subscriber email notifications** | Yes (unlimited) | Yes (capped by plan) | Rare |
| **RBAC** (Admin, Editor, Member) | Yes | Yes (paid tiers) | Rare |
| **Multi-database** (SQLite, PostgreSQL, MySQL) | Yes | N/A (managed) | Usually 1 |
| **Embeddable widgets** (status bars, latency charts, badges) | Yes | Some | Basic badges |
| **Custom CSS/JS injection** | Yes | Paid tiers only | Varies |
| **Custom fonts and theming** | Yes | Limited | Varies |
| **Multiple status pages** | Yes | Paid tiers only | Rare |
| **REST API** (17+ endpoints) | Yes | Yes | Limited |
| **i18n support** | Yes | Some | Varies |
| **Self-hosted, your data** | Yes | No | Yes |
| **Cost** | Free (open source) | $291,499+/mo | Free |
### Kener is a good fit if you need {#good-fit}
- **Built-in monitoring + status page** in one tool (no external monitoring service required)
- **Full control** over your data and infrastructure
- **No subscriber or monitor caps** — scale without upgrading plans
- **Rich incident management** with auto-generation from alerts
- **Recurring maintenance windows** with complex scheduling (RRULE)
- **Multiple database options** to match your existing infrastructure
- **Embeddable widgets** for external dashboards or documentation sites
- **Team management** with role-based access control
> [!TIP]
> Kener provides monitoring + status page + incident management + alerting in a single self-hosted package. Most open-source alternatives cover only one or two of these, and SaaS tools charge per feature tier.
@@ -178,6 +178,25 @@ NODE_ENV=production
- Controls logging verbosity
- Enables/disables development-only features
### BODY_SIZE_LIMIT {#body-size-limit}
**Purpose**: Maximum allowed request body size. This is a SvelteKit setting that controls the largest payload the server will accept.
**Default**: `3M` (3 megabytes)
**When to Change**: Increase if you need to upload larger images through the admin panel or send larger API payloads.
**Format**: Number followed by unit — `K` (kilobytes), `M` (megabytes), or `Infinity` for no limit.
**Example**:
```bash
BODY_SIZE_LIMIT=10M
```
> [!NOTE]
> The Docker image sets `BODY_SIZE_LIMIT=3M` by default. Override it by passing the variable to your container.
## Integration Variables {#integration-variables}
For detailed configuration of these integrations, see their dedicated documentation pages.
@@ -15,6 +15,7 @@ Use **Manage → Site Configurations** to control identity, navigation, monitor
6. Configure **Social Preview & SEO**.
7. Configure **Data Retention Policy**.
8. Configure **Event Display Settings**.
9. Configure **Maintenance Notification Settings**.
## Runtime impact map {#runtime-impact-map}
@@ -25,6 +26,7 @@ Use **Manage → Site Configurations** to control identity, navigation, monitor
| Monitor sub menu options | `subMenuOptions` | Gates monitor share actions (badges/embed) on public monitor pages |
| Global page visibility | `globalPageVisibilitySettings` | Controls page switcher visibility and page-scoped navigation/events |
| Data retention policy | `dataRetentionPolicy` | Controls daily cleanup of old `monitoring_data` |
| Maintenance notifications | `globalMaintenanceNotificationSettings` | Controls which maintenance lifecycle events notify subscribers and reminder buffer timing |
| Social preview & SEO | `metaSiteTitle`, `metaSiteDescription`, `socialPreviewImage` | Default `<title>`, `og:title`, `<meta description>`, `og:description`, `og:image` for all pages |
## Monitor sub menu options {#monitor-sub-menu-options}
@@ -82,6 +84,25 @@ This affects:
- event sections on status pages
- notifications payload API used by the UI
## Maintenance notification settings {#maintenance-notification-settings}
`globalMaintenanceNotificationSettings` controls which maintenance event lifecycle transitions send subscriber notifications.
### Event types {#event-types}
| Event type | Trigger | Default |
| ---------- | ------------------------------- | ------- |
| `created` | New maintenance event generated | Off |
| `reminder` | Event enters READY state | On |
| `started` | Event enters ONGOING state | On |
| `ended` | Event enters COMPLETED state | On |
### Reminder buffer {#reminder-buffer}
`reminder_buffer_hours` (default `1`, minimum `1`) sets how many hours before the event start time the status transitions from SCHEDULED to READY.
See [Maintenance Events → Subscriber Notifications](/docs/v4/maintenances/events#subscriber-notifications) for the full lifecycle flow.
## Social preview and SEO {#social-preview-and-seo}
The **Social Preview & SEO** card sets site-wide defaults for meta tags used in search engines and link previews.
@@ -107,4 +128,5 @@ These values are used as defaults for every page. Individual pages can override
- notifications calendar opens page-scoped events for the current month.
- Change event display settings and verify incident/maintenance visibility.
- Set retention policy and confirm scheduler logs in server output.
- Toggle maintenance notification event types and verify subscribers receive (or don't receive) emails at each lifecycle stage.
- Set meta title/description and social preview image, then check `<meta>` tags in page source.
+9
View File
@@ -189,6 +189,15 @@
{/each}
</div>
{/if}
{#if data.upcomingMaintenances && data.upcomingMaintenances.length > 0}
<div class="flex flex-col gap-3">
{#each data.upcomingMaintenances as maintenance, i (maintenance.id ?? i)}
<div class="rounded-3xl border p-3 sm:p-4">
<MaintenanceItem {maintenance} />
</div>
{/each}
</div>
{/if}
<div class="overflow-hidden rounded-3xl border">
<div class={`grid grid-cols-1 ${getGridContainerClass(viewType)}`}>
{#each data.monitorTags as tag, i (tag)}
@@ -189,6 +189,15 @@
{/each}
</div>
{/if}
{#if data.upcomingMaintenances && data.upcomingMaintenances.length > 0}
<div class="flex flex-col gap-3">
{#each data.upcomingMaintenances as maintenance, i (maintenance.id ?? i)}
<div class="rounded-3xl border p-3 sm:p-4">
<MaintenanceItem {maintenance} />
</div>
{/each}
</div>
{/if}
<div class="overflow-hidden rounded-3xl border">
<div class={`grid grid-cols-1 ${getGridContainerClass(viewType)}`}>
{#each data.monitorTags as tag, i (tag)}
+9 -1
View File
@@ -32,7 +32,15 @@ export const GET: RequestHandler = async ({ params, url }) => {
if (!!analyticsData["analytics.googleTagManager"]) {
let id = analyticsData["analytics.googleTagManager"].requirements["Measurement ID"];
captureScript = captureScript + ";\n" + gtm.replaceAll("{{id}}", id);
let transportUrl = analyticsData["analytics.googleTagManager"].requirements["Transport URL"] || "";
let scriptHost = analyticsData["analytics.googleTagManager"].requirements["Script Host"] || "";
captureScript =
captureScript +
";\n" +
gtm
.replaceAll("{{id}}", id)
.replaceAll("{{transport_url}}", transportUrl)
.replaceAll("{{script_host}}", scriptHost);
}
if (!!analyticsData["analytics.mixpanel"]) {
@@ -35,9 +35,17 @@ export const load: PageServerLoad = async ({ params, parent }) => {
const monitorTags = [monitor_tag];
const [ongoingIncidents, ongoingMaintenances] = await Promise.all([
const eventSettings = parentData.eventDisplaySettings;
const [ongoingIncidents, ongoingMaintenances, upcomingMaintenances] = await Promise.all([
GetOngoingIncidentsForMonitorList(monitorTags),
GetOngoingMaintenanceEventsForMonitorList(monitorTags),
eventSettings.maintenances.enabled && eventSettings.maintenances.upcoming.show
? GetUpcomingMaintenanceEventsForMonitorList(
monitorTags,
eventSettings.maintenances.upcoming.maxCount,
eventSettings.maintenances.upcoming.daysInFuture,
)
: Promise.resolve([]),
]);
//last known status
@@ -94,6 +102,7 @@ export const load: PageServerLoad = async ({ params, parent }) => {
monitorLastLatency: ParseLatency(item.avgLatency),
ongoingIncidents,
ongoingMaintenances,
upcomingMaintenances,
externalUrl: monitor.external_url,
extendedTags,
monitorGroupMembersByTag,
@@ -143,6 +143,15 @@
{/each}
</div>
{/if}
{#if data.upcomingMaintenances && data.upcomingMaintenances.length > 0}
<div class="flex flex-col gap-3">
{#each data.upcomingMaintenances as maintenance, i (maintenance.id ?? i)}
<div class="rounded-3xl border p-3 sm:p-4">
<MaintenanceItem {maintenance} />
</div>
{/each}
</div>
{/if}
<!-- Calendar View (self-contained component with its own API call) -->
<MonitorOverview
+2 -1
View File
@@ -2,7 +2,7 @@ import { redirect } from "@sveltejs/kit";
import MobileDetect from "mobile-detect";
import type { LayoutServerLoad } from "./$types";
import { IsEmailSetup } from "$lib/server/controllers/controller.js";
import GC from "$lib/global-constants";
import seedSiteData from "$lib/server/db/seedSiteData.js";
import serverResolve from "$lib/server/resolver.js";
import { resolve } from "$app/paths";
@@ -38,5 +38,6 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
siteStatusColorsDark,
font,
canSendEmail: IsEmailSetup(),
seedSiteData,
};
};
+25 -5
View File
@@ -118,6 +118,7 @@ import sendWebhook from "$lib/server/notification/webhook_notification.js";
import sendEmail from "$lib/server/notification/email_notification.js";
import sendDiscord from "$lib/server/notification/discord_notification.js";
import sendSlack from "$lib/server/notification/slack_notification.js";
import heicConvert from "heic-convert";
import serverResolver from "$lib/server/resolver.js";
function AdminCan(role: string) {
@@ -324,6 +325,7 @@ export async function POST({ request, cookies }) {
const testAlertData: MonitorAlertV2Record = {
id: 1,
config_id: 1,
monitor_tag: null,
incident_id: 4,
alert_status: Math.random() > 0.5 ? "TRIGGERED" : "RESOLVED",
created_at: new Date(),
@@ -716,7 +718,7 @@ async function uploadImage(data: ImageUploadData): Promise<{ id: string; url: st
throw new Error("Image data is required");
}
const allowedMimeTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
const allowedMimeTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp", "image/heic", "image/heif"];
if (!allowedMimeTypes.includes(mimeType)) {
throw new Error(`Invalid image type. Allowed types: ${allowedMimeTypes.join(", ")}`);
}
@@ -744,8 +746,21 @@ async function uploadImage(data: ImageUploadData): Promise<{ id: string; url: st
let width: number | undefined;
let height: number | undefined;
// Pre-convert HEIC/HEIF to JPEG before passing to sharp (sharp may lack HEVC codec)
let sharpInputBuffer = imageBuffer;
const heicSignature = imageBuffer.subarray(4, 12).toString("ascii");
const isHeicData = heicSignature.includes("ftyp");
if (isHeicData) {
const converted = await heicConvert({
buffer: new Uint8Array(imageBuffer) as unknown as ArrayBuffer,
format: "JPEG",
quality: 0.85,
});
sharpInputBuffer = Buffer.from(converted);
}
// Process with sharp and normalize output
const image = sharp(imageBuffer, { limitInputPixels: GC.MAX_INPUT_PIXELS });
const image = sharp(sharpInputBuffer, { limitInputPixels: GC.MAX_INPUT_PIXELS });
const metadata = await image.metadata();
const formatToMime: Record<string, string> = {
@@ -753,6 +768,8 @@ async function uploadImage(data: ImageUploadData): Promise<{ id: string; url: st
jpeg: "image/jpeg",
webp: "image/webp",
svg: "image/svg+xml",
heic: "image/heic",
heif: "image/heif",
};
const detectedMimeType = metadata.format ? formatToMime[metadata.format] : undefined;
@@ -764,7 +781,10 @@ async function uploadImage(data: ImageUploadData): Promise<{ id: string; url: st
throw new Error("SVG uploads are not allowed");
}
if (normalizedRequestedMime !== detectedMimeType) {
// HEIC/HEIF files often have .jpg extension (e.g. iPhone photos); allow the mismatch
const isHeicDetected = detectedMimeType === "image/heic" || detectedMimeType === "image/heif";
const isHeicRequested = normalizedRequestedMime === "image/heic" || normalizedRequestedMime === "image/heif";
if (normalizedRequestedMime !== detectedMimeType && !isHeicDetected && !isHeicRequested) {
throw new Error("Image MIME type does not match file content");
}
@@ -796,8 +816,8 @@ async function uploadImage(data: ImageUploadData): Promise<{ id: string; url: st
width = newWidth;
height = newHeight;
// Keep JPEG as JPEG; convert everything else (WebP/PNG) to PNG.
if (detectedMimeType === "image/jpeg") {
// Keep JPEG as JPEG; convert HEIC/HEIF to JPEG; convert everything else (WebP/PNG) to PNG.
if (detectedMimeType === "image/jpeg" || isHeicDetected) {
processedBuffer = await image
.resize(newWidth, newHeight, {
fit: forceDimensions ? "cover" : "inside",
@@ -164,7 +164,7 @@
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Monitor</Table.Head>
<Table.Head>Monitors</Table.Head>
<Table.Head>Alert Type</Table.Head>
<Table.Head>Severity</Table.Head>
<Table.Head>Description</Table.Head>
@@ -198,12 +198,23 @@
{#each configs as config (config.id)}
<Table.Row class={config.is_active === GC.NO ? "opacity-60" : ""}>
<Table.Cell>
<a
href={clientResolver(resolve, `/manage/app/monitors/${config.monitor_tag}`)}
class="text-primary font-medium hover:underline"
>
{config.monitor_tag}
</a>
{#if config.monitor_tags && config.monitor_tags.length > 0}
<div class="flex flex-wrap gap-1">
{#each config.monitor_tags as tag}
<a
href={clientResolver(resolve, `/manage/app/monitors/${tag}`)}
class="text-primary text-sm font-medium hover:underline"
>
{monitors.find((m) => m.tag === tag)?.name || tag}
</a>
{#if config.monitor_tags.indexOf(tag) < config.monitor_tags.length - 1}
<span class="text-muted-foreground">,</span>
{/if}
{/each}
</div>
{:else}
<span class="text-muted-foreground text-sm">-</span>
{/if}
</Table.Cell>
<Table.Cell>
<Badge variant="outline">{config.alert_for}</Badge>
@@ -15,8 +15,13 @@
import * as Select from "$lib/components/ui/select/index.js";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
import * as Breadcrumb from "$lib/components/ui/breadcrumb/index.js";
import * as Popover from "$lib/components/ui/popover/index.js";
import * as Command from "$lib/components/ui/command/index.js";
import ArrowLeftIcon from "@lucide/svelte/icons/arrow-left";
import TrashIcon from "@lucide/svelte/icons/trash";
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
import CheckIcon from "@lucide/svelte/icons/check";
import XIcon from "@lucide/svelte/icons/x";
import GC from "$lib/global-constants";
import type {
TriggerRecord,
@@ -39,10 +44,11 @@
let triggers = $state<TriggerRecord[]>([]);
let monitors = $state<{ tag: string; name: string }[]>([]);
let deleteDialogOpen = $state(false);
let monitorPopoverOpen = $state(false);
// Form state
const defaultForm = {
monitor_tag: "",
monitor_tags: [] as string[],
alert_for: "STATUS" as AlertForType,
alert_value: "DOWN",
failure_threshold: 3,
@@ -103,6 +109,14 @@
}
}
function toggleMonitor(monitorTag: string) {
if (form.monitor_tags.includes(monitorTag)) {
form.monitor_tags = form.monitor_tags.filter((tag) => tag !== monitorTag);
} else {
form.monitor_tags = [...form.monitor_tags, monitorTag];
}
}
function toggleTrigger(triggerId: number) {
if (form.trigger_ids.includes(triggerId)) {
form.trigger_ids = form.trigger_ids.filter((id) => id !== triggerId);
@@ -166,7 +180,7 @@
} else {
const config = result as MonitorAlertConfigWithTriggers;
form = {
monitor_tag: config.monitor_tag,
monitor_tags: config.monitor_tags || [],
alert_for: config.alert_for,
alert_value: config.alert_value,
failure_threshold: config.failure_threshold,
@@ -186,8 +200,8 @@
}
async function saveAlertConfig() {
if (!form.monitor_tag) {
toast.error("Please select a monitor");
if (form.monitor_tags.length === 0) {
toast.error("Please select at least one monitor");
return;
}
@@ -195,7 +209,7 @@
try {
const action = isNew ? "createMonitorAlertConfig" : "updateMonitorAlertConfig";
const data: Record<string, unknown> = {
monitor_tag: form.monitor_tag,
monitor_tags: form.monitor_tags,
alert_for: form.alert_for,
alert_value: form.alert_value,
failure_threshold: form.failure_threshold,
@@ -287,28 +301,58 @@
{:else}
<Card.Root>
<Card.Content class="space-y-6 pt-6">
<!-- Monitor Selection -->
<!-- Monitor Selection (Searchable Multi-select) -->
<div class="flex flex-col gap-2">
<Label for="monitor">Monitor</Label>
<Select.Root
type="single"
value={form.monitor_tag}
onValueChange={(v) => v && (form.monitor_tag = v)}
disabled={!isNew}
>
<Select.Trigger id="monitor" class="w-full">
{form.monitor_tag
? monitors.find((m) => m.tag === form.monitor_tag)?.name || form.monitor_tag
: "Select monitor"}
</Select.Trigger>
<Select.Content>
{#each monitors as monitor}
<Select.Item value={monitor.tag}>{monitor.name}</Select.Item>
<Label>Monitors</Label>
<p class="text-muted-foreground text-xs">Select which monitors this alert applies to</p>
<Popover.Root bind:open={monitorPopoverOpen}>
<Popover.Trigger>
<Button variant="outline" role="combobox" class="w-full justify-between font-normal">
{#if form.monitor_tags.length === 0}
Select monitors...
{:else if form.monitor_tags.length === 1}
{monitors.find((m) => m.tag === form.monitor_tags[0])?.name || form.monitor_tags[0]}
{:else}
{form.monitor_tags.length} monitors selected
{/if}
<ChevronsUpDownIcon class="text-muted-foreground size-4 shrink-0" />
</Button>
</Popover.Trigger>
<Popover.Content class="w-[var(--bits-popover-trigger-width)] p-0" align="start">
<Command.Root>
<Command.Input placeholder="Search monitors..." />
<Command.List>
<Command.Empty>No monitors found.</Command.Empty>
<Command.Group>
{#each monitors as monitor (monitor.tag)}
<Command.Item
value={monitor.name}
onSelect={() => toggleMonitor(monitor.tag)}
>
<CheckIcon class="size-4 {form.monitor_tags.includes(monitor.tag) ? 'opacity-100' : 'opacity-0'}" />
{monitor.name}
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
{#if form.monitor_tags.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each form.monitor_tags as tag (tag)}
<Badge variant="secondary" class="gap-1 pr-1">
{monitors.find((m) => m.tag === tag)?.name || tag}
<button
type="button"
class="hover:bg-muted rounded-sm p-0.5"
onclick={() => toggleMonitor(tag)}
>
<XIcon class="size-3" />
</button>
</Badge>
{/each}
</Select.Content>
</Select.Root>
{#if !isNew}
<p class="text-muted-foreground text-xs">Monitor cannot be changed after creation</p>
</div>
{/if}
</div>
@@ -3,6 +3,7 @@
import { onMount } from "svelte";
import { Button } from "$lib/components/ui/button/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import { Checkbox } from "$lib/components/ui/checkbox/index.js";
import * as Table from "$lib/components/ui/table/index.js";
import * as Select from "$lib/components/ui/select/index.js";
@@ -232,6 +233,7 @@
<Table.Header>
<Table.Row>
<Table.Head class="w-16">ID</Table.Head>
<Table.Head class="w-40">Monitor</Table.Head>
<Table.Head class="w-40">Status</Table.Head>
<Table.Head class="w-32">Incident</Table.Head>
<Table.Head class="w-44">Created At</Table.Head>
@@ -243,6 +245,13 @@
{#each alerts as alert (alert.id)}
<Table.Row class="hover:bg-muted/50">
<Table.Cell class="font-medium">{alert.id}</Table.Cell>
<Table.Cell>
{#if alert.monitor_tag}
<Badge variant="outline">{alert.monitor_tag}</Badge>
{:else}
<span class="text-muted-foreground text-sm"></span>
{/if}
</Table.Cell>
<Table.Cell>
<Select.Root
type="single"
@@ -50,6 +50,20 @@
placeholder: "G-S05E5E5E5E5",
required: true,
value: ""
},
{
label: "Transport URL",
type: "text",
placeholder: "https://www.google-analytics.com",
required: false,
value: ""
},
{
label: "Script Host",
type: "text",
placeholder: "https://www.googletagmanager.com",
required: false,
value: ""
}
]
},
@@ -287,7 +301,15 @@
</script>
<div class="flex w-full flex-col gap-4 px-4">
<div>Add your analytics ID/Key here. You can add multiple analytics providers.</div>
<div>
Add your analytics ID/Key here. You can add multiple analytics providers.
<a
href="https://kener.ing/docs/v4/analytics"
target="_blank"
rel="noopener noreferrer"
class="text-primary ml-1 underline underline-offset-2">Learn more</a
>.
</div>
{#if loading}
<div class="flex items-center justify-center py-8">
<Spinner />
@@ -724,7 +724,7 @@
<p class="text-muted-foreground mt-4 text-sm">
Want to upload and use custom fonts? Read more about it in the
<a
href={clientResolver(resolve, "/docs/v4/guides/custom-fonts")}
href="https://kener.ing/docs/v4/guides/custom-fonts"
target="_blank"
class="text-foreground underline underline-offset-4"
>
@@ -968,7 +968,7 @@
Add custom CSS to further customize the appearance of your status page. Do not include &lt;style&gt; tags.
Learn more in the
<a
href={clientResolver(resolve, "/docs/v4/guides/custom-js-css-guide")}
href="https://kener.ing/docs/v4/guides/custom-js-css-guide"
target="_blank"
class="text-foreground underline underline-offset-4"
>
@@ -204,7 +204,7 @@
<input
id="monitor-image-input"
type="file"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp,image/heic,image/heif"
class="hidden"
onchange={handleImageUpload}
disabled={uploadingImage}
@@ -86,7 +86,8 @@
// Page settings state
let pageSettings = $state<PageSettingsType>(structuredClone(defaultPageSettings));
let savingSettings = $state(false);
let savingDisplaySettings = $state(false);
let savingSeoSettings = $state(false);
// Validation
const isFormValid = $derived(formData.page_title.trim().length > 0 && formData.page_header.trim().length > 0);
@@ -381,6 +382,10 @@
})
});
if (!response.ok) {
toast.error("Failed to upload logo");
return;
}
const result = await response.json();
if (result.error) {
toast.error(result.error);
@@ -452,6 +457,10 @@
})
});
if (!response.ok) {
toast.error("Failed to upload social preview image");
return;
}
const result = await response.json();
if (result.error) {
toast.error(result.error);
@@ -467,10 +476,11 @@
}
}
async function savePageSettings() {
async function savePageSettings(source: "display" | "seo") {
if (!currentPage) return;
savingSettings = true;
if (source === "display") savingDisplaySettings = true;
else savingSeoSettings = true;
try {
const response = await fetch(clientResolver(resolve, "/manage/api"), {
method: "POST",
@@ -493,7 +503,8 @@
} catch (e) {
toast.error("Failed to save page settings");
} finally {
savingSettings = false;
if (source === "display") savingDisplaySettings = false;
else savingSeoSettings = false;
}
}
@@ -635,7 +646,7 @@
<input
id="page-logo-input"
type="file"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp,image/heic,image/heif"
class="hidden"
onchange={handleLogoUpload}
disabled={uploadingLogo}
@@ -830,8 +841,8 @@
</div>
</Card.Content>
<Card.Footer class="flex justify-end">
<Button onclick={savePageSettings} disabled={savingSettings}>
{#if savingSettings}
<Button onclick={() => savePageSettings("display")} disabled={savingDisplaySettings}>
{#if savingDisplaySettings}
<Loader class="h-4 w-4 animate-spin" />
Saving...
{:else}
@@ -885,7 +896,7 @@
<input
id="page-social-preview-input"
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp"
accept="image/png,image/jpeg,image/jpg,image/webp,image/heic,image/heif"
class="hidden"
onchange={handleSocialPreviewUpload}
disabled={uploadingSocialPreview}
@@ -926,8 +937,8 @@
</div>
</Card.Content>
<Card.Footer class="flex justify-end">
<Button onclick={savePageSettings} disabled={savingSettings}>
{#if savingSettings}
<Button onclick={() => savePageSettings("seo")} disabled={savingSeoSettings}>
{#if savingSeoSettings}
<Loader class="h-4 w-4 animate-spin" />
Saving...
{:else}
@@ -21,11 +21,13 @@
import { toast } from "svelte-sonner";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
import { page } from "$app/state";
import type {
DataRetentionPolicy,
EventDisplaySettings,
GlobalPageVisibilitySettings,
SitemapXMLConfig
SitemapXMLConfig,
GlobalMaintenanceNotificationSettings
} from "$lib/types/site.js";
interface NavItem {
@@ -47,25 +49,12 @@
let savingDataRetentionPolicy = $state(false);
let savingEventDisplaySettings = $state(false);
let savingSitemap = $state(false);
let savingMaintenanceNotificationSettings = $state(false);
let uploadingLogo = $state(false);
let uploadingFavicon = $state(false);
let uploadingSocialPreviewImage = $state(false);
const defaultEventDisplaySettings: EventDisplaySettings = {
incidents: {
enabled: true,
ongoing: { show: true },
resolved: { show: true, maxCount: 5, daysInPast: 7 }
},
maintenances: {
enabled: true,
ongoing: {
show: true
},
past: { show: true, maxCount: 5, daysInPast: 7 },
upcoming: { show: true, maxCount: 5, daysInFuture: 7 }
}
};
const defaultEventDisplaySettings: EventDisplaySettings = page.data.seedSiteData.eventDisplaySettings;
interface SiteDataForm {
siteName: string;
@@ -93,30 +82,28 @@
showShareEmbedMonitor: true
});
const defaultGlobalPageVisibilitySettings: GlobalPageVisibilitySettings = {
showSwitcher: true,
forceExclusivity: false
};
const defaultGlobalPageVisibilitySettings: GlobalPageVisibilitySettings =
page.data.seedSiteData.globalPageVisibilitySettings;
let globalPageVisibilitySettings = $state<GlobalPageVisibilitySettings>(
structuredClone(defaultGlobalPageVisibilitySettings)
);
let dataRetentionPolicy = $state<DataRetentionPolicy>({
enabled: true,
retentionDays: 90
});
let dataRetentionPolicy = $state<DataRetentionPolicy>(page.data.seedSiteData.dataRetentionPolicy);
let eventDisplaySettings = $state<EventDisplaySettings>(structuredClone(defaultEventDisplaySettings));
let metaSiteTitle = $state("");
let metaSiteDescription = $state("");
const defaultSitemap: SitemapXMLConfig = {
mode: "off",
urls: []
};
const defaultSitemap: SitemapXMLConfig = page.data.seedSiteData.sitemap;
let sitemap = $state<SitemapXMLConfig>(structuredClone(defaultSitemap));
const defaultMaintenanceNotificationSettings: GlobalMaintenanceNotificationSettings =
page.data.seedSiteData.globalMaintenanceNotificationSettings;
let maintenanceNotificationSettings = $state<GlobalMaintenanceNotificationSettings>(
structuredClone(defaultMaintenanceNotificationSettings)
);
const sitemapURL = $derived(
siteData.siteURL ? siteData.siteURL.replace(/\/$/, "") + clientResolver(resolve, "/sitemap.xml") : ""
);
@@ -244,6 +231,27 @@
} else {
sitemap = structuredClone(defaultSitemap);
}
if (data.globalMaintenanceNotificationSettings) {
try {
const parsed =
typeof data.globalMaintenanceNotificationSettings === "string"
? JSON.parse(data.globalMaintenanceNotificationSettings)
: data.globalMaintenanceNotificationSettings;
maintenanceNotificationSettings = {
...structuredClone(defaultMaintenanceNotificationSettings),
...parsed,
event_types: {
...structuredClone(defaultMaintenanceNotificationSettings.event_types),
...parsed?.event_types
}
};
} catch {
maintenanceNotificationSettings = structuredClone(defaultMaintenanceNotificationSettings);
}
} else {
maintenanceNotificationSettings = structuredClone(defaultMaintenanceNotificationSettings);
}
}
} catch (e) {
toast.error("Failed to load site data");
@@ -543,13 +551,53 @@
}
}
async function saveMaintenanceNotificationSettings() {
const bufferHours = Number(maintenanceNotificationSettings.reminder_buffer_hours);
if (!Number.isFinite(bufferHours) || bufferHours < 1) {
toast.error("Reminder buffer hours must be a number of at least 1");
return;
}
savingMaintenanceNotificationSettings = true;
try {
const payload: GlobalMaintenanceNotificationSettings = {
event_types: {
created: maintenanceNotificationSettings.event_types.created,
reminder: maintenanceNotificationSettings.event_types.reminder,
started: maintenanceNotificationSettings.event_types.started,
ended: maintenanceNotificationSettings.event_types.ended
},
reminder_buffer_hours: Math.max(1, Math.floor(bufferHours))
};
const response = await fetch(clientResolver(resolve, "/manage/api"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "storeSiteData",
data: { globalMaintenanceNotificationSettings: JSON.stringify(payload) }
})
});
const result = await response.json();
if (result.error) {
toast.error(result.error);
} else {
maintenanceNotificationSettings.reminder_buffer_hours = payload.reminder_buffer_hours;
toast.success("Maintenance notification settings saved successfully");
}
} catch (e) {
toast.error("Failed to save maintenance notification settings");
} finally {
savingMaintenanceNotificationSettings = false;
}
}
async function handleImageUpload(event: Event, type: "logo" | "favicon" | "socialPreviewImage"): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// Validate file type
const allowedTypes = ["image/png", "image/jpeg", "image/jpg", "image/svg+xml", "image/webp"];
const allowedTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
if (!allowedTypes.includes(file.type)) {
toast.error("Invalid file type. Allowed: PNG, JPG, SVG, WebP");
return;
@@ -792,7 +840,7 @@
<input
id="logo-input"
type="file"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp"
accept="image/png,image/jpeg,image/jpg,image/webp,image/heic,image/heif"
class="hidden"
onchange={(e) => handleImageUpload(e, "logo")}
disabled={uploadingLogo}
@@ -862,7 +910,7 @@
<input
id="favicon-input"
type="file"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp"
accept="image/png,image/jpeg,image/jpg,image/webp,image/heic,image/heif"
class="hidden"
onchange={(e) => handleImageUpload(e, "favicon")}
disabled={uploadingFavicon}
@@ -932,7 +980,7 @@
<input
id="social-preview-image-input"
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp"
accept="image/png,image/jpeg,image/jpg,image/webp,image/heic,image/heif"
class="hidden"
onchange={(e) => handleImageUpload(e, "socialPreviewImage")}
disabled={uploadingSocialPreviewImage}
@@ -1028,7 +1076,7 @@
<input
id="nav-icon-input-{index}"
type="file"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp"
accept="image/png,image/jpeg,image/jpg,image/webp,image/heic,image/heif"
class="hidden"
onchange={(e) => handleNavIconUpload(e, index)}
/>
@@ -1446,5 +1494,100 @@
</Button>
</Card.Footer>
</Card.Root>
<!-- Maintenance Notification Settings Card -->
<Card.Root>
<Card.Header>
<Card.Title>Maintenance Notification Settings</Card.Title>
<Card.Description
>Configure which maintenance lifecycle events trigger subscriber notifications</Card.Description
>
</Card.Header>
<Card.Content class="space-y-6">
<div class="space-y-4">
<Label>Event Types</Label>
<div class="grid gap-4 sm:grid-cols-2">
<div class="flex items-center justify-between gap-2 rounded-lg border p-3">
<div>
<p class="text-sm font-medium">Created</p>
<p class="text-muted-foreground text-xs">When a maintenance is created</p>
</div>
<Switch
checked={maintenanceNotificationSettings.event_types.created}
onCheckedChange={(v) => {
maintenanceNotificationSettings.event_types.created = v === true;
}}
/>
</div>
<div class="flex items-center justify-between gap-2 rounded-lg border p-3">
<div>
<p class="text-sm font-medium">Reminder</p>
<p class="text-muted-foreground text-xs">Before a scheduled maintenance starts</p>
</div>
<Switch
checked={maintenanceNotificationSettings.event_types.reminder}
onCheckedChange={(v) => {
maintenanceNotificationSettings.event_types.reminder = v === true;
}}
/>
</div>
<div class="flex items-center justify-between gap-2 rounded-lg border p-3">
<div>
<p class="text-sm font-medium">Started</p>
<p class="text-muted-foreground text-xs">When a maintenance begins</p>
</div>
<Switch
checked={maintenanceNotificationSettings.event_types.started}
onCheckedChange={(v) => {
maintenanceNotificationSettings.event_types.started = v === true;
}}
/>
</div>
<div class="flex items-center justify-between gap-2 rounded-lg border p-3">
<div>
<p class="text-sm font-medium">Ended</p>
<p class="text-muted-foreground text-xs">When a maintenance completes</p>
</div>
<Switch
checked={maintenanceNotificationSettings.event_types.ended}
onCheckedChange={(v) => {
maintenanceNotificationSettings.event_types.ended = v === true;
}}
/>
</div>
</div>
</div>
{#if maintenanceNotificationSettings.event_types.reminder}
<div class="space-y-2">
<Label for="reminder-buffer-hours">Reminder Buffer (hours)</Label>
<Input
id="reminder-buffer-hours"
type="number"
min={1}
bind:value={maintenanceNotificationSettings.reminder_buffer_hours}
/>
<p class="text-muted-foreground text-xs">
How many hours before the maintenance start time to send the reminder notification
</p>
</div>
{/if}
</Card.Content>
<Card.Footer class="flex justify-end">
<Button
onclick={saveMaintenanceNotificationSettings}
disabled={savingMaintenanceNotificationSettings}
class="cursor-pointer"
>
{#if savingMaintenanceNotificationSettings}
<Loader class="h-4 w-4 animate-spin" />
Saving...
{:else}
<SaveIcon class="h-4 w-4" />
Save
{/if}
</Button>
</Card.Footer>
</Card.Root>
{/if}
</div>
+1 -1
View File
@@ -1,4 +1,4 @@
@import url("https://fonts.googleapis.com/css2?family=Bodoni+Moda:ital,opsz,wght@0,6..96,400..900;1,6..96,400..900&display=swap");
@import url("https://fonts.bunny.net/css?family=bodoni-moda:400,400i,500,500i,600,600i,700,700i,800,800i,900,900i&family=plus-jakarta-sans:300,400,500,600,700,800");
/* Prose/Content Body Styles - needed for markdown rendering */
.docs-content-body {