mirror of
https://github.com/rajnandan1/kener.git
synced 2026-08-07 15:27:13 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db6cb6cf7d | |||
| babeeb75b2 | |||
| e0187605e7 | |||
| e2861f1e59 | |||
| 01aa4d9984 | |||
| 555372f175 | |||
| bd5fd409d1 | |||
| f61d82e13f | |||
| f39588fff7 | |||
| 0716f271df | |||
| 1085c3e561 | |||
| 93907e6d96 | |||
| d03fd63c64 | |||
| 3ff43af787 | |||
| 7f2fef8e8e | |||
| 7658170865 | |||
| b1f8a505a5 | |||
| 104da58646 | |||
| ed6e97e8c9 | |||
| 0b25274874 | |||
| bb8ab41bfe | |||
| b43a5fb343 | |||
| 554caa5018 | |||
| ffe7403043 | |||
| a26d0ece59 | |||
| a4277f7ed0 | |||
| f75aaf9cef | |||
| 4e1ecf41ee | |||
| f23fb5313c | |||
| 18489c5339 | |||
| 4044bae26d | |||
| 02686caa78 | |||
| 797aef80d6 | |||
| a8841ad8a3 | |||
| 0b2cd5fc8a |
@@ -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"
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+270
-221
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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": "Poslední stav",
|
||||
"Latest Status": "Naposledy zjištěný stav",
|
||||
"Light": "Světlý",
|
||||
"Live Status": "Aktuální stav",
|
||||
"Loading your preferences...": "Načítám vaše nastavení...",
|
||||
"maintenance": "Údržba",
|
||||
"Loading your preferences...": "Načítání nastavení...",
|
||||
"maintenance": "údržba",
|
||||
"Maintenance": "Údržba",
|
||||
"MAINTENANCE": "ÚDRŽBA",
|
||||
"Maintenance Updates": "Aktualizace údržby",
|
||||
"Maintenances": "Údrždy",
|
||||
"Maintenances": "Údržby",
|
||||
"Major System Outage": "Závažný výpadek systému",
|
||||
"Manage Site": "Spravovat stránku",
|
||||
"Manage your notification preferences.": "Spravujte svá nastavení oznámení.",
|
||||
"Manage your notification preferences.": "Spravujte nastavení oznámení",
|
||||
"Max Latency": "Max. latence",
|
||||
"maximum": "maximální",
|
||||
"Maximum Latency": "Maximální latence",
|
||||
@@ -82,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "رخداد",
|
||||
"Incident Updates": "به روز رسانی حادثه",
|
||||
"Incidents": "حوادث",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "آخرین به روز رسانی",
|
||||
"Latency": "تأخیر",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "घटना",
|
||||
"Incident Updates": "घटना अपडेट",
|
||||
"Incidents": "घटनाएँ",
|
||||
"Included Monitors": "शामिल मॉनिटर",
|
||||
"Included Monitors (%count)": "शामिल मॉनिटर (%count)",
|
||||
"INVESTIGATING": "जाँच कर रहे हैं",
|
||||
"Last Updated": "अंतिम अपडेट",
|
||||
"Latency": "लेटेंसी",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "インシデント",
|
||||
"Incident Updates": "インシデントの最新情報",
|
||||
"Incidents": "事件",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "最終更新日",
|
||||
"Latency": "レイテンシー",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "인시던트",
|
||||
"Incident Updates": "사고 업데이트",
|
||||
"Incidents": "사건",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "마지막 업데이트",
|
||||
"Latency": "숨어 있음",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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": "Odoberať",
|
||||
"Subscribe": "Prihlásiť sa na odber",
|
||||
"Subscribe to Updates": "Odoberať aktualizácie",
|
||||
"Theme": "Motív",
|
||||
"There are no incidents or maintenances scheduled for this month.": "Na tento mesiac nie sú naplánované incidenty ani údržba.",
|
||||
"There are no ongoing incidents or maintenance events.": "V tejto chvíli neprebiehajú žiadne incidenty ani údržba.",
|
||||
"There are no incidents or maintenances scheduled for this month.": "Na tento mesiac nie sú naplánované žiadne incidenty ani údržby",
|
||||
"There are no ongoing incidents or maintenance events.": "Momentálne neprebiehajú žiadne incidenty ani údržba",
|
||||
"Timeline": "Časová os",
|
||||
"Total Incidents": "Celkom incidentov",
|
||||
"Total Maintenances": "Celkom údržieb",
|
||||
@@ -151,6 +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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ễ",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"incident": "事件",
|
||||
"Incident Updates": "事件更新",
|
||||
"Incidents": "事件",
|
||||
"Included Monitors": "Included Monitors",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"Last Updated": "最后更新",
|
||||
"Latency": "延迟",
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()}`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
<ModeWatcher />
|
||||
<Toaster />
|
||||
|
||||
<svelte:head></svelte:head>
|
||||
<main>
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+595
-280
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 (10–30 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 ~$15–20/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 ($21–41/user/mo)
|
||||
- Subscriber limits (250–500 depending on plan)
|
||||
- Primarily an incident management tool — status page is secondary
|
||||
- No self-hosting, significant vendor lock-in
|
||||
- Annual renewal price increases reported (10–15%)
|
||||
|
||||
## 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) | 1–5 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) | $29–1,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.
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 <style> 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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user