mirror of
https://github.com/rajnandan1/kener.git
synced 2026-08-07 23:32:02 +00:00
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10586108c5 | |||
| 87c69201ab | |||
| a3ec81af20 | |||
| 61acf53c10 | |||
| 8bbafe4c8a | |||
| 4589568405 | |||
| e8fb4126a8 | |||
| e9d3281067 | |||
| 7439632eff | |||
| f0362fd919 | |||
| e61873164b | |||
| 6d7b56a0ac | |||
| 0940c8d01e | |||
| 17e3fa6d77 | |||
| a363079695 | |||
| 87fc3081df | |||
| 350e291db0 | |||
| 9a545dbf48 | |||
| ab527ff7d8 | |||
| 850ebae11a | |||
| a6948f087c | |||
| aaa7c2a46d | |||
| 8edf92ea02 | |||
| e5e7e44471 | |||
| f5ab338e2b | |||
| 5012ff1421 | |||
| 122ca71b8e | |||
| e27ab6ff7d | |||
| c301aaab90 | |||
| 4ed40a0b08 | |||
| 1ac0f2259f | |||
| 544bdc9dcb | |||
| a843ac2926 | |||
| ccceeb38bd | |||
| b01560c29b | |||
| 80c5e298d7 | |||
| 3d1335bf40 | |||
| 604210568b | |||
| 41f5296227 | |||
| 8c1a97d844 | |||
| ed1a70d75b | |||
| e63a2f6311 | |||
| 951ab06f7e | |||
| af4684a90f | |||
| da6eaee3ab | |||
| f264115ab8 | |||
| 15b78dab66 | |||
| 54277ece9a | |||
| 54f056ad34 | |||
| 8d2808c291 | |||
| bd638ccf24 | |||
| c2945485e2 | |||
| a57c92fc0e | |||
| 8e7bc47b14 | |||
| cd26c46493 | |||
| 508b08f8f3 | |||
| 638393efac | |||
| 5a54d69d87 | |||
| 7e5ea5fda1 | |||
| 175cf605c6 | |||
| 6a9bfffbd4 | |||
| 7b120911b4 | |||
| 35817bc20a | |||
| a8fbac1b69 | |||
| cfc99e2f14 | |||
| 31ba10f434 | |||
| 1750e2a341 | |||
| a12df92b94 | |||
| 5d86084138 | |||
| 7050f780a3 | |||
| cb93089dcc | |||
| fcd05e1d68 | |||
| db9d7807e0 | |||
| b7e0756c54 | |||
| b920d2f9bc | |||
| 560c87219b | |||
| 94e24eec04 | |||
| 15680a58aa | |||
| 59f0eaef27 | |||
| 8362a73058 | |||
| 52f8c50f50 | |||
| 60868d55ca | |||
| f7e657ee95 | |||
| 63e5ec2886 | |||
| 2aef97c1ed | |||
| 51b2da97e0 | |||
| 50bddcd9a3 | |||
| bd36533b05 | |||
| 17500a0b43 | |||
| 0050cd810b |
+6
-1
@@ -34,4 +34,9 @@ temp.js
|
||||
.DS_Store
|
||||
knip-output.txt
|
||||
check-output.txt
|
||||
translation-report.json
|
||||
translation-report.json
|
||||
|
||||
# AI workflow docs (not version-controlled)
|
||||
CONTEXT.md
|
||||
docs/adr/
|
||||
docs/superpowers/
|
||||
@@ -121,3 +121,17 @@ Read `.claude/skills/` for specialized instructions on:
|
||||
- **svelte-code-writer** - Svelte component creation/editing
|
||||
- **documentation-writer** - Editing docs in `src/routes/(docs)/docs/content/`
|
||||
- **tailwindcss** - Tailwind CSS v4 patterns
|
||||
|
||||
## Agent skills
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Issues and PRDs are tracked in GitHub Issues for `rajnandan1/kener`. See `docs/agents/issue-tracker.md`.
|
||||
|
||||
### Triage labels
|
||||
|
||||
Triage uses the default mattpocock/skills label vocabulary. See `docs/agents/triage-labels.md`.
|
||||
|
||||
### Domain docs
|
||||
|
||||
This repo uses a single-context domain-doc layout. See `docs/agents/domain.md`.
|
||||
|
||||
@@ -163,6 +163,7 @@ COPY --chown=node:node --from=builder /app/seeds ./seeds
|
||||
COPY --chown=node:node --from=builder /app/src/lib/server/db/seedSiteData.ts ./src/lib/server/db/seedSiteData.ts
|
||||
COPY --chown=node:node --from=builder /app/src/lib/server/db/seedMonitorData.ts ./src/lib/server/db/seedMonitorData.ts
|
||||
COPY --chown=node:node --from=builder /app/src/lib/server/db/seedPagesData.ts ./src/lib/server/db/seedPagesData.ts
|
||||
COPY --chown=node:node --from=builder /app/src/lib/allPerms.ts ./src/lib/allPerms.ts
|
||||
COPY --chown=node:node --from=builder /app/src/lib/server/templates/general ./src/lib/server/templates/general
|
||||
|
||||
# Locale JSON files (read at runtime by server-side i18n)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Domain Docs
|
||||
|
||||
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
|
||||
|
||||
This repo is configured as a **single-context** repo.
|
||||
|
||||
## Before exploring, read these
|
||||
|
||||
- **`CONTEXT.md`** at the repo root.
|
||||
- **`docs/adr/`** — read ADRs that touch the area you're about to work in.
|
||||
|
||||
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved.
|
||||
|
||||
## File structure
|
||||
|
||||
Single-context repo:
|
||||
|
||||
```text
|
||||
/
|
||||
├── CONTEXT.md
|
||||
├── docs/adr/
|
||||
│ ├── 0001-event-sourced-orders.md
|
||||
│ └── 0002-postgres-for-write-model.md
|
||||
└── src/
|
||||
```
|
||||
|
||||
## Use the glossary's vocabulary
|
||||
|
||||
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
|
||||
|
||||
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`).
|
||||
|
||||
## Flag ADR conflicts
|
||||
|
||||
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
|
||||
|
||||
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
|
||||
@@ -0,0 +1,22 @@
|
||||
# Issue tracker: GitHub
|
||||
|
||||
Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
|
||||
- **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
|
||||
- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
|
||||
- **Comment on an issue**: `gh issue comment <number> --body "..."`
|
||||
- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
|
||||
- **Close**: `gh issue close <number> --comment "..."`
|
||||
|
||||
Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone.
|
||||
|
||||
## When a skill says "publish to the issue tracker"
|
||||
|
||||
Create a GitHub issue.
|
||||
|
||||
## When a skill says "fetch the relevant ticket"
|
||||
|
||||
Run `gh issue view <number> --comments`.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Triage Labels
|
||||
|
||||
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
|
||||
|
||||
| Label in mattpocock/skills | Label in our tracker | Meaning |
|
||||
| -------------------------- | -------------------- | ---------------------------------------- |
|
||||
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
|
||||
| `needs-info` | `needs-info` | Waiting on reporter for more information |
|
||||
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
|
||||
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
||||
| `wontfix` | `wontfix` | Will not be actioned |
|
||||
|
||||
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
|
||||
|
||||
Edit the right-hand column to match whatever vocabulary you actually use.
|
||||
+47
-3
@@ -7,13 +7,45 @@ const databaseURLParts = databaseURL.split("://");
|
||||
const databaseType = databaseURLParts[0];
|
||||
const databasePath = databaseURLParts[1];
|
||||
|
||||
const intFromEnv = (name: string, fallback: number): number => {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined) return fallback;
|
||||
const parsed = parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
};
|
||||
|
||||
// TCP keepalive on pooled connections, on by default. Cloud networks (Railway,
|
||||
// Docker Swarm overlays, k8s) silently drop idle TCP connections; without
|
||||
// keepalive the pool keeps handing out dead sockets after an idle period or a
|
||||
// database restart. See docs/adr/0003-fail-fast-self-healing-db-pool.md.
|
||||
const keepAliveEnabled = process.env.DATABASE_KEEPALIVE !== "false";
|
||||
|
||||
// Pool defaults deviate from knex's on purpose:
|
||||
// - min 0: knex's min 2 connections are never reaped, so they are exactly the
|
||||
// ones that go stale and wedge the app until a manual restart
|
||||
// - 15s acquire/create timeouts: fail fast instead of hanging requests for
|
||||
// knex's default 60s during a database outage
|
||||
// Tarn requires max >= 1 and min <= max; clamp so a bad env value can not
|
||||
// produce a pool that fails every acquire
|
||||
const poolMax = Math.max(1, intFromEnv("DATABASE_POOL_MAX", 10));
|
||||
const poolMin = Math.min(intFromEnv("DATABASE_POOL_MIN", 0), poolMax);
|
||||
const pool = {
|
||||
min: poolMin,
|
||||
max: poolMax,
|
||||
idleTimeoutMillis: intFromEnv("DATABASE_IDLE_TIMEOUT_MS", 30000),
|
||||
createTimeoutMillis: intFromEnv("DATABASE_CREATE_TIMEOUT_MS", 15000),
|
||||
};
|
||||
const acquireConnectionTimeout = intFromEnv("DATABASE_ACQUIRE_TIMEOUT_MS", 15000);
|
||||
|
||||
interface KnexConfig {
|
||||
migrations: { directory: string };
|
||||
seeds: { directory: string };
|
||||
databaseType: string;
|
||||
client?: string;
|
||||
connection?: string | { filename: string };
|
||||
connection?: string | { filename: string } | Record<string, unknown>;
|
||||
useNullAsDefault?: boolean;
|
||||
pool?: typeof pool;
|
||||
acquireConnectionTimeout?: number;
|
||||
}
|
||||
|
||||
const knexOb: KnexConfig = {
|
||||
@@ -25,6 +57,7 @@ const knexOb: KnexConfig = {
|
||||
},
|
||||
databaseType,
|
||||
};
|
||||
console.log(`Configuring database with type ${databaseType}`);
|
||||
if (databaseType === "sqlite") {
|
||||
knexOb.client = "better-sqlite3";
|
||||
knexOb.connection = {
|
||||
@@ -33,10 +66,21 @@ if (databaseType === "sqlite") {
|
||||
knexOb.useNullAsDefault = true;
|
||||
} else if (databaseType === "postgresql") {
|
||||
knexOb.client = "pg";
|
||||
knexOb.connection = databaseURL;
|
||||
knexOb.connection = {
|
||||
connectionString: databaseURL,
|
||||
keepAlive: keepAliveEnabled,
|
||||
};
|
||||
knexOb.pool = pool;
|
||||
knexOb.acquireConnectionTimeout = acquireConnectionTimeout;
|
||||
} else if (databaseType === "mysql") {
|
||||
knexOb.client = "mysql2";
|
||||
knexOb.connection = databaseURL;
|
||||
knexOb.connection = {
|
||||
uri: databaseURL,
|
||||
enableKeepAlive: keepAliveEnabled,
|
||||
keepAliveInitialDelay: 10000,
|
||||
};
|
||||
knexOb.pool = pool;
|
||||
knexOb.acquireConnectionTimeout = acquireConnectionTimeout;
|
||||
} else {
|
||||
console.error("Invalid database type");
|
||||
process.exit(1);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// 1. Roles table
|
||||
if (!(await knex.schema.hasTable("roles"))) {
|
||||
await knex.schema.createTable("roles", (table) => {
|
||||
table.string("id", 100).primary();
|
||||
table.text("role_name").notNullable();
|
||||
table.integer("readonly").notNullable().defaultTo(0);
|
||||
table.string("status", 20).notNullable().defaultTo("ACTIVE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Permissions table
|
||||
if (!(await knex.schema.hasTable("permissions"))) {
|
||||
await knex.schema.createTable("permissions", (table) => {
|
||||
table.string("id", 100).primary();
|
||||
table.text("permission_name").notNullable();
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Roles ↔ Permissions junction table
|
||||
if (!(await knex.schema.hasTable("roles_permissions"))) {
|
||||
await knex.schema.createTable("roles_permissions", (table) => {
|
||||
table.string("roles_id", 100).notNullable().references("id").inTable("roles").onDelete("CASCADE");
|
||||
table.string("permissions_id", 100).notNullable().references("id").inTable("permissions").onDelete("CASCADE");
|
||||
table.string("status", 20).notNullable().defaultTo("ACTIVE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
table.primary(["roles_id", "permissions_id"]);
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Users ↔ Roles junction table
|
||||
if (!(await knex.schema.hasTable("users_roles"))) {
|
||||
await knex.schema.createTable("users_roles", (table) => {
|
||||
table.string("roles_id", 100).notNullable().references("id").inTable("roles").onDelete("CASCADE");
|
||||
table.integer("users_id").unsigned().notNullable().references("id").inTable("users").onDelete("CASCADE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
table.primary(["roles_id", "users_id"]);
|
||||
table.index("users_id", "idx_users_roles_users_id");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists("users_roles");
|
||||
await knex.schema.dropTableIfExists("roles_permissions");
|
||||
await knex.schema.dropTableIfExists("permissions");
|
||||
await knex.schema.dropTableIfExists("roles");
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
// Maps the legacy users.role string to the new roles.id value.
|
||||
// The old default was "user"; everything unmapped falls back to "member".
|
||||
const ROLE_MAP: Record<string, string> = {
|
||||
admin: "admin",
|
||||
editor: "editor",
|
||||
member: "member",
|
||||
user: "member",
|
||||
};
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn("users", "role");
|
||||
if (!hasColumn) return;
|
||||
|
||||
// 1. Ensure the three target roles exist so FK inserts succeed.
|
||||
// Seeds will reconcile permissions later; we only need the rows.
|
||||
const rolesToEnsure = [
|
||||
{ id: "admin", role_name: "Administrator" },
|
||||
{ id: "editor", role_name: "Editor" },
|
||||
{ id: "member", role_name: "Member" },
|
||||
];
|
||||
for (const role of rolesToEnsure) {
|
||||
const exists = await knex("roles").where("id", role.id).first();
|
||||
if (!exists) {
|
||||
await knex("roles").insert({
|
||||
id: role.id,
|
||||
role_name: role.role_name,
|
||||
readonly: 1,
|
||||
status: "ACTIVE",
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Read users.role into memory BEFORE dropping the column.
|
||||
// On SQLite, dropColumn recreates the table (create → copy → drop → rename),
|
||||
// which can discard DML inserts to tables with FKs pointing at users.
|
||||
const users: Array<{ id: number; role: string }> = await knex("users").select("id", "role");
|
||||
|
||||
// 3. Drop the column first.
|
||||
await knex.schema.alterTable("users", (table) => {
|
||||
table.dropColumn("role");
|
||||
});
|
||||
|
||||
// 4. Now populate users_roles from the in-memory snapshot.
|
||||
for (const user of users) {
|
||||
const newRoleId = ROLE_MAP[user.role] ?? "member";
|
||||
|
||||
const alreadyAssigned = await knex("users_roles").where({ roles_id: newRoleId, users_id: user.id }).first();
|
||||
|
||||
if (!alreadyAssigned) {
|
||||
await knex("users_roles").insert({
|
||||
roles_id: newRoleId,
|
||||
users_id: user.id,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse map: pick the highest-precedence role when backfilling.
|
||||
const REVERSE_ROLE_PRECEDENCE: string[] = ["admin", "editor", "member"];
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn("users", "role");
|
||||
if (!hasColumn) {
|
||||
await knex.schema.alterTable("users", (table) => {
|
||||
table.string("role").defaultTo("member");
|
||||
});
|
||||
}
|
||||
|
||||
// Backfill users.role from users_roles using deterministic precedence
|
||||
const assignments: Array<{ users_id: number; roles_id: string }> = await knex("users_roles").select(
|
||||
"users_id",
|
||||
"roles_id",
|
||||
);
|
||||
|
||||
// Group roles by user
|
||||
const userRolesMap = new Map<number, string[]>();
|
||||
for (const row of assignments) {
|
||||
const list = userRolesMap.get(row.users_id) || [];
|
||||
list.push(row.roles_id);
|
||||
userRolesMap.set(row.users_id, list);
|
||||
}
|
||||
|
||||
// Pick highest-precedence role for each user
|
||||
for (const [userId, roleIds] of userRolesMap) {
|
||||
const bestRole = REVERSE_ROLE_PRECEDENCE.find((r) => roleIds.includes(r)) || roleIds[0] || "member";
|
||||
await knex("users").where("id", userId).update({ role: bestRole });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasColumn("monitors", "confirmation_threshold"))) {
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.integer("confirmation_threshold").unsigned().notNullable().defaultTo(1);
|
||||
});
|
||||
}
|
||||
if (!(await knex.schema.hasColumn("monitoring_data", "raw_status"))) {
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.text("raw_status").nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasColumn("monitors", "confirmation_threshold")) {
|
||||
await knex.schema.alterTable("monitors", (table) => {
|
||||
table.dropColumn("confirmation_threshold");
|
||||
});
|
||||
}
|
||||
if (await knex.schema.hasColumn("monitoring_data", "raw_status")) {
|
||||
await knex.schema.alterTable("monitoring_data", (table) => {
|
||||
table.dropColumn("raw_status");
|
||||
});
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "kener",
|
||||
"version": "4.0.21",
|
||||
"version": "4.0.23",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kener",
|
||||
"version": "4.0.21",
|
||||
"version": "4.0.23",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kener",
|
||||
"version": "4.0.21",
|
||||
"version": "4.1.0",
|
||||
"type": "module",
|
||||
"private": false,
|
||||
"license": "MIT",
|
||||
|
||||
+110
-70
@@ -6,6 +6,7 @@ import Startup from "../src/lib/server/startup.ts";
|
||||
import shutdownSchedulers from "../src/lib/server/schedulers/shutdown.ts";
|
||||
import shutdownQueues from "../src/lib/server/queues/shutdown.ts";
|
||||
import dbInstance from "../src/lib/server/db/db.ts";
|
||||
import { redisConnection } from "../src/lib/server/redisConnector.ts";
|
||||
import knex from "knex";
|
||||
import knexOb from "../knexfile.js";
|
||||
|
||||
@@ -13,89 +14,128 @@ const PORT = process.env.PORT || 3000;
|
||||
const base = process.env.KENER_BASE_PATH || "";
|
||||
|
||||
async function start() {
|
||||
// Dynamic import so BODY_SIZE_LIMIT from .env is available
|
||||
// before the handler reads it at module top-level
|
||||
const { handler } = await import("../build/handler.js");
|
||||
// Dynamic import so BODY_SIZE_LIMIT from .env is available
|
||||
// before the handler reads it at module top-level
|
||||
const { handler } = await import("../build/handler.js");
|
||||
|
||||
const app: any = express();
|
||||
const db = knex(knexOb);
|
||||
const app: any = express();
|
||||
const db = knex(knexOb);
|
||||
|
||||
app.get(base + "/healthcheck", (req: any, res: any) => {
|
||||
res.end("ok");
|
||||
});
|
||||
// Caps a health probe at 2s so a wedged dependency can not hang the
|
||||
// endpoint. A probe is healthy unless it throws, times out, or resolves false.
|
||||
const probe = async (check: () => Promise<unknown>): Promise<boolean> => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
check(),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("health probe timeout")), 2000);
|
||||
}),
|
||||
]);
|
||||
return result !== false;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
app.use(handler);
|
||||
// Reports component health. Always 200 so healthcheck-driven restarters do
|
||||
// not bounce the app while a dependency is down (a restart can not fix a
|
||||
// dead database); pass ?strict=1 to get 503 when any component is down.
|
||||
app.get(base + "/healthcheck", async (req: any, res: any) => {
|
||||
const [dbOk, redisOk] = await Promise.all([
|
||||
probe(() => dbInstance.ping()),
|
||||
// Guard on status before PING: the shared ioredis client has
|
||||
// maxRetriesPerRequest null, so commands sent while disconnected would
|
||||
// queue forever and accumulate across healthcheck polls
|
||||
probe(async () => {
|
||||
const redis = redisConnection();
|
||||
if (redis.status !== "ready") return false;
|
||||
return await redis.ping();
|
||||
}),
|
||||
]);
|
||||
const healthy = dbOk && redisOk;
|
||||
const strict = req.query.strict === "1";
|
||||
res.status(strict && !healthy ? 503 : 200).json({
|
||||
status: healthy ? "ok" : "degraded",
|
||||
db: dbOk,
|
||||
redis: redisOk,
|
||||
});
|
||||
});
|
||||
|
||||
//migrations
|
||||
async function runMigrations() {
|
||||
try {
|
||||
// Rename old .js migration entries to .ts in the knex_migrations table
|
||||
// so Knex can find the renamed files on disk
|
||||
const hasTable = await db.schema.hasTable("knex_migrations");
|
||||
if (hasTable) {
|
||||
const oldJsMigrations = await db("knex_migrations").where("name", "like", "%.js");
|
||||
for (const row of oldJsMigrations) {
|
||||
const newName = row.name.replace(/\.js$/, ".ts");
|
||||
await db("knex_migrations").where("id", row.id).update({ name: newName });
|
||||
console.log(`Renamed migration record: ${row.name} -> ${newName}`);
|
||||
}
|
||||
}
|
||||
app.use(handler);
|
||||
|
||||
console.log("Running migrations...");
|
||||
await db.migrate.latest(); // Runs migrations to the latest state
|
||||
console.log("Migrations completed successfully!");
|
||||
} catch (err) {
|
||||
console.error("Error running migrations:", err);
|
||||
}
|
||||
}
|
||||
//migrations
|
||||
async function runMigrations() {
|
||||
try {
|
||||
// Rename old .js migration entries to .ts in the knex_migrations table
|
||||
// so Knex can find the renamed files on disk
|
||||
const hasTable = await db.schema.hasTable("knex_migrations");
|
||||
if (hasTable) {
|
||||
const oldJsMigrations = await db("knex_migrations").where("name", "like", "%.js");
|
||||
for (const row of oldJsMigrations) {
|
||||
const newName = row.name.replace(/\.js$/, ".ts");
|
||||
await db("knex_migrations").where("id", row.id).update({ name: newName });
|
||||
console.log(`Renamed migration record: ${row.name} -> ${newName}`);
|
||||
}
|
||||
}
|
||||
|
||||
//seed
|
||||
async function runSeed() {
|
||||
try {
|
||||
console.log("Running seed...");
|
||||
await db.seed.run(); // Runs seed to the latest state
|
||||
console.log("Seed completed successfully!");
|
||||
} catch (err) {
|
||||
console.error("Error running seed:", err);
|
||||
}
|
||||
}
|
||||
console.log("Running migrations...");
|
||||
await db.migrate.latest(); // Runs migrations to the latest state
|
||||
console.log("Migrations completed successfully!");
|
||||
} catch (err) {
|
||||
console.error("Error running migrations:", err);
|
||||
}
|
||||
}
|
||||
|
||||
app.listen(PORT, async () => {
|
||||
await runMigrations();
|
||||
await runSeed();
|
||||
await db.destroy();
|
||||
Startup();
|
||||
console.log("Kener is running on port " + PORT + "!");
|
||||
});
|
||||
//seed
|
||||
async function runSeed() {
|
||||
try {
|
||||
console.log("Running seed...");
|
||||
await db.seed.run(); // Runs seed to the latest state
|
||||
console.log("Seed completed successfully!");
|
||||
} catch (err) {
|
||||
console.error("Error running seed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown handler
|
||||
async function gracefulShutdown(signal: string) {
|
||||
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
|
||||
app.listen(PORT, async () => {
|
||||
await runMigrations();
|
||||
await runSeed();
|
||||
await db.destroy();
|
||||
Startup();
|
||||
console.log("Kener is running on port " + PORT + "!");
|
||||
});
|
||||
|
||||
try {
|
||||
console.log("Shutting down schedulers...");
|
||||
await shutdownSchedulers();
|
||||
console.log("Schedulers shut down successfully.");
|
||||
// Graceful shutdown handler
|
||||
async function gracefulShutdown(signal: string) {
|
||||
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
|
||||
|
||||
console.log("Shutting down queues...");
|
||||
await shutdownQueues();
|
||||
console.log("Queues shut down successfully.");
|
||||
try {
|
||||
console.log("Shutting down schedulers...");
|
||||
await shutdownSchedulers();
|
||||
console.log("Schedulers shut down successfully.");
|
||||
|
||||
console.log("Closing database connection...");
|
||||
await dbInstance.close();
|
||||
console.log("Database connection closed successfully.");
|
||||
console.log("Shutting down queues...");
|
||||
await shutdownQueues();
|
||||
console.log("Queues shut down successfully.");
|
||||
|
||||
console.log("Graceful shutdown completed.");
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error("Error during graceful shutdown:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log("Closing database connection...");
|
||||
await dbInstance.close();
|
||||
console.log("Database connection closed successfully.");
|
||||
|
||||
// Handle termination signals
|
||||
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
||||
console.log("Graceful shutdown completed.");
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error("Error during graceful shutdown:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle termination signals
|
||||
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
||||
}
|
||||
|
||||
start();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Knex } from "knex";
|
||||
import { permissions } from "../src/lib/allPerms.ts";
|
||||
|
||||
export async function seed(knex: Knex): Promise<void> {
|
||||
const permissionIds = new Set(permissions.map((p) => p.id));
|
||||
|
||||
// Get all existing permissions
|
||||
const existing: Array<{ id: string }> = await knex("permissions").select("id");
|
||||
const existingIds = new Set(existing.map((e) => e.id));
|
||||
|
||||
// Insert missing permissions
|
||||
for (const perm of permissions) {
|
||||
if (!existingIds.has(perm.id)) {
|
||||
await knex("permissions").insert({
|
||||
id: perm.id,
|
||||
permission_name: perm.permission_name,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Delete permissions that are no longer in the seed list
|
||||
const toDelete = existing.filter((e) => !permissionIds.has(e.id)).map((e) => e.id);
|
||||
if (toDelete.length > 0) {
|
||||
await knex("permissions").whereIn("id", toDelete).del();
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import type { Knex } from "knex";
|
||||
import { permissions } from "../src/lib/allPerms.ts";
|
||||
|
||||
/**
|
||||
* Seeds the three readonly roles (admin, editor, member),
|
||||
* assigns permissions to each role in roles_permissions,
|
||||
* and migrates existing users.role → users_roles.
|
||||
*
|
||||
* Permission mapping derived from src/routes/(manage)/manage/api/+server.ts:
|
||||
*
|
||||
* admin → all permissions
|
||||
* editor → all except api_keys.delete (AdminCan-only)
|
||||
* member → all .read permissions only
|
||||
*/
|
||||
|
||||
const readonlyRoles = [
|
||||
{ id: "admin", role_name: "Administrator" },
|
||||
{ id: "editor", role_name: "Editor" },
|
||||
{ id: "member", role_name: "Member" },
|
||||
];
|
||||
|
||||
const allPermissionIds = permissions.map((p) => p.id);
|
||||
const readPermissionIds = allPermissionIds.filter((id) => id.endsWith(".read"));
|
||||
|
||||
const rolePermissions: Record<string, string[]> = {
|
||||
admin: allPermissionIds,
|
||||
editor: allPermissionIds.filter((id) => id !== "api_keys.delete"),
|
||||
member: readPermissionIds,
|
||||
};
|
||||
|
||||
export async function seed(knex: Knex): Promise<void> {
|
||||
// 1. Ensure readonly roles exist
|
||||
for (const role of readonlyRoles) {
|
||||
const existing = await knex("roles").where("id", role.id).first();
|
||||
if (!existing) {
|
||||
await knex("roles").insert({
|
||||
id: role.id,
|
||||
role_name: role.role_name,
|
||||
readonly: 1,
|
||||
status: "ACTIVE",
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Seed roles_permissions for readonly roles
|
||||
// Only insert permissions that actually exist in the permissions table
|
||||
// to avoid FK constraint errors if permissions seed hasn't run yet.
|
||||
const existingPermRows: Array<{ id: string }> = await knex("permissions").select("id");
|
||||
const existingPermIds = new Set(existingPermRows.map((p) => p.id));
|
||||
|
||||
for (const [roleId, permissionIds] of Object.entries(rolePermissions)) {
|
||||
const validPermissionIds = permissionIds.filter((id) => existingPermIds.has(id));
|
||||
|
||||
const existingPerms: Array<{ permissions_id: string }> = await knex("roles_permissions")
|
||||
.where("roles_id", roleId)
|
||||
.select("permissions_id");
|
||||
const existingSet = new Set(existingPerms.map((e) => e.permissions_id));
|
||||
|
||||
// Insert missing permissions
|
||||
for (const permId of validPermissionIds) {
|
||||
if (!existingSet.has(permId)) {
|
||||
await knex("roles_permissions").insert({
|
||||
roles_id: roleId,
|
||||
permissions_id: permId,
|
||||
status: "ACTIVE",
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove permissions no longer assigned to this role
|
||||
const desiredSet = new Set(validPermissionIds);
|
||||
const toRemove = existingPerms.filter((e) => !desiredSet.has(e.permissions_id)).map((e) => e.permissions_id);
|
||||
if (toRemove.length > 0) {
|
||||
await knex("roles_permissions").where("roles_id", roleId).whereIn("permissions_id", toRemove).del();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Migrate existing users: read users.role → insert into users_roles
|
||||
const hasRoleColumn = await knex.schema.hasColumn("users", "role");
|
||||
if (hasRoleColumn) {
|
||||
const users: Array<{ id: number; role: string }> = await knex("users").select("id", "role");
|
||||
|
||||
for (const user of users) {
|
||||
if (!user.role) continue;
|
||||
|
||||
// Only migrate if a matching role exists
|
||||
const roleExists = await knex("roles").where("id", user.role).first();
|
||||
if (!roleExists) continue;
|
||||
|
||||
// Skip if already assigned
|
||||
const existing = await knex("users_roles").where({ roles_id: user.role, users_id: user.id }).first();
|
||||
if (!existing) {
|
||||
await knex("users_roles").insert({
|
||||
roles_id: user.role,
|
||||
users_id: user.id,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta http-equiv="refresh" content="30" />
|
||||
<title>%sveltekit.status% — Status page temporarily unavailable</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #ffffff;
|
||||
--fg: #09090b;
|
||||
--muted: #71717a;
|
||||
--border: #e4e4e7;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #09090b;
|
||||
--fg: #fafafa;
|
||||
--muted: #a1a1aa;
|
||||
--border: #27272a;
|
||||
}
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
}
|
||||
.card {
|
||||
max-width: 28rem;
|
||||
margin: 1rem;
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
p {
|
||||
color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.code {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>This status page is temporarily unavailable</h1>
|
||||
<p>We are having trouble serving this page right now. It usually resolves on its own.</p>
|
||||
<p>This page will retry automatically in 30 seconds.</p>
|
||||
<div class="code">%sveltekit.status%</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+17
-1
@@ -4,6 +4,7 @@ import { VerifyAPIKey } from "$lib/server/controllers/apiController";
|
||||
import db from "$lib/server/db/db";
|
||||
import type { UnauthorizedResponse, NotFoundResponse } from "$lib/types/api";
|
||||
import { GetMonitorsParsed } from "$lib/server/controllers/monitorsController";
|
||||
import GC from "$lib/global-constants";
|
||||
|
||||
const API_PATH_PREFIX = "/api/";
|
||||
|
||||
@@ -119,6 +120,18 @@ const apiAuthHandle: Handle = async ({ event, resolve }) => {
|
||||
return json(errorResponse, { status: 401 });
|
||||
}
|
||||
|
||||
// API consumers must always get JSON; without this, an /api/ path with no
|
||||
// matching route falls through to SvelteKit's HTML error page
|
||||
if (event.route.id === null) {
|
||||
const errorResponse: NotFoundResponse = {
|
||||
error: {
|
||||
code: "NOT_FOUND",
|
||||
message: `No API route matches '${pathname}'`,
|
||||
},
|
||||
};
|
||||
return json(errorResponse, { status: 404 });
|
||||
}
|
||||
|
||||
// Validate monitor tag exists for /api/(vX/)?monitors/:monitor_tag/* routes
|
||||
const monitorTag = extractMonitorTag(pathname);
|
||||
if (monitorTag) {
|
||||
@@ -173,7 +186,10 @@ const apiAuthHandle: Handle = async ({ event, resolve }) => {
|
||||
// Validate page_path exists for /api/(vX/)?pages/:page_path/* routes
|
||||
const pagePath = extractPagePath(pathname);
|
||||
if (pagePath) {
|
||||
const page = await db.getPageByPath(pagePath);
|
||||
// The home page has an empty page_path, unreachable as a URL segment;
|
||||
// the ~home token addresses it instead
|
||||
const lookupPath = pagePath === GC.HOME_PAGE_TOKEN ? "" : pagePath;
|
||||
const page = await db.getPageByPath(lookupPath);
|
||||
if (!page) {
|
||||
const errorResponse: NotFoundResponse = {
|
||||
error: {
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Permissions derived from src/routes/(manage)/manage/api/+server.ts actions.
|
||||
* Grouped by domain with read/write granularity.
|
||||
*
|
||||
* Mapping from actions → permissions:
|
||||
*
|
||||
* monitors.read → getMonitors, getMonitoringDataPaginated
|
||||
* monitors.write → storeMonitorData, updateMonitoringData, deleteMonitor, deleteMonitorData, cloneMonitor, testMonitor
|
||||
*
|
||||
* incidents.read → getIncidents, getIncident, getComments
|
||||
* incidents.write → createIncident, updateIncident, deleteIncident, addMonitor, removeMonitor, addComment, deleteComment, updateComment
|
||||
*
|
||||
* maintenances.read → getMaintenances, getMaintenance, getMaintenanceEvents, getMaintenanceEvent, getMaintenanceMonitors
|
||||
* maintenances.write → createMaintenance, updateMaintenance, deleteMaintenance, createMaintenanceEvent, updateMaintenanceEvent, updateMaintenanceEventStatus, deleteMaintenanceEvent, addMonitorToMaintenance, removeMonitorFromMaintenance, updateMaintenanceMonitorImpact
|
||||
*
|
||||
* pages.read → getPages
|
||||
* pages.write → createPage, updatePage, deletePage, addMonitorToPage, removeMonitorFromPage, reorderPageMonitors
|
||||
*
|
||||
* triggers.read → getTriggers
|
||||
* triggers.write → createUpdateTrigger, updateMonitorTriggers, deleteTrigger, testTrigger
|
||||
*
|
||||
* alerts.read → getMonitorAlertConfig, getMonitorAlertConfigById, getMonitorAlertConfigsByMonitorTag, getAlertConfigsPaginated, getAllAlertsPaginated
|
||||
* alerts.write → createMonitorAlertConfig, updateMonitorAlertConfig, deleteMonitorAlertConfig, toggleMonitorAlertConfigStatus, deleteMonitorAlertV2, updateMonitorAlertV2Status
|
||||
*
|
||||
* api_keys.read → getAPIKeys
|
||||
* api_keys.write → createNewApiKey, updateApiKeyStatus
|
||||
* api_keys.delete → deleteApiKey (admin-only today)
|
||||
*
|
||||
* users.read → getUsers
|
||||
* users.write → manualUpdate, createNewUser, resendInvitation, sendVerificationEmail
|
||||
*
|
||||
* settings.read → getAllSiteData, getSiteDataByKey, getSubscriptionsConfig
|
||||
* settings.write → storeSiteData, updateSubscriptionsConfig
|
||||
*
|
||||
* subscribers.read → getSubscribersByMethod, getSubscriberWithSubscriptions, getSubscriberCountsByMethod, getAdminSubscribers
|
||||
* subscribers.write → deleteUserSubscription, updateUserSubscriptionStatus, adminUpdateSubscriptionStatus, adminDeleteSubscriber, adminAddSubscriber
|
||||
*
|
||||
* email_templates.read → getGeneralEmailTemplates, getGeneralEmailTemplateById
|
||||
* email_templates.write → updateGeneralEmailTemplate
|
||||
*
|
||||
* images.write → uploadImage, deleteImage
|
||||
*/
|
||||
export const permissions: Array<{ id: string; permission_name: string }> = [
|
||||
// Monitors
|
||||
{ id: "monitors.read", permission_name: "View monitors and monitoring data" },
|
||||
{ id: "monitors.write", permission_name: "Create, update, delete, and clone monitors" },
|
||||
|
||||
// Incidents
|
||||
{ id: "incidents.read", permission_name: "View incidents and comments" },
|
||||
{ id: "incidents.write", permission_name: "Create, update, and delete incidents and comments" },
|
||||
|
||||
// Maintenances
|
||||
{ id: "maintenances.read", permission_name: "View maintenances and events" },
|
||||
{ id: "maintenances.write", permission_name: "Create, update, and delete maintenances and events" },
|
||||
|
||||
// Pages
|
||||
{ id: "pages.read", permission_name: "View pages" },
|
||||
{ id: "pages.write", permission_name: "Create, update, and delete pages" },
|
||||
|
||||
// Triggers
|
||||
{ id: "triggers.read", permission_name: "View triggers" },
|
||||
{ id: "triggers.write", permission_name: "Create, update, delete, and test triggers" },
|
||||
|
||||
// Alerts
|
||||
{ id: "alerts.read", permission_name: "View alert configurations and alert history" },
|
||||
{ id: "alerts.write", permission_name: "Create, update, and delete alert configurations" },
|
||||
|
||||
// API Keys
|
||||
{ id: "api_keys.read", permission_name: "View API keys" },
|
||||
{ id: "api_keys.write", permission_name: "Create and update API keys" },
|
||||
{ id: "api_keys.delete", permission_name: "Delete API keys" },
|
||||
|
||||
// Users
|
||||
{ id: "users.read", permission_name: "View users" },
|
||||
{ id: "users.write", permission_name: "Manage users, invitations, and verification" },
|
||||
|
||||
// Settings (site data + subscriptions config)
|
||||
{ id: "settings.read", permission_name: "View site settings and subscriptions config" },
|
||||
{ id: "settings.write", permission_name: "Update site settings and subscriptions config" },
|
||||
|
||||
// Subscribers
|
||||
{ id: "subscribers.read", permission_name: "View subscribers" },
|
||||
{ id: "subscribers.write", permission_name: "Manage subscribers and subscriptions" },
|
||||
|
||||
// Email Templates
|
||||
{ id: "email_templates.read", permission_name: "View email templates" },
|
||||
{ id: "email_templates.write", permission_name: "Update email templates" },
|
||||
|
||||
// Images
|
||||
{ id: "images.write", permission_name: "Upload and delete images" },
|
||||
|
||||
// Roles
|
||||
{ id: "roles.read", permission_name: "View roles, permissions, and user assignments" },
|
||||
{ id: "roles.write", permission_name: "Create, update, and delete roles" },
|
||||
{ id: "roles.assign_permissions", permission_name: "Add and remove permissions from roles" },
|
||||
{ id: "roles.assign_users", permission_name: "Add and remove users to and from roles" },
|
||||
];
|
||||
|
||||
export const ACTION_PERMISSION_MAP: Record<string, string | null> = {
|
||||
// Self-actions — no permission needed beyond being logged in
|
||||
updateUser: null,
|
||||
updatePassword: null,
|
||||
sendVerificationEmail: null, // controller has its own self-vs-other check
|
||||
|
||||
// Settings
|
||||
getAllSiteData: "settings.read",
|
||||
getSiteDataByKey: "settings.read",
|
||||
getSubscriptionsConfig: "settings.read",
|
||||
storeSiteData: "settings.write",
|
||||
updateSubscriptionsConfig: "settings.write",
|
||||
|
||||
// Users
|
||||
getUsers: "users.read",
|
||||
manualUpdate: "users.write",
|
||||
createNewUser: "users.write",
|
||||
resendInvitation: "users.write",
|
||||
|
||||
// Monitors
|
||||
getMonitors: "monitors.read",
|
||||
getMonitoringDataPaginated: "monitors.read",
|
||||
storeMonitorData: "monitors.write",
|
||||
updateMonitoringData: "monitors.write",
|
||||
deleteMonitor: "monitors.write",
|
||||
deleteMonitorData: "monitors.write",
|
||||
cloneMonitor: "monitors.write",
|
||||
testMonitor: "monitors.write",
|
||||
|
||||
// Incidents
|
||||
getIncidents: "incidents.read",
|
||||
getIncident: "incidents.read",
|
||||
getComments: "incidents.read",
|
||||
createIncident: "incidents.write",
|
||||
updateIncident: "incidents.write",
|
||||
deleteIncident: "incidents.write",
|
||||
addMonitor: "incidents.write",
|
||||
removeMonitor: "incidents.write",
|
||||
addComment: "incidents.write",
|
||||
deleteComment: "incidents.write",
|
||||
updateComment: "incidents.write",
|
||||
|
||||
// Maintenances
|
||||
getMaintenances: "maintenances.read",
|
||||
getMaintenance: "maintenances.read",
|
||||
getMaintenanceEvents: "maintenances.read",
|
||||
getMaintenanceEvent: "maintenances.read",
|
||||
getMaintenanceMonitors: "maintenances.read",
|
||||
createMaintenance: "maintenances.write",
|
||||
updateMaintenance: "maintenances.write",
|
||||
deleteMaintenance: "maintenances.write",
|
||||
createMaintenanceEvent: "maintenances.write",
|
||||
updateMaintenanceEvent: "maintenances.write",
|
||||
updateMaintenanceEventStatus: "maintenances.write",
|
||||
deleteMaintenanceEvent: "maintenances.write",
|
||||
addMonitorToMaintenance: "maintenances.write",
|
||||
removeMonitorFromMaintenance: "maintenances.write",
|
||||
updateMaintenanceMonitorImpact: "maintenances.write",
|
||||
|
||||
// Pages
|
||||
getPages: "pages.read",
|
||||
createPage: "pages.write",
|
||||
updatePage: "pages.write",
|
||||
deletePage: "pages.write",
|
||||
addMonitorToPage: "pages.write",
|
||||
removeMonitorFromPage: "pages.write",
|
||||
reorderPageMonitors: "pages.write",
|
||||
|
||||
// Triggers
|
||||
getTriggers: "triggers.read",
|
||||
createUpdateTrigger: "triggers.write",
|
||||
updateMonitorTriggers: "triggers.write",
|
||||
deleteTrigger: "triggers.write",
|
||||
testTrigger: "triggers.write",
|
||||
|
||||
// Alerts
|
||||
getAllAlertsPaginated: "alerts.read",
|
||||
getMonitorAlertConfig: "alerts.read",
|
||||
getMonitorAlertConfigById: "alerts.read",
|
||||
getMonitorAlertConfigsByMonitorTag: "alerts.read",
|
||||
getAlertConfigsPaginated: "alerts.read",
|
||||
createMonitorAlertConfig: "alerts.write",
|
||||
updateMonitorAlertConfig: "alerts.write",
|
||||
deleteMonitorAlertConfig: "alerts.write",
|
||||
toggleMonitorAlertConfigStatus: "alerts.write",
|
||||
deleteMonitorAlertV2: "alerts.write",
|
||||
updateMonitorAlertV2Status: "alerts.write",
|
||||
|
||||
// API Keys
|
||||
getAPIKeys: "api_keys.read",
|
||||
createNewApiKey: "api_keys.write",
|
||||
updateApiKeyStatus: "api_keys.write",
|
||||
deleteApiKey: "api_keys.delete",
|
||||
|
||||
// Subscribers
|
||||
getSubscribersByMethod: "subscribers.read",
|
||||
getSubscriberWithSubscriptions: "subscribers.read",
|
||||
getSubscriberCountsByMethod: "subscribers.read",
|
||||
getAdminSubscribers: "subscribers.read",
|
||||
deleteUserSubscription: "subscribers.write",
|
||||
updateUserSubscriptionStatus: "subscribers.write",
|
||||
adminUpdateSubscriptionStatus: "subscribers.write",
|
||||
adminDeleteSubscriber: "subscribers.write",
|
||||
adminAddSubscriber: "subscribers.write",
|
||||
|
||||
// Email Templates
|
||||
getGeneralEmailTemplates: "email_templates.read",
|
||||
getGeneralEmailTemplateById: "email_templates.read",
|
||||
updateGeneralEmailTemplate: "email_templates.write",
|
||||
|
||||
// Images
|
||||
uploadImage: "images.write",
|
||||
deleteImage: "images.write",
|
||||
|
||||
// Roles
|
||||
getRoles: "roles.read",
|
||||
getAllPermissions: "roles.read",
|
||||
getRolePermissions: "roles.read",
|
||||
getRoleUsers: "roles.read",
|
||||
createRole: "roles.write",
|
||||
updateRole: "roles.write",
|
||||
deleteRole: "roles.write",
|
||||
updateRolePermissions: "roles.assign_permissions",
|
||||
addUserToRole: "roles.assign_users",
|
||||
removeUserFromRole: "roles.assign_users",
|
||||
};
|
||||
|
||||
export const ROUTE_PERMISSION_MAP: Record<string, string | null> = {
|
||||
// Monitors
|
||||
"/(manage)/manage/app/monitors": "monitors.read",
|
||||
"/(manage)/manage/app/monitors/[tag]": "monitors.read",
|
||||
"/(manage)/manage/app/monitoring-data": "monitors.read",
|
||||
|
||||
// Incidents
|
||||
"/(manage)/manage/app/incidents": "incidents.read",
|
||||
"/(manage)/manage/app/incidents/[incident_id]": "incidents.read",
|
||||
|
||||
// Maintenances
|
||||
"/(manage)/manage/app/maintenances": "maintenances.read",
|
||||
"/(manage)/manage/app/maintenances/[id]": "maintenances.read",
|
||||
|
||||
// Pages
|
||||
"/(manage)/manage/app/pages": "pages.read",
|
||||
"/(manage)/manage/app/pages/[page_id]": "pages.read",
|
||||
|
||||
// Triggers
|
||||
"/(manage)/manage/app/triggers": "triggers.read",
|
||||
"/(manage)/manage/app/triggers/[trigger_id]": "triggers.read",
|
||||
|
||||
// Alerts
|
||||
"/(manage)/manage/app/alerts": "alerts.read",
|
||||
"/(manage)/manage/app/alerts/[alert_config_id]": "alerts.read",
|
||||
"/(manage)/manage/app/alerts/logs/[alert_config_id]": "alerts.read",
|
||||
|
||||
// API Keys
|
||||
"/(manage)/manage/app/api-keys": "api_keys.read",
|
||||
|
||||
// Users
|
||||
"/(manage)/manage/app/users": "users.read",
|
||||
|
||||
// Settings
|
||||
"/(manage)/manage/app/site-configurations": "settings.read",
|
||||
"/(manage)/manage/app/customizations": "settings.read",
|
||||
"/(manage)/manage/app/internationalization": "settings.read",
|
||||
"/(manage)/manage/app/analytics-providers": "settings.read",
|
||||
"/(manage)/manage/app/badges": "settings.read",
|
||||
"/(manage)/manage/app/embed": "settings.read",
|
||||
|
||||
// Subscribers
|
||||
"/(manage)/manage/app/subscriptions": "subscribers.read",
|
||||
|
||||
// Email Templates
|
||||
"/(manage)/manage/app/templates": "email_templates.read",
|
||||
|
||||
// Roles
|
||||
"/(manage)/manage/app/roles": "roles.read",
|
||||
};
|
||||
@@ -31,3 +31,39 @@ export default function urlResolve(resolve: ResolveFn, path: string, params?: Re
|
||||
}
|
||||
return resolve(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a path to an absolute URL by prefixing the site URL.
|
||||
* Required for meta tags like og:image and twitter:image that need absolute URLs.
|
||||
* @param resolve - The resolve function from $app/paths
|
||||
* @param siteUrl - The site URL (e.g., "https://status.example.com")
|
||||
* @param path - The route path or absolute URL
|
||||
* @param params - Optional parameters for dynamic route segments
|
||||
* @returns An absolute URL, or the resolved relative URL if siteUrl is empty
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* absoluteResolve(resolve, "https://status.example.com", "/uploads/preview.png")
|
||||
* // => "https://status.example.com/uploads/preview.png"
|
||||
* ```
|
||||
*/
|
||||
export function absoluteResolve(
|
||||
resolve: ResolveFn,
|
||||
siteUrl: string,
|
||||
path: string,
|
||||
params?: Record<string, string>
|
||||
): string {
|
||||
// Normalize relative paths like "./assets/..." to "/assets/..." so the
|
||||
// final URL doesn't contain "/./" segments (crawlers don't normalize these)
|
||||
const normalizedPath = path.startsWith("./") ? path.slice(1) : path;
|
||||
const resolved = urlResolve(resolve, normalizedPath, params);
|
||||
// Already absolute, return as-is
|
||||
if (resolved.startsWith("http://") || resolved.startsWith("https://")) {
|
||||
return resolved;
|
||||
}
|
||||
if (!siteUrl) {
|
||||
return resolved;
|
||||
}
|
||||
const trimmedSiteUrl = siteUrl.replace(/\/+$/, "");
|
||||
return trimmedSiteUrl + (resolved.startsWith("/") ? resolved : "/" + resolved);
|
||||
}
|
||||
|
||||
+42
-34
@@ -1,5 +1,5 @@
|
||||
import type { TimestampStatusCount } from "$lib/server/types/db";
|
||||
import { PAGE_STATUS_MESSAGES } from "$lib/global-constants";
|
||||
import GC, { PAGE_STATUS_MESSAGES, type StatusType } from "$lib/global-constants";
|
||||
|
||||
function ParseLatency(latencyMs: number): string {
|
||||
if (!!!latencyMs) {
|
||||
@@ -316,46 +316,53 @@ interface GameItem {
|
||||
function GetGameFromId(list: GameItem[], id: string): GameItem | undefined {
|
||||
return list.find((game: GameItem) => game.id === id);
|
||||
}
|
||||
type StatusCounts = Pick<TimestampStatusCount, "countOfUp" | "countOfDown" | "countOfDegraded" | "countOfMaintenance">;
|
||||
|
||||
// Canonical Overall Status collapse: the worst state wins, and maintenance
|
||||
// never masks an active problem. See docs/adr/0007-problem-first-overall-status.md.
|
||||
function CollapseStatusCounts(counts: StatusCounts): StatusType {
|
||||
const total = counts.countOfUp + counts.countOfDown + counts.countOfDegraded + counts.countOfMaintenance;
|
||||
if (total === 0) return GC.NO_DATA;
|
||||
if (counts.countOfDown > 0) return GC.DOWN;
|
||||
if (counts.countOfDegraded > 0) return GC.DEGRADED;
|
||||
if (counts.countOfMaintenance > 0) return GC.MAINTENANCE;
|
||||
return GC.UP;
|
||||
}
|
||||
|
||||
function GetStatusSummary(item: TimestampStatusCount): string {
|
||||
const total = item.countOfUp + item.countOfDown + item.countOfDegraded + item.countOfMaintenance;
|
||||
if (total === 0) return PAGE_STATUS_MESSAGES.NO_DATA;
|
||||
|
||||
const maintenancePercent = (item.countOfMaintenance / total) * 100;
|
||||
const downPercent = (item.countOfDown / total) * 100;
|
||||
const degradedPercent = (item.countOfDegraded / total) * 100;
|
||||
|
||||
if (maintenancePercent > 0) {
|
||||
return PAGE_STATUS_MESSAGES.UNDER_MAINTENANCE;
|
||||
} else if (downPercent >= 75) {
|
||||
return PAGE_STATUS_MESSAGES.MAJOR_OUTAGE;
|
||||
} else if (downPercent >= 50) {
|
||||
return PAGE_STATUS_MESSAGES.PARTIAL_OUTAGE;
|
||||
} else if (item.countOfDown > 0) {
|
||||
return PAGE_STATUS_MESSAGES.PARTIAL_OUTAGE;
|
||||
} else if (degradedPercent >= 75) {
|
||||
return PAGE_STATUS_MESSAGES.DEGRADED_PERFORMANCE;
|
||||
} else if (degradedPercent >= 50) {
|
||||
return PAGE_STATUS_MESSAGES.PARTIAL_DEGRADED;
|
||||
} else if (item.countOfDegraded > 0) {
|
||||
return PAGE_STATUS_MESSAGES.PARTIAL_DEGRADED;
|
||||
} else if (item.countOfUp === total) {
|
||||
return PAGE_STATUS_MESSAGES.ALL_OPERATIONAL;
|
||||
switch (CollapseStatusCounts(item)) {
|
||||
case GC.DOWN:
|
||||
return (item.countOfDown / total) * 100 >= 75
|
||||
? PAGE_STATUS_MESSAGES.MAJOR_OUTAGE
|
||||
: PAGE_STATUS_MESSAGES.PARTIAL_OUTAGE;
|
||||
case GC.DEGRADED:
|
||||
return (item.countOfDegraded / total) * 100 >= 75
|
||||
? PAGE_STATUS_MESSAGES.DEGRADED_PERFORMANCE
|
||||
: PAGE_STATUS_MESSAGES.PARTIAL_DEGRADED;
|
||||
case GC.MAINTENANCE:
|
||||
return PAGE_STATUS_MESSAGES.UNDER_MAINTENANCE;
|
||||
case GC.UP:
|
||||
return PAGE_STATUS_MESSAGES.ALL_OPERATIONAL;
|
||||
default:
|
||||
return PAGE_STATUS_MESSAGES.NO_DATA;
|
||||
}
|
||||
|
||||
return PAGE_STATUS_MESSAGES.NO_DATA;
|
||||
}
|
||||
|
||||
function GetStatusColor(item: TimestampStatusCount): string {
|
||||
const total = item.countOfUp + item.countOfDown + item.countOfDegraded + item.countOfMaintenance;
|
||||
if (total === 0) return "text-muted-foreground";
|
||||
|
||||
const maintenancePercent = (item.countOfMaintenance / total) * 100;
|
||||
const downPercent = (item.countOfDown / total) * 100;
|
||||
|
||||
if (maintenancePercent > 0) return "text-maintenance";
|
||||
if (downPercent > 0) return "text-down";
|
||||
if (item.countOfDegraded > 0) return "text-degraded";
|
||||
return "text-up";
|
||||
switch (CollapseStatusCounts(item)) {
|
||||
case GC.DOWN:
|
||||
return "text-down";
|
||||
case GC.DEGRADED:
|
||||
return "text-degraded";
|
||||
case GC.MAINTENANCE:
|
||||
return "text-maintenance";
|
||||
case GC.UP:
|
||||
return "text-up";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
function GetStatusBgColor(item: TimestampStatusCount): string {
|
||||
@@ -378,6 +385,7 @@ export {
|
||||
IsValidNameServer,
|
||||
IsValidURL,
|
||||
IsValidPort,
|
||||
CollapseStatusCounts,
|
||||
GetStatusSummary,
|
||||
GetStatusColor,
|
||||
GetStatusBgColor,
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import { GetInitials } from "$lib/clientTools.js";
|
||||
import type { MaintenanceEventsMonitorList } from "$lib/server/types/db";
|
||||
import { SveltePurify } from "@humanspeak/svelte-purify";
|
||||
import mdToHTML from "$lib/marked";
|
||||
import { page } from "$app/state";
|
||||
|
||||
interface Props {
|
||||
@@ -42,9 +44,11 @@
|
||||
</div>
|
||||
|
||||
{#if maintenance.description}
|
||||
<p class="text-muted-foreground mt-1 text-sm">
|
||||
{maintenance.description}
|
||||
</p>
|
||||
<div
|
||||
class="prose prose-sm dark:prose-invert text-muted-foreground mt-1 max-w-none min-w-0 overflow-x-auto text-sm wrap-break-word"
|
||||
>
|
||||
<SveltePurify html={mdToHTML(maintenance.description)} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if maintenance.monitors && maintenance.monitors.length > 0 && !hideMonitors}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import * as Command from "$lib/components/ui/command/index.js";
|
||||
import * as Popover from "$lib/components/ui/popover/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import type { MonitorRecord } from "$lib/server/types/db.js";
|
||||
import CheckIcon from "@lucide/svelte/icons/check";
|
||||
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
|
||||
import ListPlusIcon from "@lucide/svelte/icons/list-plus";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import { resolve } from "$app/paths";
|
||||
|
||||
let {
|
||||
monitors = [],
|
||||
selectedTags = [],
|
||||
onToggle,
|
||||
onAddMany,
|
||||
placeholder = "Search monitors to add..."
|
||||
}: {
|
||||
monitors: MonitorRecord[];
|
||||
selectedTags: string[];
|
||||
onToggle: (tag: string) => void;
|
||||
onAddMany?: (tags: string[]) => void;
|
||||
placeholder?: string;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let search = $state("");
|
||||
|
||||
// Own filtering (shouldFilter={false}) so "Add all matching" counts stay
|
||||
// consistent with what the list shows. Case-insensitive over name + tag.
|
||||
const filteredMonitors = $derived.by(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
if (!query) return monitors;
|
||||
return monitors.filter((m) => m.name.toLowerCase().includes(query) || m.tag.toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
const unselectedMatches = $derived(filteredMonitors.filter((m) => !selectedTags.includes(m.tag)));
|
||||
const showAddAll = $derived(!!search.trim() && unselectedMatches.length > 0 && !!onAddMany);
|
||||
|
||||
function addAllMatching() {
|
||||
onAddMany?.(unselectedMatches.map((m) => m.tag));
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
class="w-full justify-between font-normal"
|
||||
>
|
||||
<span class="text-muted-foreground">{placeholder}</span>
|
||||
<ChevronsUpDownIcon class="text-muted-foreground size-4 shrink-0" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-[var(--bits-popover-trigger-width)] p-0" align="start">
|
||||
<Command.Root shouldFilter={false}>
|
||||
<Command.Input {placeholder} bind:value={search} />
|
||||
<Command.List class="max-h-64">
|
||||
<Command.Empty>No monitors found.</Command.Empty>
|
||||
<Command.Group>
|
||||
{#each filteredMonitors as monitor (monitor.tag)}
|
||||
{@const selected = selectedTags.includes(monitor.tag)}
|
||||
<Command.Item value={monitor.tag} onSelect={() => onToggle(monitor.tag)}>
|
||||
<CheckIcon class="size-4 {selected ? 'opacity-100' : 'opacity-0'}" />
|
||||
{#if monitor.image}
|
||||
<img
|
||||
src={clientResolver(resolve, monitor.image)}
|
||||
alt={monitor.name}
|
||||
class="size-5 rounded object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="bg-muted flex size-5 items-center justify-center rounded text-[10px] font-medium">
|
||||
{monitor.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="truncate">{monitor.name}</span>
|
||||
<span class="text-muted-foreground ml-auto truncate text-xs">{monitor.tag}</span>
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.Group>
|
||||
{#if showAddAll}
|
||||
<Command.Separator />
|
||||
<Command.Group>
|
||||
<Command.Item value="__add-all-matching__" onSelect={addAllMatching}>
|
||||
<ListPlusIcon class="size-4" />
|
||||
Add all {unselectedMatches.length} matching
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
{/if}
|
||||
</Command.List>
|
||||
</Command.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -69,6 +69,18 @@ export default {
|
||||
STATUS: "STATUS",
|
||||
LATENCY: "LATENCY",
|
||||
UPTIME: "UPTIME",
|
||||
// Special path segment addressing the home page in the v4 API; its stored
|
||||
// page_path is an empty string. See docs/adr/0004-home-page-api-token.md.
|
||||
HOME_PAGE_TOKEN: "~home",
|
||||
// Status history window (days of per-day status shown), shared by pages and
|
||||
// monitors, the manage UI, the public pages, and the v4 API
|
||||
DEFAULT_STATUS_HISTORY_DAYS_DESKTOP: 90,
|
||||
DEFAULT_STATUS_HISTORY_DAYS_MOBILE: 30,
|
||||
STATUS_HISTORY_DAYS_MIN: 1,
|
||||
STATUS_HISTORY_DAYS_MAX: 365,
|
||||
// Monitor layout styles available on status pages
|
||||
MONITOR_LAYOUT_STYLES: ["default-list", "default-grid", "compact-list", "compact-grid"],
|
||||
DEFAULT_MONITOR_LAYOUT_STYLE: "default-list",
|
||||
DOCS_URL: "https://kener.ing/docs",
|
||||
MAX_UPLOAD_BYTES: 2 * 1024 * 1024, // 2MB
|
||||
MAX_IMAGE_DIMENSION: 4096,
|
||||
|
||||
+22
-22
@@ -5,12 +5,12 @@
|
||||
"Affected Monitors (%count)": "Betroffene Monitore (%count)",
|
||||
"All Systems Operational": "Alle Systeme betriebsbereit",
|
||||
"Average Latency": "Durchschnittliche Latenz",
|
||||
"Avg Latency": "Durchschnittliche Latenz",
|
||||
"Avg Latency": "Durchschn. Latenz",
|
||||
"Back": "Zurück",
|
||||
"Badges": "Abzeichen",
|
||||
"Badges": "Anzeigen",
|
||||
"CANCELLED": "ABGESAGT",
|
||||
"COMPLETED": "ABGESCHLOSSEN",
|
||||
"Continue": "Weitermachen",
|
||||
"Continue": "Fortsetzen",
|
||||
"Copied": "Kopiert",
|
||||
"Current": "Aktuell",
|
||||
"Dark": "Dunkel",
|
||||
@@ -20,7 +20,7 @@
|
||||
"Degraded": "Beeinträchtigt",
|
||||
"DEGRADED": "BEEINTRÄCHTIGT",
|
||||
"Degraded Performance": "Beeinträchtigte Leistung",
|
||||
"Didn't receive the code? Resend": "Sie haben den Code nicht erhalten? ",
|
||||
"Didn't receive the code? Resend": "Sie haben den Code nicht erhalten? Erneut senden",
|
||||
"Down": "Ausgefallen",
|
||||
"DOWN": "AUSGEFALLEN",
|
||||
"Duration": "Dauer",
|
||||
@@ -29,13 +29,13 @@
|
||||
"Embed this monitor in your website or app": "Betten Sie diesen Monitor in Ihre Website oder App ein",
|
||||
"End Time": "Endzeit",
|
||||
"Enter the verification code sent to your email.": "Geben Sie den Bestätigungscode ein, der an Ihre E-Mail-Adresse gesendet wurde.",
|
||||
"Events": "Veranstaltungen",
|
||||
"Events": "Ereignisse",
|
||||
"Failed to load data": "Daten konnten nicht geladen werden",
|
||||
"Failed to load latency data": "Latenzdaten konnten nicht geladen werden",
|
||||
"Failed to load status data for this day": "Statusdaten für diesen Tag konnten nicht geladen werden",
|
||||
"Failed to send verification code": "Der Bestätigungscode konnte nicht gesendet werden",
|
||||
"Failed to update preference": "Die Präferenz konnte nicht aktualisiert werden",
|
||||
"Get badges for this monitor": "Erhalten Sie Abzeichen für diesen Monitor",
|
||||
"Get badges for this monitor": "Erhalten Sie Statusanzeigen für diesen Monitor",
|
||||
"Get notified about incidents and scheduled maintenance.": "Lassen Sie sich über Vorfälle und geplante Wartungsarbeiten benachrichtigen.",
|
||||
"Get notified about incidents updates": "Lassen Sie sich über Aktualisierungen von Vorfällen benachrichtigen",
|
||||
"Get notified about scheduled maintenance": "Lassen Sie sich über geplante Wartungsarbeiten benachrichtigen",
|
||||
@@ -43,7 +43,7 @@
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Auswirkungen",
|
||||
"incident": "Vorfall",
|
||||
"Incident Updates": "Vorfallaktualisierungen",
|
||||
"Incident Updates": "Vorfallsaktualisierungen",
|
||||
"Incidents": "Vorfälle",
|
||||
"Included Monitors (%count)": "Enthaltene Monitore (%count)",
|
||||
"INVESTIGATING": "WIRD UNTERSUCHT",
|
||||
@@ -64,13 +64,13 @@
|
||||
"Major System Outage": "Schwerwiegender Systemausfall",
|
||||
"Manage Site": "Seite verwalten",
|
||||
"Manage your notification preferences.": "Verwalten Sie Ihre Benachrichtigungseinstellungen.",
|
||||
"Max Latency": "Maximale Latenz",
|
||||
"Max Latency": "Max. Latenz",
|
||||
"Maximum Latency": "Maximale Latenz",
|
||||
"Min Latency": "Min. Latenz",
|
||||
"Minimum Latency": "Min. Latenz",
|
||||
"Minimum Latency": "Minimale Latenz",
|
||||
"Minute-by-minute status data for this day": "Minutenweise Statusdaten für diesen Tag",
|
||||
"MONITORING": "WIRD ÜBERWACHT",
|
||||
"Network error. Please try again.": "Netzwerkfehler. ",
|
||||
"Network error. Please try again.": "Netzwerkfehler. Bitte erneut versuchen.",
|
||||
"No Events in %currentMonth": "Keine Ereignisse in %currentMonth",
|
||||
"No events to show": "Keine Ereignisse zum Anzeigen",
|
||||
"No incidents for this day": "Keine Vorfälle für diesen Tag",
|
||||
@@ -83,7 +83,7 @@
|
||||
"No Status Available": "Kein Status verfügbar",
|
||||
"No upcoming maintenances": "Keine bevorstehenden Wartungsarbeiten",
|
||||
"No Updates": "Keine Aktualisierungen",
|
||||
"No updates yet": "Noch keine Updates",
|
||||
"No updates yet": "Noch keine Aktualisierungen",
|
||||
"Notifications": "Benachrichtigungen",
|
||||
"One-time": "Einmalig",
|
||||
"Ongoing": "Laufend",
|
||||
@@ -103,35 +103,35 @@
|
||||
"SCHEDULED": "GEPLANT",
|
||||
"Scheduled Events (%count)": "Geplante Ereignisse (%count)",
|
||||
"Script": "Skript",
|
||||
"Select Language": "Wählen Sie Sprache aus",
|
||||
"Select latency metric to display": "Latenzmetrik zur Anzeige auswählen",
|
||||
"Select Range": "Wählen Sie Bereich aus",
|
||||
"Select Language": "Sprache auswählen",
|
||||
"Select latency metric to display": "Anzuzeigende Latenzmetrik auswählen",
|
||||
"Select Range": "Bereich auswählen",
|
||||
"Sending...": "Senden...",
|
||||
"Standard": "Standard",
|
||||
"Start Time": "Startzeit",
|
||||
"Status": "Status",
|
||||
"Status Badge": "Statusabzeichen",
|
||||
"Status Badge": "Statusanzeige",
|
||||
"Status Embed": "Status einbetten",
|
||||
"Status history and latency trend": "Statusverlauf und Latenztrend",
|
||||
"Subscribe": "Abonnieren",
|
||||
"Subscribe to Updates": "Benachrichtigungen erhalten",
|
||||
"Subscribe to Updates": "Benachrichtigungen abonnieren",
|
||||
"There are no incidents or maintenances scheduled for this month.": "Für diesen Monat sind keine Vorfälle oder Wartungsarbeiten geplant.",
|
||||
"There are no ongoing incidents or maintenance events.": "Es gibt keine laufenden Vorfälle oder Wartungsereignisse.",
|
||||
"There are no ongoing incidents or maintenance events.": "Es gibt keine laufenden Vorfälle oder Wartungsarbeiten.",
|
||||
"Total Incidents": "Gesamtzahl der Vorfälle",
|
||||
"Total Maintenances": "Gesamtwartungen",
|
||||
"Total Maintenances": "Gesamtzahl der Wartungen",
|
||||
"Under Maintenance": "Unter Wartung",
|
||||
"Unknown impact": "Unbekannte Auswirkung",
|
||||
"UP": "AKTIV",
|
||||
"Upcoming": "Demnächst",
|
||||
"Upcoming": "Anstehend",
|
||||
"Update Incident": "Vorfall aktualisieren",
|
||||
"Update Maintenance": "Wartung aktualisieren",
|
||||
"Updates": "Aktualisierungen",
|
||||
"Updates (%count)": "Aktualisierungen (%count)",
|
||||
"Uptime": "Betriebszeit",
|
||||
"Uptime Badge": "Verfügbarkeitsabzeichen",
|
||||
"Verification failed": "Die Überprüfung ist fehlgeschlagen",
|
||||
"Uptime Badge": "Verfügbarkeitsanzeige",
|
||||
"Verification failed": "Überprüfung fehlgeschlagen",
|
||||
"Verify": "Verifizieren",
|
||||
"Verifying": "Verifizieren",
|
||||
"We sent a 6-digit code to": "Wir haben einen 6-stelligen Code an gesendet"
|
||||
"We sent a 6-digit code to": "Wir haben einen 6-stelligen Code gesendet an"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
"mappings": {
|
||||
"%latency %metric latency": "%latency %metric latence",
|
||||
"Affected Monitors (%count)": "Moniteurs concernés (%count)",
|
||||
"All Systems Operational": "Tous les systèmes opérationnels",
|
||||
"All Systems Operational": "Tous les systèmes sont opérationnels",
|
||||
"Average Latency": "Latence moyenne",
|
||||
"Avg Latency": "Latence moyenne",
|
||||
"Back": "Dos",
|
||||
"Badges": "Insignes",
|
||||
"Back": "Retour",
|
||||
"Badges": "Badges",
|
||||
"CANCELLED": "ANNULÉ",
|
||||
"COMPLETED": "TERMINÉ",
|
||||
"Continue": "Continuer",
|
||||
@@ -93,7 +93,7 @@
|
||||
"Past": "Passé",
|
||||
"Per-Minute Status": "Statut par minute",
|
||||
"Pinging": "Ping",
|
||||
"Please enter a valid email address": "S'il vous plaît, mettez une adresse email valide",
|
||||
"Please enter a valid email address": "Veuillez renseigner une adresse email valide",
|
||||
"Please enter the 6-digit verification code": "Veuillez saisir le code de vérification à 6 chiffres",
|
||||
"Read less": "Lire moins",
|
||||
"Read more": "En savoir plus",
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"name": "Українська",
|
||||
"code": "uk",
|
||||
"mappings": {
|
||||
"%latency %metric latency": "%latency %metric latency",
|
||||
"Affected Monitors (%count)": "Затронуті монітори (%count)",
|
||||
"All Systems Operational": "Усі системи працюють",
|
||||
"Average Latency": "Середня затримка",
|
||||
"Avg Latency": "Сер. затримка",
|
||||
"Back": "Назад",
|
||||
"Badges": "Бейджі",
|
||||
"CANCELLED": "СКАСОВАНО",
|
||||
"COMPLETED": "ЗАВЕРШЕНО",
|
||||
"Continue": "Продовжити",
|
||||
"Copied": "Скопійовано",
|
||||
"Current": "Поточні",
|
||||
"Dark": "Темна",
|
||||
"Day": "День",
|
||||
"Day Uptime": "Час роботи за день",
|
||||
"Days": "Дні",
|
||||
"Degraded": "Погіршення",
|
||||
"DEGRADED": "ПОГІРШЕННЯ",
|
||||
"Degraded Performance": "Зниження продуктивності",
|
||||
"Didn't receive the code? Resend": "Не отримали код? Надіслати повторно",
|
||||
"Down": "Недоступний",
|
||||
"DOWN": "НЕ ПРАЦЮЄ",
|
||||
"Duration": "Тривалість",
|
||||
"Email address": "Адреса електронної пошти",
|
||||
"Embed Monitor": "Вбудувати монітор",
|
||||
"Embed this monitor in your website or app": "Вбудуйте цей монітор у свій сайт або застосунок",
|
||||
"End Time": "Час завершення",
|
||||
"Enter the verification code sent to your email.": "Введіть код підтвердження, надісланий на вашу пошту.",
|
||||
"Events": "Події",
|
||||
"Failed to load data": "Не вдалося завантажити дані",
|
||||
"Failed to load latency data": "Не вдалося завантажити дані затримки",
|
||||
"Failed to load status data for this day": "Не вдалося завантажити дані статусу за цей день",
|
||||
"Failed to send verification code": "Не вдалося надіслати код підтвердження",
|
||||
"Failed to update preference": "Не вдалося оновити налаштування",
|
||||
"Get badges for this monitor": "Отримати бейджі для цього монітора",
|
||||
"Get notified about incidents and scheduled maintenance.": "Отримуйте сповіщення про інциденти та планове обслуговування",
|
||||
"Get notified about incidents updates": "Отримуйте сповіщення про оновлення інцидентів",
|
||||
"Get notified about scheduled maintenance": "Отримуйте сповіщення про планове обслуговування",
|
||||
"IDENTIFIED": "ВИЗНАЧЕНО",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Вплив",
|
||||
"incident": "інцидент",
|
||||
"Incident Updates": "Оновлення інцидентів",
|
||||
"Incidents": "Інциденти",
|
||||
"Included Monitors (%count)": "Включені монітори (%count)",
|
||||
"INVESTIGATING": "ДОСЛІДЖЕННЯ",
|
||||
"Last Updated": "Останнє оновлення",
|
||||
"Latency": "Затримка",
|
||||
"Latency Embed": "Вбудована затримка",
|
||||
"Latency Over Time": "Затримка з часом",
|
||||
"Latency Trend": "Тренд затримки",
|
||||
"Latest Latency": "Остання затримка",
|
||||
"Latest Status": "Останній статус",
|
||||
"Light": "Світла",
|
||||
"Live Status": "Статус у реальному часі",
|
||||
"Loading your preferences...": "Завантаження налаштувань...",
|
||||
"maintenance": "обслуговування",
|
||||
"MAINTENANCE": "ОБСЛУГОВУВАННЯ",
|
||||
"Maintenance Updates": "Оновлення обслуговування",
|
||||
"Maintenances": "Обслуговування",
|
||||
"Major System Outage": "Критичний збій системи",
|
||||
"Manage Site": "Керування сайтом",
|
||||
"Manage your notification preferences.": "Керуйте налаштуваннями сповіщень",
|
||||
"Max Latency": "Макс. затримка",
|
||||
"Maximum Latency": "Максимальна затримка",
|
||||
"Min Latency": "Мін. затримка",
|
||||
"Minimum Latency": "Мінімальна затримка",
|
||||
"Minute-by-minute status data for this day": "Похвилинні дані статусу за цей день",
|
||||
"MONITORING": "МОНІТОРИНГ",
|
||||
"Network error. Please try again.": "Помилка мережі. Спробуйте ще раз",
|
||||
"No Events in %currentMonth": "Немає подій у %currentMonth",
|
||||
"No events to show": "Немає подій для відображення",
|
||||
"No incidents for this day": "Немає інцидентів за цей день",
|
||||
"No latency data available for this day": "Немає даних про затримку за цей день",
|
||||
"No maintenances for this day": "Немає обслуговування за цей день",
|
||||
"No monitors affected": "Жоден монітор не зачеплений",
|
||||
"No monitors available.": "Немає доступних моніторів",
|
||||
"No ongoing maintenances": "Немає поточного обслуговування",
|
||||
"No past maintenances": "Немає минулого обслуговування",
|
||||
"No Status Available": "Статус недоступний",
|
||||
"No upcoming maintenances": "Немає запланованого обслуговування",
|
||||
"No Updates": "Немає оновлень",
|
||||
"No updates yet": "Оновлень поки немає",
|
||||
"Notifications": "Сповіщення",
|
||||
"One-time": "Одноразове",
|
||||
"Ongoing": "Поточні",
|
||||
"Operational": "Працює",
|
||||
"Partial Degraded Performance": "Часткове зниження продуктивності",
|
||||
"Partial System Outage": "Частковий збій системи",
|
||||
"Past": "Минулі",
|
||||
"Per-Minute Status": "Похвилинний статус",
|
||||
"Pinging": "Перевірка доступності",
|
||||
"Please enter a valid email address": "Будь ласка, введіть дійсну електронну адресу",
|
||||
"Please enter the 6-digit verification code": "Будь ласка, введіть 6-значний код підтвердження",
|
||||
"Read less": "Згорнути",
|
||||
"Read more": "Читати більше",
|
||||
"READY": "ГОТОВО",
|
||||
"Recurring": "Повторюване",
|
||||
"RESOLVED": "ВИРІШЕНО",
|
||||
"SCHEDULED": "ЗАПЛАНОВАНО",
|
||||
"Scheduled Events (%count)": "Заплановані події (%count)",
|
||||
"Script": "Скрипт",
|
||||
"Select Language": "Оберіть мову",
|
||||
"Select latency metric to display": "Оберіть метрику затримки для відображення",
|
||||
"Select Range": "Оберіть діапазон",
|
||||
"Sending...": "Надсилання...",
|
||||
"Standard": "Стандартний",
|
||||
"Start Time": "Час початку",
|
||||
"Status": "Статус",
|
||||
"Status Badge": "Бейдж статусу",
|
||||
"Status Embed": "Вбудований статус",
|
||||
"Status history and latency trend": "Історія статусів та тренд затримки",
|
||||
"Subscribe": "Підписатися",
|
||||
"Subscribe to Updates": "Підписатися на оновлення",
|
||||
"There are no incidents or maintenances scheduled for this month.": "На цей місяць не заплановано інцидентів або обслуговування",
|
||||
"There are no ongoing incidents or maintenance events.": "Наразі немає активних інцидентів або обслуговування",
|
||||
"Total Incidents": "Загальна кількість інцидентів",
|
||||
"Total Maintenances": "Загальна кількість обслуговувань",
|
||||
"Under Maintenance": "На обслуговуванні",
|
||||
"Unknown impact": "Невідомий вплив",
|
||||
"UP": "ПРАЦЮЄ",
|
||||
"Upcoming": "Майбутні",
|
||||
"Update Incident": "Оновити інцидент",
|
||||
"Update Maintenance": "Оновити обслуговування",
|
||||
"Updates": "Оновлення",
|
||||
"Updates (%count)": "Оновлення (%count)",
|
||||
"Uptime": "Час роботи",
|
||||
"Uptime Badge": "Бейдж часу роботи",
|
||||
"Verification failed": "Перевірка не вдалася",
|
||||
"Verify": "Підтвердити",
|
||||
"Verifying": "Перевірка",
|
||||
"We sent a 6-digit code to": "Ми надіслали 6-значний код на"
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,10 @@ import type { LayoutServerData } from "./layoutController.js";
|
||||
// Default page settings
|
||||
const defaultPageSettings: PageSettingsType = {
|
||||
monitor_status_history_days: {
|
||||
desktop: 90,
|
||||
mobile: 30,
|
||||
desktop: GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP,
|
||||
mobile: GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE,
|
||||
},
|
||||
monitor_layout_style: "default-list",
|
||||
monitor_layout_style: GC.DEFAULT_MONITOR_LAYOUT_STYLE,
|
||||
};
|
||||
|
||||
export interface NotificationEvent {
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
GetLoggedInSession,
|
||||
GetLocaleFromCookie,
|
||||
GetUsersCount,
|
||||
HasRequiredEnv,
|
||||
IsEmailSetup,
|
||||
IsSetupComplete,
|
||||
} from "./controller.js";
|
||||
import type { EventDisplaySettings, GlobalPageVisibilitySettings, SiteDateTimeFormat } from "$lib/types/site.js";
|
||||
|
||||
@@ -86,7 +86,9 @@ export async function GetLayoutServerData(cookies: Cookies, request: Request): P
|
||||
GetUsersCount(),
|
||||
]);
|
||||
|
||||
const isSetupComplete = await IsSetupComplete();
|
||||
// Same check as IsSetupComplete, but reuses the site data fetched above
|
||||
// instead of querying it a second time on every request
|
||||
const isSetupComplete = HasRequiredEnv() && Object.keys(siteData).length > 0;
|
||||
|
||||
const selectedLang = GetLocaleFromCookie(siteData, cookies);
|
||||
const siteStatusColors = siteData.colors;
|
||||
|
||||
@@ -486,18 +486,75 @@ export const UpdateMaintenanceEvent = async (
|
||||
return await db.updateMaintenanceEvent(id, data);
|
||||
};
|
||||
|
||||
export const UpdateMaintenanceEventStatus = async (id: number, status: string): Promise<number> => {
|
||||
const validStatuses = ["SCHEDULED", "IN_PROGRESS", "COMPLETED", "CANCELLED"];
|
||||
if (!validStatuses.includes(status)) {
|
||||
throw new Error(`Invalid status: ${status}`);
|
||||
/**
|
||||
* Manually transition a maintenance event to a terminal status.
|
||||
* Allowed transitions: ONGOING → COMPLETED, SCHEDULED/READY/ONGOING → CANCELLED.
|
||||
* An event that already started has its end_date_time moved to the moment it was
|
||||
* ended (the record reflects what actually happened); an event that never started
|
||||
* keeps its planned window. See docs/adr/0006-manual-maintenance-event-transitions.md
|
||||
*/
|
||||
export const UpdateMaintenanceEventStatus = async (id: number, status: string): Promise<MaintenanceEventRecord> => {
|
||||
if (status !== GC.COMPLETED && status !== GC.CANCELLED) {
|
||||
throw new Error(`Invalid status: ${status}. Allowed values are ${GC.COMPLETED} and ${GC.CANCELLED}`);
|
||||
}
|
||||
const targetStatus = status as "COMPLETED" | "CANCELLED";
|
||||
|
||||
const existing = await db.getMaintenanceEventById(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Maintenance event with id ${id} does not exist`);
|
||||
}
|
||||
|
||||
return await db.updateMaintenanceEventStatus(id, status);
|
||||
const allowedFrom: string[] = targetStatus === GC.COMPLETED ? [GC.ONGOING] : [GC.SCHEDULED, GC.READY, GC.ONGOING];
|
||||
if (!allowedFrom.includes(existing.status)) {
|
||||
throw new Error(`Cannot transition event from ${existing.status} to ${targetStatus}`);
|
||||
}
|
||||
|
||||
if (existing.status === GC.ONGOING) {
|
||||
// Ended now, but never before its first minute nor after its planned end
|
||||
const endDateTime = Math.min(
|
||||
existing.end_date_time,
|
||||
Math.max(GetMinuteStartNowTimestampUTC(), existing.start_date_time + 60),
|
||||
);
|
||||
await db.updateMaintenanceEvent(id, { status: targetStatus, end_date_time: endDateTime });
|
||||
} else {
|
||||
await db.updateMaintenanceEventStatus(id, targetStatus);
|
||||
}
|
||||
|
||||
const updated = await db.getMaintenanceEventById(id);
|
||||
if (!updated) {
|
||||
throw new Error(`Maintenance event with id ${id} does not exist`);
|
||||
}
|
||||
|
||||
try {
|
||||
const siteData = await GetAllSiteData();
|
||||
const notificationSettings =
|
||||
siteData.globalMaintenanceNotificationSettings || seedSiteData.globalMaintenanceNotificationSettings;
|
||||
if (notificationSettings.event_types.ended) {
|
||||
const siteVars = siteDataToVariables(siteData);
|
||||
const siteUrl = siteVars.site_url;
|
||||
const maintenance = await db.getMaintenanceById(updated.maintenance_id);
|
||||
const monitors = await db.getMonitorsByMaintenanceId(updated.maintenance_id);
|
||||
const monitorNames = monitors.map((m) => `${m.monitor_name}(${m.monitor_impact})`).join(", ");
|
||||
const eventDetailed: MaintenanceEventRecordDetailed = {
|
||||
...updated,
|
||||
title: maintenance?.title || "",
|
||||
description: maintenance?.description || null,
|
||||
};
|
||||
const update = maintenanceToVariables(
|
||||
eventDetailed,
|
||||
monitorNames,
|
||||
targetStatus === GC.COMPLETED ? "**has been completed**" : "**has been cancelled**",
|
||||
targetStatus === GC.COMPLETED ? "completed" : "cancelled",
|
||||
targetStatus === GC.COMPLETED ? "Maintenance Completed" : "Maintenance Cancelled",
|
||||
siteUrl,
|
||||
);
|
||||
await subscriberQueue.push(update);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error sending ${targetStatus} notification for maintenance event ${id}:`, err);
|
||||
}
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
export const DeleteMaintenanceEvent = async (id: number): Promise<number> => {
|
||||
|
||||
@@ -328,7 +328,8 @@ export async function DeleteMonitorAlertConfig(id: number): Promise<boolean> {
|
||||
throw new Error(`Monitor alert config with id '${id}' not found`);
|
||||
}
|
||||
|
||||
// Triggers will be deleted automatically due to CASCADE
|
||||
// The repository deletes trigger/monitor junctions and v2 alerts explicitly;
|
||||
// FK cascades are not enforced on SQLite
|
||||
const deleted = await db.deleteMonitorAlertConfig(id);
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
@@ -21,11 +21,11 @@ import type {
|
||||
import type { MonitorFilter } from "../db/repositories/base.js";
|
||||
import db from "../db/db.js";
|
||||
import type { PaginationInput } from "../../types/common.js";
|
||||
import type { DayWiseStatus, NumberWithChange } from "../../types/monitor.js";
|
||||
import GC, { getBadgeStyle, type BadgeStyle } from "../../global-constants.js";
|
||||
import { makeBadge } from "badge-maker";
|
||||
import { ErrorSvg } from "../../anywhere.js";
|
||||
import { GetLastMonitoringValue, SetLastHeartbeat, DeleteMonitorCaches } from "../cache/setGet.js";
|
||||
import { CollapseStatusCounts } from "../../clientTools.js";
|
||||
import { translate, isLocaleAvailable } from "../i18n.js";
|
||||
import type { HeartbeatMonitor, GroupMonitorTypeData } from "../types/monitor.js";
|
||||
|
||||
@@ -92,6 +92,7 @@ interface MonitoringDataInput {
|
||||
latency?: number;
|
||||
type: string;
|
||||
error_message?: string | null;
|
||||
raw_status?: string | null;
|
||||
}
|
||||
|
||||
interface InterpolatedDataEntry {
|
||||
@@ -112,6 +113,7 @@ export const InsertMonitoringData = async (data: MonitoringDataInput): Promise<M
|
||||
latency: data.latency || 0,
|
||||
type: data.type,
|
||||
error_message: data.error_message,
|
||||
raw_status: data.raw_status,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -264,6 +266,7 @@ export const CloneMonitor = async ({ sourceTag, newTag, newName }: CloneMonitorI
|
||||
type_data: source.type_data,
|
||||
day_degraded_minimum_count: source.day_degraded_minimum_count,
|
||||
day_down_minimum_count: source.day_down_minimum_count,
|
||||
confirmation_threshold: source.confirmation_threshold,
|
||||
include_degraded_in_downtime: source.include_degraded_in_downtime,
|
||||
is_hidden: source.is_hidden,
|
||||
monitor_settings_json: source.monitor_settings_json,
|
||||
@@ -290,7 +293,7 @@ export const GetLatestMonitoringData = async (monitor_tag: string): Promise<Moni
|
||||
};
|
||||
export const GetLatestStatusActiveAll = async (): Promise<{ status: string }> => {
|
||||
//get all the active not hidden monitor tags
|
||||
const monitors = await db.getMonitors({ status: "ACTIVE", is_hidden: "NO" });
|
||||
const monitors = await db.getMonitors({ status: GC.ACTIVE, is_hidden: GC.NO });
|
||||
const monitor_tags = monitors.map((m) => m.tag);
|
||||
|
||||
const latestData: MonitoringData[] = [];
|
||||
@@ -302,19 +305,20 @@ export const GetLatestStatusActiveAll = async (): Promise<{ status: string }> =>
|
||||
}
|
||||
}
|
||||
|
||||
let status: string = GC.NO_DATA;
|
||||
for (let i = 0; i < latestData.length; i++) {
|
||||
//if any status is down then status = down, if any is degraded then status = degraded, down > degraded > up
|
||||
if (latestData[i].status === GC.DOWN) {
|
||||
status = GC.DOWN;
|
||||
} else if (latestData[i].status === GC.DEGRADED && status !== GC.DOWN) {
|
||||
status = GC.DEGRADED;
|
||||
} else if (latestData[i].status === GC.UP && status !== GC.DOWN && status !== GC.DEGRADED) {
|
||||
status = GC.UP;
|
||||
const counts = { countOfUp: 0, countOfDown: 0, countOfDegraded: 0, countOfMaintenance: 0 };
|
||||
for (const data of latestData) {
|
||||
if (data.status === GC.UP) {
|
||||
counts.countOfUp++;
|
||||
} else if (data.status === GC.DOWN) {
|
||||
counts.countOfDown++;
|
||||
} else if (data.status === GC.DEGRADED) {
|
||||
counts.countOfDegraded++;
|
||||
} else if (data.status === GC.MAINTENANCE) {
|
||||
counts.countOfMaintenance++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: status,
|
||||
status: CollapseStatusCounts(counts),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -419,6 +423,7 @@ async function removeTagFromGroupMonitors(tag: string): Promise<void> {
|
||||
type_data: JSON.stringify(typeData),
|
||||
day_degraded_minimum_count: group.day_degraded_minimum_count,
|
||||
day_down_minimum_count: group.day_down_minimum_count,
|
||||
confirmation_threshold: group.confirmation_threshold,
|
||||
include_degraded_in_downtime: group.include_degraded_in_downtime,
|
||||
is_hidden: group.is_hidden,
|
||||
monitor_settings_json:
|
||||
@@ -436,6 +441,7 @@ export const DeleteMonitorCompletelyUsingTag = async (tag: string): Promise<numb
|
||||
await db.deleteMonitorDataByTag(tag);
|
||||
await db.deleteIncidentMonitorsByTag(tag);
|
||||
await db.deleteMonitorAlertsByTag(tag);
|
||||
await db.deleteMonitorAlertConfigsByMonitorTag(tag);
|
||||
await db.deletePageMonitorsByTag(tag);
|
||||
await db.deleteMaintenanceMonitorsByTag(tag);
|
||||
await removeTagFromGroupMonitors(tag);
|
||||
@@ -461,9 +467,6 @@ export const GetAllAlertsPaginated = async (
|
||||
export const GetMonitoringData = async (tag: string, since: number, now: number): Promise<MonitoringData[]> => {
|
||||
return await db.getMonitoringData(tag, since, now);
|
||||
};
|
||||
export const GetMonitoringDataAll = async (tags: string[], since: number, now: number): Promise<MonitoringData[]> => {
|
||||
return await db.getMonitoringDataAll(tags, since, now);
|
||||
};
|
||||
|
||||
export const InsertNewAlert = async (data: MonitorAlertInsert): Promise<MonitorAlert | undefined> => {
|
||||
if (await db.alertExists(data.monitor_tag, data.monitor_status, data.alert_status)) {
|
||||
@@ -547,7 +550,7 @@ export const GetBadge = async (badgeType: BadgeType, params: BadgeParams): Promi
|
||||
lastObj = await GetLatestStatusActiveAll();
|
||||
} else {
|
||||
// Single monitor status
|
||||
const monitors = await GetMonitorsParsed({ tag, status: "ACTIVE", is_hidden: "NO" });
|
||||
const monitors = await GetMonitorsParsed({ tag, status: GC.ACTIVE, is_hidden: GC.NO });
|
||||
if (monitors.length === 0) {
|
||||
return new Response(ErrorSvg, {
|
||||
headers: { "Content-Type": "image/svg+xml" },
|
||||
@@ -635,14 +638,14 @@ export const GetBadge = async (badgeType: BadgeType, params: BadgeParams): Promi
|
||||
const siteData = await db.getSiteDataByKey("siteName");
|
||||
const siteName = siteData?.value as string | undefined;
|
||||
name = siteName || "All Monitors";
|
||||
const goodMonitors = await GetMonitorsParsed({ status: "ACTIVE", is_hidden: "NO" });
|
||||
const goodMonitors = await GetMonitorsParsed({ status: GC.ACTIVE, is_hidden: GC.NO });
|
||||
const activeTags = goodMonitors.map((monitor) => monitor.tag);
|
||||
|
||||
stats = await db.getStatusCountsByInterval(activeTags, since, now - since, 1);
|
||||
uptimeData = UptimeCalculator(stats);
|
||||
} else {
|
||||
// Single monitor badge
|
||||
const monitors = await GetMonitorsParsed({ tag });
|
||||
const monitors = await GetMonitorsParsed({ tag, status: GC.ACTIVE, is_hidden: GC.NO });
|
||||
if (monitors.length === 0) {
|
||||
return new Response(ErrorSvg, {
|
||||
headers: { "Content-Type": "image/svg+xml" },
|
||||
@@ -751,3 +754,6 @@ export const GetStatusCountsByIntervalGroupedByMonitor = async (
|
||||
await setCache(cacheKey, result, 60);
|
||||
return result;
|
||||
};
|
||||
export const GetLastKnownStatus = async (monitor_tag: string): Promise<MonitoringData | undefined> => {
|
||||
return await db.getLastKnownStatus(monitor_tag);
|
||||
};
|
||||
|
||||
@@ -108,6 +108,22 @@ export const GetLocaleFromCookie = (site: SiteDataTransformed, cookies: Cookies)
|
||||
return selectedLang;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the site URL used for building absolute public URLs, without a trailing slash.
|
||||
* Prefers the configured siteURL and falls back to the ORIGIN env var; only absolute
|
||||
* http(s) values are returned. Returns an empty string when neither is usable, in which
|
||||
* case callers degrade to a relative path.
|
||||
*/
|
||||
export const GetSiteURL = async (): Promise<string> => {
|
||||
const siteURL = await GetSiteDataByKey("siteURL");
|
||||
for (const candidate of [siteURL, process.env.ORIGIN]) {
|
||||
if (typeof candidate === "string" && /^https?:\/\//i.test(candidate)) {
|
||||
return candidate.replace(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
export const GetSiteLogoURL = async (siteURL: string, logo: string, base: string): Promise<string> => {
|
||||
if (logo.startsWith("http")) {
|
||||
return logo;
|
||||
@@ -138,14 +154,17 @@ export const GetSiteDataByKey = async (key: string): Promise<unknown> => {
|
||||
return data.value;
|
||||
};
|
||||
|
||||
/** Checks the env vars required for setup, without touching the database. */
|
||||
export const HasRequiredEnv = (): boolean => {
|
||||
return (
|
||||
process.env.KENER_SECRET_KEY !== undefined &&
|
||||
process.env.ORIGIN !== undefined &&
|
||||
process.env.REDIS_URL !== undefined
|
||||
);
|
||||
};
|
||||
|
||||
export const IsSetupComplete = async (): Promise<boolean> => {
|
||||
if (process.env.KENER_SECRET_KEY === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (process.env.ORIGIN === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (process.env.REDIS_URL === undefined) {
|
||||
if (!HasRequiredEnv()) {
|
||||
return false;
|
||||
}
|
||||
let data = await db.getAllSiteData();
|
||||
|
||||
@@ -2,7 +2,7 @@ import db from "../db/db.js";
|
||||
import type { PaginationInput } from "$lib/types/common";
|
||||
import { GenerateToken, HashPassword, ValidatePassword, VerifyToken } from "./commonController.js";
|
||||
import type { Cookies } from "@sveltejs/kit";
|
||||
import type { UserRecordPublic, UserRecordDashboard } from "../types/db.js";
|
||||
import type { UserRecordPublic, UserRecordDashboard, RoleRecord } from "../types/db.js";
|
||||
import { GetAllSiteData } from "./controller.js";
|
||||
import { siteDataToVariables } from "../notification/notification_utils.js";
|
||||
import sendEmail from "../notification/email_notification.js";
|
||||
@@ -16,7 +16,7 @@ export interface UserUpdateInput {
|
||||
|
||||
interface ManualUserUpdateInput {
|
||||
updateType: string;
|
||||
role?: string;
|
||||
role_ids?: string[];
|
||||
is_active?: number;
|
||||
password?: string;
|
||||
passwordPlain?: string;
|
||||
@@ -32,7 +32,7 @@ interface NewUserInput {
|
||||
name: string;
|
||||
password: string;
|
||||
plainPassword: string;
|
||||
role: string;
|
||||
role_ids: string[];
|
||||
}
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
@@ -65,12 +65,18 @@ const validateNameOrThrow = (name: string): string => {
|
||||
return normalizedName;
|
||||
};
|
||||
|
||||
export const GetAllUsersPaginated = async (data: PaginationInput): Promise<UserRecordPublic[]> => {
|
||||
return await db.getUsersPaginated(data.page, data.limit);
|
||||
export const GetAllUsersPaginated = async (
|
||||
data: PaginationInput,
|
||||
filter?: { is_active?: number },
|
||||
): Promise<UserRecordPublic[]> => {
|
||||
return await db.getUsersPaginated(data.page, data.limit, filter);
|
||||
};
|
||||
|
||||
export const GetAllUsersPaginatedDashboard = async (data: PaginationInput): Promise<UserRecordDashboard[]> => {
|
||||
const users = await db.getUsersPaginated(data.page, data.limit);
|
||||
export const GetAllUsersPaginatedDashboard = async (
|
||||
data: PaginationInput,
|
||||
filter?: { is_active?: number },
|
||||
): Promise<UserRecordDashboard[]> => {
|
||||
const users = await db.getUsersPaginated(data.page, data.limit, filter);
|
||||
if (users.length === 0) return [];
|
||||
|
||||
// Batch fetch password statuses for all users
|
||||
@@ -88,8 +94,8 @@ export const GetAllUsers = async () => {
|
||||
return await db.getAllUsers();
|
||||
};
|
||||
|
||||
export const GetUsersCount = async () => {
|
||||
return await db.getUsersCount();
|
||||
export const GetUsersCount = async (filter?: { is_active?: number }) => {
|
||||
return await db.getTotalUsers(filter);
|
||||
};
|
||||
|
||||
export const GetUserPasswordHashById = async (id: number) => {
|
||||
@@ -145,14 +151,20 @@ export const UpdateUserData = async (data: UserUpdateInput): Promise<number> =>
|
||||
}
|
||||
};
|
||||
|
||||
export const CreateNewUser = async (currentUser: { role: string }, data: NewUserInput): Promise<number[]> => {
|
||||
let acceptedRoles = ["member", "editor"];
|
||||
if (!acceptedRoles.includes(data.role)) {
|
||||
throw new Error("Invalid role");
|
||||
export const CreateNewUser = async (data: NewUserInput): Promise<number[]> => {
|
||||
if (!data.role_ids || data.role_ids.length === 0) {
|
||||
throw new Error("At least one role is required");
|
||||
}
|
||||
|
||||
if (currentUser.role === "member") {
|
||||
throw new Error("Only admins and editors can create new users");
|
||||
// Validate all role_ids exist and are active
|
||||
for (const roleId of data.role_ids) {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" does not exist`);
|
||||
}
|
||||
if (role.status !== "ACTIVE") {
|
||||
throw new Error(`Role "${roleId}" is not active`);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedEmail = validateEmailOrThrow(data.email);
|
||||
@@ -163,11 +175,6 @@ export const CreateNewUser = async (currentUser: { role: string }, data: NewUser
|
||||
throw new Error("Password cannot be empty");
|
||||
}
|
||||
|
||||
//if data.role empty, throw error
|
||||
if (!!!data.role) {
|
||||
throw new Error("Role cannot be empty");
|
||||
}
|
||||
|
||||
//if data.password not equal to data.plainPassword, throw error
|
||||
if (data.password !== data.plainPassword) {
|
||||
throw new Error("Passwords do not match");
|
||||
@@ -182,7 +189,7 @@ export const CreateNewUser = async (currentUser: { role: string }, data: NewUser
|
||||
email: normalizedEmail,
|
||||
password_hash: await HashPassword(data.password),
|
||||
name: normalizedName,
|
||||
role: data.role,
|
||||
role_ids: data.role_ids,
|
||||
};
|
||||
return await db.insertUser(user);
|
||||
};
|
||||
@@ -202,7 +209,7 @@ export const CreateFirstUser = async (data: { email: string; name: string; passw
|
||||
email: normalizedEmail,
|
||||
password_hash: await HashPassword(data.password),
|
||||
name: normalizedName,
|
||||
role: "admin",
|
||||
role_ids: ["admin"],
|
||||
is_owner: "YES",
|
||||
};
|
||||
return await db.insertUser(user);
|
||||
@@ -229,33 +236,34 @@ export const UpdatePassword = async (data: PasswordUpdateInput): Promise<number>
|
||||
});
|
||||
};
|
||||
|
||||
const VALID_ROLES = ["admin", "editor", "member"] as const;
|
||||
|
||||
export const ManualUpdateUserData = async (
|
||||
byUser: { id: number; role: string; is_owner: string },
|
||||
forUserId: number,
|
||||
data: ManualUserUpdateInput,
|
||||
): Promise<number | undefined> => {
|
||||
export const ManualUpdateUserData = async (forUserId: number, data: ManualUserUpdateInput): Promise<number | void> => {
|
||||
let forUser = await db.getUserById(forUserId);
|
||||
if (!forUser) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
//only admins can update
|
||||
if (byUser.role !== "admin") {
|
||||
throw new Error("You do not have permission to update user");
|
||||
}
|
||||
// non-owner admins cannot modify other admins (self-updates are allowed)
|
||||
if (forUser.role === "admin" && byUser.is_owner !== "YES" && forUser.id !== byUser.id) {
|
||||
throw new Error("Only the owner can modify other admins");
|
||||
}
|
||||
if (data.updateType == "role") {
|
||||
if (!data.role) throw new Error("Role is required");
|
||||
if (!VALID_ROLES.includes(data.role as (typeof VALID_ROLES)[number])) {
|
||||
throw new Error(`Invalid role. Must be one of: ${VALID_ROLES.join(", ")}`);
|
||||
if (!data.role_ids || data.role_ids.length === 0) throw new Error("At least one role is required");
|
||||
// Owner must always retain the admin role
|
||||
if (forUser.is_owner === "YES" && !data.role_ids.includes("admin")) {
|
||||
throw new Error("Owner must retain the admin role");
|
||||
}
|
||||
return await db.updateUserRole(forUser.id, data.role);
|
||||
// Validate all role_ids exist and are active
|
||||
for (const roleId of data.role_ids) {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" does not exist`);
|
||||
}
|
||||
if (role.status !== "ACTIVE") {
|
||||
throw new Error(`Role "${roleId}" is not active`);
|
||||
}
|
||||
}
|
||||
return await db.updateUserRoles(forUser.id, data.role_ids);
|
||||
} else if (data.updateType == "is_active") {
|
||||
if (data.is_active === undefined) throw new Error("is_active is required");
|
||||
// Owner cannot be deactivated
|
||||
if (forUser.is_owner === "YES" && data.is_active === 0) {
|
||||
throw new Error("Owner account cannot be deactivated");
|
||||
}
|
||||
return await db.updateUserIsActive(forUser.id, data.is_active);
|
||||
} else if (data.updateType == "password") {
|
||||
if (!data.password || !data.passwordPlain) throw new Error("Password is required");
|
||||
@@ -297,15 +305,20 @@ export const GetTotalUserPages = async (limit: number): Promise<number> => {
|
||||
};
|
||||
|
||||
//send invitation email to user for account creation
|
||||
export const SendInvitationEmail = async (email: string, role: string, name: string, currentUserRole: string) => {
|
||||
if (currentUserRole === "member") {
|
||||
throw new Error("Only admins and editors can create new users");
|
||||
export const SendInvitationEmail = async (email: string, role_ids: string[], name: string) => {
|
||||
if (!role_ids || role_ids.length === 0) {
|
||||
throw new Error("At least one role is required");
|
||||
}
|
||||
|
||||
// Admins can add admin, editor, member; Editors can only add editor, member
|
||||
const acceptedRoles = currentUserRole === "admin" ? ["admin", "editor", "member"] : ["editor", "member"];
|
||||
if (!acceptedRoles.includes(role)) {
|
||||
throw new Error("Invalid role");
|
||||
// Validate all role_ids exist and are active
|
||||
for (const roleId of role_ids) {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" does not exist`);
|
||||
}
|
||||
if (role.status !== "ACTIVE") {
|
||||
throw new Error(`Role "${roleId}" is not active`);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedEmail = validateEmailOrThrow(email);
|
||||
@@ -323,7 +336,7 @@ export const SendInvitationEmail = async (email: string, role: string, name: str
|
||||
email: normalizedEmail,
|
||||
password_hash: "",
|
||||
name: normalizedName,
|
||||
role,
|
||||
role_ids: role_ids,
|
||||
is_active: 0,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
@@ -364,11 +377,7 @@ export const SendInvitationEmail = async (email: string, role: string, name: str
|
||||
};
|
||||
|
||||
//resend invitation email to existing user with blank password
|
||||
export const ResendInvitationEmail = async (email: string, currentUserRole: string) => {
|
||||
if (currentUserRole === "member") {
|
||||
throw new Error("Only admins and editors can resend invitations");
|
||||
}
|
||||
|
||||
export const ResendInvitationEmail = async (email: string) => {
|
||||
const normalizedEmail = validateEmailOrThrow(email);
|
||||
|
||||
const user = await db.getUserByEmail(normalizedEmail);
|
||||
@@ -410,17 +419,11 @@ export const ResendInvitationEmail = async (email: string, currentUserRole: stri
|
||||
};
|
||||
|
||||
// send verification email with verification link
|
||||
export const SendVerificationEmail = async (toUserId: number, currentUser: { id: number; role: string }) => {
|
||||
export const SendVerificationEmail = async (toUserId: number, currentUserId: number) => {
|
||||
if (!toUserId) {
|
||||
throw new Error("User ID is required");
|
||||
}
|
||||
|
||||
// Only admins/editors can send verification to other users.
|
||||
// Members can only send verification email to themselves.
|
||||
if (currentUser.role === "member" && currentUser.id !== toUserId) {
|
||||
throw new Error("You do not have permission to send verification email for this user");
|
||||
}
|
||||
|
||||
const user = await db.getUserById(toUserId);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
@@ -458,3 +461,221 @@ export const SendVerificationEmail = async (toUserId: number, currentUser: { id:
|
||||
template.template_text_body || "",
|
||||
);
|
||||
};
|
||||
|
||||
const RESTRICTED_ROLE_IDS = ["admin", "editor", "member"];
|
||||
const ROLE_ID_REGEX = /^[a-z0-9_-]+$/;
|
||||
|
||||
const normalizeRoleId = (id: string): string => {
|
||||
return id.trim().toLowerCase().replace(/\s+/g, "_");
|
||||
};
|
||||
|
||||
export const CreateRole = async (data: { role_id: string; name: string }): Promise<RoleRecord> => {
|
||||
const roleId = normalizeRoleId(data.role_id || "");
|
||||
const roleName = data.name?.trim();
|
||||
|
||||
if (!roleId) {
|
||||
throw new Error("Role ID is required");
|
||||
}
|
||||
if (!ROLE_ID_REGEX.test(roleId)) {
|
||||
throw new Error("Role ID can only contain lowercase letters, numbers, underscores, and hyphens");
|
||||
}
|
||||
if (!roleName) {
|
||||
throw new Error("Role name is required");
|
||||
}
|
||||
|
||||
if (RESTRICTED_ROLE_IDS.includes(roleId)) {
|
||||
throw new Error(`Role ID "${roleId}" is restricted and cannot be used`);
|
||||
}
|
||||
|
||||
const existing = await db.getRoleById(roleId);
|
||||
if (existing) {
|
||||
throw new Error(`Role with ID "${roleId}" already exists`);
|
||||
}
|
||||
|
||||
await db.insertRole({ id: roleId, role_name: roleName });
|
||||
|
||||
const created = await db.getRoleById(roleId);
|
||||
if (!created) {
|
||||
throw new Error("Failed to create role");
|
||||
}
|
||||
return created;
|
||||
};
|
||||
|
||||
export const UpdateRole = async (roleId: string, data: { name?: string; status?: string }): Promise<RoleRecord> => {
|
||||
if (!roleId) {
|
||||
throw new Error("Role ID is required");
|
||||
}
|
||||
|
||||
const existing = await db.getRoleById(roleId);
|
||||
if (!existing) {
|
||||
throw new Error(`Role "${roleId}" not found`);
|
||||
}
|
||||
|
||||
if (existing.readonly === 1) {
|
||||
throw new Error("Readonly roles cannot be updated");
|
||||
}
|
||||
|
||||
const updates: { role_name?: string; status?: string } = {};
|
||||
|
||||
if (data.name !== undefined) {
|
||||
const trimmed = data.name.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Role name cannot be empty");
|
||||
}
|
||||
updates.role_name = trimmed;
|
||||
}
|
||||
|
||||
if (data.status !== undefined) {
|
||||
if (data.status !== "ACTIVE" && data.status !== "INACTIVE") {
|
||||
throw new Error("Status must be ACTIVE or INACTIVE");
|
||||
}
|
||||
updates.status = data.status;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
throw new Error("No valid fields to update");
|
||||
}
|
||||
|
||||
await db.updateRole(roleId, updates);
|
||||
|
||||
const updated = await db.getRoleById(roleId);
|
||||
if (!updated) {
|
||||
throw new Error("Failed to retrieve updated role");
|
||||
}
|
||||
return updated;
|
||||
};
|
||||
|
||||
export const DeleteRole = async (
|
||||
roleId: string,
|
||||
options: { action: "migrate"; targetRoleId: string } | { action: "remove" },
|
||||
): Promise<{ success: true }> => {
|
||||
if (!roleId) {
|
||||
throw new Error("Role ID is required");
|
||||
}
|
||||
|
||||
const existing = await db.getRoleById(roleId);
|
||||
if (!existing) {
|
||||
throw new Error(`Role "${roleId}" not found`);
|
||||
}
|
||||
|
||||
if (existing.readonly === 1) {
|
||||
throw new Error("Readonly roles cannot be deleted");
|
||||
}
|
||||
|
||||
if (options.action === "migrate") {
|
||||
const targetRoleId = options.targetRoleId?.trim();
|
||||
if (!targetRoleId) {
|
||||
throw new Error("Target role ID is required for migration");
|
||||
}
|
||||
if (targetRoleId === roleId) {
|
||||
throw new Error("Target role cannot be the same as the role being deleted");
|
||||
}
|
||||
const targetRole = await db.getRoleById(targetRoleId);
|
||||
if (!targetRole) {
|
||||
throw new Error(`Target role "${targetRoleId}" not found`);
|
||||
}
|
||||
if (targetRole.status !== "ACTIVE") {
|
||||
throw new Error("Cannot migrate users to an inactive role");
|
||||
}
|
||||
await db.migrateUsersRole(roleId, targetRoleId);
|
||||
}
|
||||
|
||||
// CASCADE on FK will clean up users_roles and roles_permissions
|
||||
await db.deleteRole(roleId);
|
||||
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const GetAllRoles = async (): Promise<RoleRecord[]> => {
|
||||
return await db.getAllRoles();
|
||||
};
|
||||
|
||||
export const GetAllPermissions = async () => {
|
||||
return await db.getAllPermissions();
|
||||
};
|
||||
|
||||
export const GetRolePermissions = async (roleId: string) => {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" not found`);
|
||||
}
|
||||
return await db.getRolePermissions(roleId);
|
||||
};
|
||||
|
||||
export const UpdateRolePermissions = async (roleId: string, permissionIds: string[]) => {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" not found`);
|
||||
}
|
||||
if (role.readonly === 1) {
|
||||
throw new Error("Readonly roles cannot have their permissions modified");
|
||||
}
|
||||
|
||||
// Get current permissions
|
||||
const current = await db.getRolePermissions(roleId);
|
||||
const currentIds = new Set(current.map((p) => p.permissions_id));
|
||||
const desiredIds = new Set(permissionIds);
|
||||
|
||||
// Add new permissions
|
||||
for (const pid of permissionIds) {
|
||||
if (!currentIds.has(pid)) {
|
||||
await db.addRolePermission(roleId, pid);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old permissions
|
||||
for (const pid of currentIds) {
|
||||
if (!desiredIds.has(pid)) {
|
||||
await db.removeRolePermission(roleId, pid);
|
||||
}
|
||||
}
|
||||
|
||||
return await db.getRolePermissions(roleId);
|
||||
};
|
||||
|
||||
export const GetRoleUsers = async (roleId: string) => {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" not found`);
|
||||
}
|
||||
return await db.getUsersByRoleId(roleId);
|
||||
};
|
||||
|
||||
export const AddUserToRole = async (roleId: string, userId: number) => {
|
||||
const role = await db.getRoleById(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role "${roleId}" not found`);
|
||||
}
|
||||
if (role.status !== "ACTIVE") {
|
||||
throw new Error(`Role "${roleId}" is not active`);
|
||||
}
|
||||
// Check if user already in role
|
||||
const users = await db.getUsersByRoleId(roleId);
|
||||
if (users.some((u) => u.id === userId)) {
|
||||
throw new Error("User is already assigned to this role");
|
||||
}
|
||||
await db.addUserToRole(roleId, userId);
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const RemoveUserFromRole = async (roleId: string, userId: number) => {
|
||||
if (roleId === "admin") {
|
||||
const user = await db.getUserById(userId);
|
||||
if (user && user.is_owner === "YES") {
|
||||
throw new Error("The owner cannot be removed from the admin role");
|
||||
}
|
||||
}
|
||||
await db.removeUserFromRole(roleId, userId);
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const GetUserPermissions = async (userId: number): Promise<Set<string>> => {
|
||||
const permissionIds = await db.getUserPermissionIds(userId);
|
||||
return new Set(permissionIds);
|
||||
};
|
||||
|
||||
export const RequirePermission = (userPermissions: Set<string>, permissionId: string): void => {
|
||||
if (!userPermissions.has(permissionId)) {
|
||||
throw new Error("You do not have permission to perform this action");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -48,7 +48,6 @@ class DbImpl {
|
||||
// ============ Monitoring Data ============
|
||||
insertMonitoringData!: MonitoringRepository["insertMonitoringData"];
|
||||
getMonitoringData!: MonitoringRepository["getMonitoringData"];
|
||||
getMonitoringDataAll!: MonitoringRepository["getMonitoringDataAll"];
|
||||
getLatestMonitoringData!: MonitoringRepository["getLatestMonitoringData"];
|
||||
getLatestMonitoringDataN!: MonitoringRepository["getLatestMonitoringDataN"];
|
||||
getMonitoringDataPaginated!: MonitoringRepository["getMonitoringDataPaginated"];
|
||||
@@ -65,11 +64,15 @@ class DbImpl {
|
||||
consecutivelyStatusFor!: MonitoringRepository["consecutivelyStatusFor"];
|
||||
consecutivelyLatencyGreaterThan!: MonitoringRepository["consecutivelyLatencyGreaterThan"];
|
||||
consecutivelyLatencyLessThan!: MonitoringRepository["consecutivelyLatencyLessThan"];
|
||||
getRecentSamplesForConfirmation!: MonitoringRepository["getRecentSamplesForConfirmation"];
|
||||
getLastObservedStatus!: MonitoringRepository["getLastObservedStatus"];
|
||||
backfillConfirmedStatus!: MonitoringRepository["backfillConfirmedStatus"];
|
||||
updateMonitoringData!: MonitoringRepository["updateMonitoringData"];
|
||||
deleteMonitorDataByTag!: MonitoringRepository["deleteMonitorDataByTag"];
|
||||
getStatusCountsByInterval!: MonitoringRepository["getStatusCountsByInterval"];
|
||||
getStatusCountsByIntervalGroupedByMonitor!: MonitoringRepository["getStatusCountsByIntervalGroupedByMonitor"];
|
||||
getStatusCountsForLastN!: MonitoringRepository["getStatusCountsForLastN"];
|
||||
getLastKnownStatus!: MonitoringRepository["getLastKnownStatus"];
|
||||
|
||||
// ============ Monitors ============
|
||||
getMonitorsByTags!: MonitorsRepository["getMonitorsByTags"];
|
||||
@@ -115,11 +118,29 @@ class DbImpl {
|
||||
getUsersPaginated!: UsersRepository["getUsersPaginated"];
|
||||
getTotalUsers!: UsersRepository["getTotalUsers"];
|
||||
updateUserName!: UsersRepository["updateUserName"];
|
||||
updateUserRole!: UsersRepository["updateUserRole"];
|
||||
updateUserRoles!: UsersRepository["updateUserRoles"];
|
||||
updateUserIsActive!: UsersRepository["updateUserIsActive"];
|
||||
updateUserPasswordById!: UsersRepository["updateUserPasswordById"];
|
||||
updateIsVerified!: UsersRepository["updateIsVerified"];
|
||||
|
||||
// ============ Roles ============
|
||||
getRoleById!: UsersRepository["getRoleById"];
|
||||
getAllRoles!: UsersRepository["getAllRoles"];
|
||||
insertRole!: UsersRepository["insertRole"];
|
||||
updateRole!: UsersRepository["updateRole"];
|
||||
deleteRole!: UsersRepository["deleteRole"];
|
||||
getUsersCountByRoleId!: UsersRepository["getUsersCountByRoleId"];
|
||||
migrateUsersRole!: UsersRepository["migrateUsersRole"];
|
||||
getRolePermissions!: UsersRepository["getRolePermissions"];
|
||||
getAllPermissions!: UsersRepository["getAllPermissions"];
|
||||
addRolePermission!: UsersRepository["addRolePermission"];
|
||||
removeRolePermission!: UsersRepository["removeRolePermission"];
|
||||
getUsersByRoleId!: UsersRepository["getUsersByRoleId"];
|
||||
addUserToRole!: UsersRepository["addUserToRole"];
|
||||
removeUserFromRole!: UsersRepository["removeUserFromRole"];
|
||||
getUserPermissionIds!: UsersRepository["getUserPermissionIds"];
|
||||
getUserRoleIds!: UsersRepository["getUserRoleIds"];
|
||||
|
||||
// ============ API Keys ============
|
||||
createNewApiKey!: UsersRepository["createNewApiKey"];
|
||||
updateApiKeyStatus!: UsersRepository["updateApiKeyStatus"];
|
||||
@@ -390,7 +411,6 @@ class DbImpl {
|
||||
private bindMonitoringMethods(): void {
|
||||
this.insertMonitoringData = this.monitoring.insertMonitoringData.bind(this.monitoring);
|
||||
this.getMonitoringData = this.monitoring.getMonitoringData.bind(this.monitoring);
|
||||
this.getMonitoringDataAll = this.monitoring.getMonitoringDataAll.bind(this.monitoring);
|
||||
this.getLatestMonitoringData = this.monitoring.getLatestMonitoringData.bind(this.monitoring);
|
||||
this.getLatestMonitoringDataN = this.monitoring.getLatestMonitoringDataN.bind(this.monitoring);
|
||||
this.getMonitoringDataPaginated = this.monitoring.getMonitoringDataPaginated.bind(this.monitoring);
|
||||
@@ -407,6 +427,9 @@ class DbImpl {
|
||||
this.consecutivelyStatusFor = this.monitoring.consecutivelyStatusFor.bind(this.monitoring);
|
||||
this.consecutivelyLatencyGreaterThan = this.monitoring.consecutivelyLatencyGreaterThan.bind(this.monitoring);
|
||||
this.consecutivelyLatencyLessThan = this.monitoring.consecutivelyLatencyLessThan.bind(this.monitoring);
|
||||
this.getRecentSamplesForConfirmation = this.monitoring.getRecentSamplesForConfirmation.bind(this.monitoring);
|
||||
this.getLastObservedStatus = this.monitoring.getLastObservedStatus.bind(this.monitoring);
|
||||
this.backfillConfirmedStatus = this.monitoring.backfillConfirmedStatus.bind(this.monitoring);
|
||||
this.updateMonitoringData = this.monitoring.updateMonitoringData.bind(this.monitoring);
|
||||
this.deleteMonitorDataByTag = this.monitoring.deleteMonitorDataByTag.bind(this.monitoring);
|
||||
this.getStatusCountsByInterval = this.monitoring.getStatusCountsByInterval.bind(this.monitoring);
|
||||
@@ -414,6 +437,7 @@ class DbImpl {
|
||||
this.monitoring,
|
||||
);
|
||||
this.getStatusCountsForLastN = this.monitoring.getStatusCountsForLastN.bind(this.monitoring);
|
||||
this.getLastKnownStatus = this.monitoring.getLastKnownStatus.bind(this.monitoring);
|
||||
}
|
||||
|
||||
private bindMonitorsMethods(): void {
|
||||
@@ -460,7 +484,7 @@ class DbImpl {
|
||||
this.getUsersPaginated = this.users.getUsersPaginated.bind(this.users);
|
||||
this.getTotalUsers = this.users.getTotalUsers.bind(this.users);
|
||||
this.updateUserName = this.users.updateUserName.bind(this.users);
|
||||
this.updateUserRole = this.users.updateUserRole.bind(this.users);
|
||||
this.updateUserRoles = this.users.updateUserRoles.bind(this.users);
|
||||
this.updateUserIsActive = this.users.updateUserIsActive.bind(this.users);
|
||||
this.updateUserPasswordById = this.users.updateUserPasswordById.bind(this.users);
|
||||
this.updateIsVerified = this.users.updateIsVerified.bind(this.users);
|
||||
@@ -469,6 +493,24 @@ class DbImpl {
|
||||
this.deleteApiKey = this.users.deleteApiKey.bind(this.users);
|
||||
this.getApiKeyByHashedKey = this.users.getApiKeyByHashedKey.bind(this.users);
|
||||
this.getAllApiKeys = this.users.getAllApiKeys.bind(this.users);
|
||||
|
||||
// Roles
|
||||
this.getRoleById = this.users.getRoleById.bind(this.users);
|
||||
this.getAllRoles = this.users.getAllRoles.bind(this.users);
|
||||
this.insertRole = this.users.insertRole.bind(this.users);
|
||||
this.updateRole = this.users.updateRole.bind(this.users);
|
||||
this.deleteRole = this.users.deleteRole.bind(this.users);
|
||||
this.getUsersCountByRoleId = this.users.getUsersCountByRoleId.bind(this.users);
|
||||
this.migrateUsersRole = this.users.migrateUsersRole.bind(this.users);
|
||||
this.getRolePermissions = this.users.getRolePermissions.bind(this.users);
|
||||
this.getAllPermissions = this.users.getAllPermissions.bind(this.users);
|
||||
this.addRolePermission = this.users.addRolePermission.bind(this.users);
|
||||
this.removeRolePermission = this.users.removeRolePermission.bind(this.users);
|
||||
this.getUsersByRoleId = this.users.getUsersByRoleId.bind(this.users);
|
||||
this.addUserToRole = this.users.addUserToRole.bind(this.users);
|
||||
this.removeUserFromRole = this.users.removeUserFromRole.bind(this.users);
|
||||
this.getUserPermissionIds = this.users.getUserPermissionIds.bind(this.users);
|
||||
this.getUserRoleIds = this.users.getUserRoleIds.bind(this.users);
|
||||
}
|
||||
|
||||
private bindSiteDataMethods(): void {
|
||||
@@ -798,6 +840,16 @@ class DbImpl {
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
/** Probes database connectivity with a trivial query. Never throws. */
|
||||
async ping(): Promise<boolean> {
|
||||
try {
|
||||
await this.knex.raw("select 1");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
return await this.knex.destroy();
|
||||
}
|
||||
|
||||
@@ -141,9 +141,17 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a monitor alert config by ID
|
||||
* Delete a monitor alert config by ID, including all child rows.
|
||||
*
|
||||
* Child rows are removed explicitly even though FK cascades are declared:
|
||||
* SQLite never enforces them (foreign_keys pragma is off), so relying on
|
||||
* CASCADE orphans children on the default deployment. See
|
||||
* docs/adr/0008-explicit-deletes-over-fk-cascades.md.
|
||||
*/
|
||||
async deleteMonitorAlertConfig(id: number): Promise<number> {
|
||||
await this.knex("monitor_alerts_v2").where({ config_id: id }).del();
|
||||
await this.knex("monitor_alerts_config_triggers").where({ monitor_alerts_id: id }).del();
|
||||
await this.knex("monitor_alerts_config_monitors").where({ monitor_alerts_id: id }).del();
|
||||
return await this.knex("monitor_alerts_config").where({ id }).del();
|
||||
}
|
||||
|
||||
@@ -158,7 +166,11 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
|
||||
if (configIds.length === 0) return 0;
|
||||
|
||||
// Remove the monitor from the junction table
|
||||
// Remove the monitor from the junction table, along with its per-monitor
|
||||
// alert state — a shared config survives the detach, but its v2 rows for
|
||||
// this tag would otherwise dangle (see deleteMonitorAlertConfig on why
|
||||
// FK cascades can't be relied on)
|
||||
await this.knex("monitor_alerts_v2").where({ monitor_tag: monitorTag }).del();
|
||||
await this.knex("monitor_alerts_config_monitors").where({ monitor_tag: monitorTag }).del();
|
||||
|
||||
// Delete any configs that now have zero monitors
|
||||
@@ -170,7 +182,7 @@ export class MonitorAlertConfigRepository extends BaseRepository {
|
||||
.where({ monitor_alerts_id: id })
|
||||
.first<CountResult>();
|
||||
if (Number(remainingMonitors?.count) === 0) {
|
||||
await this.knex("monitor_alerts_config").where({ id }).del();
|
||||
await this.deleteMonitorAlertConfig(id);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,18 +10,42 @@ import type {
|
||||
TimestampStatusCountByMonitor,
|
||||
} from "../../types/db.js";
|
||||
|
||||
/**
|
||||
* Sample types alert evaluation can see (see docs/adr/0005-alerts-evaluate-alert-visible-samples.md).
|
||||
* Exactly the types written by flows that enqueue alert evaluation: scheduler checks
|
||||
* (REALTIME/ERROR/TIMEOUT), default-status fill (DEFAULT_STATUS), and data-API pushes (MANUAL).
|
||||
* SIGNAL rows (raw heartbeat receipts) and INCIDENT/MAINTENANCE overlays stay invisible, so the
|
||||
* alert window freezes during manual overlays instead of triggering or resolving on them.
|
||||
*/
|
||||
const ALERT_VISIBLE_TYPES = [GC.REALTIME, GC.ERROR, GC.TIMEOUT, GC.MANUAL, GC.DEFAULT_STATUS];
|
||||
|
||||
/**
|
||||
* Scheduled-check sample types that count toward a monitor's Confirmation Threshold
|
||||
* (issue #712). Intentionally narrower than ALERT_VISIBLE_TYPES: MANUAL pushes
|
||||
* and DEFAULT_STATUS fill stay transparent to threshold counting.
|
||||
*/
|
||||
const OBSERVED_CHECK_TYPES = [GC.REALTIME, GC.TIMEOUT, GC.ERROR];
|
||||
|
||||
/**
|
||||
* Overlay sample types that FREEZE Confirmation Threshold counting (issue #712):
|
||||
* while one is active the count does not advance, and it acts as a hard boundary the
|
||||
* pending run cannot cross. Included in the confirmation lookback (unlike MANUAL/DEFAULT,
|
||||
* which stay transparent) so the resolver can detect the boundary.
|
||||
*/
|
||||
const OVERLAY_TYPES = [GC.INCIDENT, GC.MAINTENANCE];
|
||||
|
||||
/**
|
||||
* Repository for monitoring data operations
|
||||
*/
|
||||
export class MonitoringRepository extends BaseRepository {
|
||||
async insertMonitoringData(data: MonitoringDataInsert): Promise<MonitoringData | null> {
|
||||
const { monitor_tag, timestamp, status, latency, type, error_message } = data;
|
||||
const { monitor_tag, timestamp, status, latency, type, error_message, raw_status } = data;
|
||||
|
||||
// Perform insert/update - works across PostgreSQL, MySQL, and SQLite
|
||||
await this.knex("monitoring_data")
|
||||
.insert({ monitor_tag, timestamp, status, latency, type, error_message })
|
||||
.insert({ monitor_tag, timestamp, status, latency, type, error_message, raw_status })
|
||||
.onConflict(["monitor_tag", "timestamp"])
|
||||
.merge({ status, latency, type, error_message });
|
||||
.merge({ status, latency, type, error_message, raw_status });
|
||||
|
||||
// Query and return the inserted/updated record (works consistently across all databases)
|
||||
const record = await this.knex("monitoring_data")
|
||||
@@ -40,27 +64,6 @@ export class MonitoringRepository extends BaseRepository {
|
||||
.orderBy("timestamp", "asc");
|
||||
}
|
||||
|
||||
// Groups by timestamp and applies priority: DOWN > DEGRADED > UP
|
||||
async getMonitoringDataAll(monitor_tags: string[], start: number, end: number): Promise<MonitoringData[]> {
|
||||
return await this.knex("monitoring_data")
|
||||
.select(
|
||||
"timestamp",
|
||||
this.knex.raw(`
|
||||
CASE
|
||||
WHEN MAX(CASE WHEN status = 'DOWN' THEN 1 ELSE 0 END) = 1 THEN 'DOWN'
|
||||
WHEN MAX(CASE WHEN status = 'DEGRADED' THEN 1 ELSE 0 END) = 1 THEN 'DEGRADED'
|
||||
ELSE 'UP'
|
||||
END as status
|
||||
`),
|
||||
)
|
||||
.whereIn("monitor_tag", monitor_tags)
|
||||
.where("timestamp", ">=", start)
|
||||
.where("timestamp", "<=", end)
|
||||
.whereNotNull("status")
|
||||
.groupBy("timestamp")
|
||||
.orderBy("timestamp", "asc");
|
||||
}
|
||||
|
||||
async getLatestMonitoringData(monitor_tag: string): Promise<MonitoringData | undefined> {
|
||||
return await this.knex("monitoring_data")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
@@ -262,7 +265,7 @@ export class MonitoringRepository extends BaseRepository {
|
||||
qb.select("*")
|
||||
.from("monitoring_data")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
.andWhere("type", "=", GC.REALTIME)
|
||||
.whereIn("type", ALERT_VISIBLE_TYPES)
|
||||
.orderBy("timestamp", "desc")
|
||||
.limit(lastX);
|
||||
})
|
||||
@@ -288,7 +291,7 @@ export class MonitoringRepository extends BaseRepository {
|
||||
qb.select("*")
|
||||
.from("monitoring_data")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
.andWhere("type", "=", GC.REALTIME)
|
||||
.whereIn("type", ALERT_VISIBLE_TYPES)
|
||||
.orderBy("timestamp", "desc")
|
||||
.limit(lastX);
|
||||
})
|
||||
@@ -310,7 +313,7 @@ export class MonitoringRepository extends BaseRepository {
|
||||
qb.select("*")
|
||||
.from("monitoring_data")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
.andWhere("type", "=", GC.REALTIME)
|
||||
.whereIn("type", ALERT_VISIBLE_TYPES)
|
||||
.orderBy("timestamp", "desc")
|
||||
.limit(lastX);
|
||||
})
|
||||
@@ -326,6 +329,107 @@ export class MonitoringRepository extends BaseRepository {
|
||||
return result.is_recovered === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent samples the Confirmation Threshold resolver needs, newest first: scheduled-check
|
||||
* observations (REALTIME/TIMEOUT/ERROR) plus incident/maintenance overlays. MANUAL pushes
|
||||
* and DEFAULT fill are excluded — they stay transparent to the counter. Returns `type` so
|
||||
* the resolver can stop at overlay rows (freeze). Observations whose status is NO_DATA are
|
||||
* excluded entirely (neutral — they neither advance nor reset the count and must not consume lookback slots).
|
||||
*/
|
||||
async getRecentSamplesForConfirmation(
|
||||
monitor_tag: string,
|
||||
beforeTs: number,
|
||||
limit: number,
|
||||
): Promise<Array<{ timestamp: number; status: string | null; raw_status: string | null; type: string | null }>> {
|
||||
return await this.knex("monitoring_data")
|
||||
.select("timestamp", "status", "raw_status", "type")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
.where("timestamp", "<", beforeTs)
|
||||
.whereIn("type", [...OBSERVED_CHECK_TYPES, ...OVERLAY_TYPES])
|
||||
.whereNot("status", GC.NO_DATA)
|
||||
.orderBy("timestamp", "desc")
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* The committed status of the most recent real scheduled-check observation before `beforeTs`
|
||||
* — the Confirmation Threshold "anchor" (the side currently shown). Looks past overlays,
|
||||
* MANUAL/DEFAULT, and NO_DATA so a long incident/maintenance window can never hide the anchor
|
||||
* (issue #712). Returns null when there is no prior observation (cold start).
|
||||
*/
|
||||
async getLastObservedStatus(monitor_tag: string, beforeTs: number): Promise<string | null> {
|
||||
const row = await this.knex("monitoring_data")
|
||||
.select("status")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
.where("timestamp", "<", beforeTs)
|
||||
.whereIn("type", OBSERVED_CHECK_TYPES)
|
||||
.whereNot("status", GC.NO_DATA)
|
||||
.orderBy("timestamp", "desc")
|
||||
.limit(1)
|
||||
.first();
|
||||
return row ? (row.status ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill a confirmed status flip: set each row's committed status to its observed raw_status.
|
||||
* `confirmThreshold` is the number of consecutive checks that confirmed the flip — when it is a
|
||||
* number the run resolved to an unhealthy side and a per-row note ("Down"/"Degraded confirmed
|
||||
* after N consecutive checks", matching each row's own severity) is appended to the existing
|
||||
* error text; when it is null the run resolved to UP (recovery) and the error text is cleared.
|
||||
*/
|
||||
async backfillConfirmedStatus(
|
||||
monitor_tag: string,
|
||||
timestamps: number[],
|
||||
confirmThreshold: number | null,
|
||||
): Promise<number> {
|
||||
if (timestamps.length === 0) return 0;
|
||||
|
||||
// Recovery (confirmed UP): rows become the UP side — clear any held error text in one update.
|
||||
if (confirmThreshold === null) {
|
||||
return await this.knex("monitoring_data")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
.whereIn("timestamp", timestamps)
|
||||
.whereNotNull("raw_status")
|
||||
.update({
|
||||
status: this.knex.ref("raw_status"),
|
||||
error_message: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Confirmed unhealthy: set each row's status from its observed raw_status and APPEND a
|
||||
// severity-matched confirmation note to the existing error text (preserving the observed
|
||||
// failure reason). Done per-row for portable string concatenation (|| vs CONCAT differ across
|
||||
// SQLite/PG/MySQL), per-row severity wording, and idempotency if the backfill is replayed.
|
||||
// The whole read+update window runs in one transaction — a confirmation flip is one logical
|
||||
// write, so it must not leave the window half-confirmed/half-held if a row update fails.
|
||||
return await this.knex.transaction(async (trx: KnexType.Transaction) => {
|
||||
const rows = await trx("monitoring_data")
|
||||
.select("timestamp", "error_message", "raw_status")
|
||||
.where("monitor_tag", monitor_tag)
|
||||
.whereIn("timestamp", timestamps)
|
||||
.whereNotNull("raw_status");
|
||||
|
||||
let updated = 0;
|
||||
for (const row of rows) {
|
||||
const severity = row.raw_status === GC.DEGRADED ? "Degraded" : "Down";
|
||||
const note = `${severity} confirmed after ${confirmThreshold} consecutive checks`;
|
||||
const existing: string | null = row.error_message;
|
||||
let nextMessage: string;
|
||||
if (!existing) {
|
||||
nextMessage = note;
|
||||
} else if (existing.indexOf(note) !== -1) {
|
||||
nextMessage = existing; // already appended — keep idempotent
|
||||
} else {
|
||||
nextMessage = `${existing} | ${note}`;
|
||||
}
|
||||
updated += await trx("monitoring_data")
|
||||
.where({ monitor_tag, timestamp: row.timestamp })
|
||||
.update({ status: row.raw_status, error_message: nextMessage });
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
async updateMonitoringData(
|
||||
monitor_tag: string,
|
||||
start: number,
|
||||
@@ -587,4 +691,9 @@ export class MonitoringRepository extends BaseRepository {
|
||||
minLatency: Number(result?.min_latency) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
//get the last known status for a monitor
|
||||
async getLastKnownStatus(monitor_tag: string): Promise<MonitoringData | undefined> {
|
||||
return await this.knex("monitoring_data").where("monitor_tag", monitor_tag).orderBy("timestamp", "desc").first();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,17 @@ import type { Knex as KnexType } from "knex";
|
||||
import { BaseRepository, type MonitorFilter, type CountResult } from "./base.js";
|
||||
import type { MonitorRecord, MonitorRecordInsert } from "../../types/db.js";
|
||||
|
||||
/**
|
||||
* Clamp the Confirmation Threshold to its 1–60 invariant at the data layer, so the bound holds
|
||||
* for every app write path (v4 API, manage API, clone, group), not only the v4 API validator.
|
||||
* A non-finite/missing value defaults to 1 (off).
|
||||
*/
|
||||
function clampConfirmationThreshold(value: number | null | undefined): number {
|
||||
const n = Math.round(Number(value));
|
||||
if (!Number.isFinite(n)) return 1;
|
||||
return Math.min(60, Math.max(1, n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository for monitors CRUD operations
|
||||
*/
|
||||
@@ -28,6 +39,7 @@ export class MonitorsRepository extends BaseRepository {
|
||||
type_data: data.type_data,
|
||||
day_degraded_minimum_count: data.day_degraded_minimum_count,
|
||||
day_down_minimum_count: data.day_down_minimum_count,
|
||||
confirmation_threshold: clampConfirmationThreshold(data.confirmation_threshold),
|
||||
include_degraded_in_downtime: data.include_degraded_in_downtime,
|
||||
is_hidden: data.is_hidden || "NO",
|
||||
monitor_settings_json: data.monitor_settings_json,
|
||||
@@ -51,6 +63,7 @@ export class MonitorsRepository extends BaseRepository {
|
||||
type_data: data.type_data,
|
||||
day_degraded_minimum_count: data.day_degraded_minimum_count,
|
||||
day_down_minimum_count: data.day_down_minimum_count,
|
||||
confirmation_threshold: clampConfirmationThreshold(data.confirmation_threshold),
|
||||
include_degraded_in_downtime: data.include_degraded_in_downtime,
|
||||
is_hidden: data.is_hidden,
|
||||
monitor_settings_json: data.monitor_settings_json,
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { BaseRepository, type CountResult } from "./base.js";
|
||||
import type { UserRecordInsert, UserRecordPublic, ApiKeyRecord, ApiKeyRecordInsert } from "../../types/db.js";
|
||||
import type {
|
||||
UserRecordInsert,
|
||||
UserRecordPublic,
|
||||
ApiKeyRecord,
|
||||
ApiKeyRecordInsert,
|
||||
RoleRecord,
|
||||
RolePermissionRecord,
|
||||
UserRoleRecord,
|
||||
} from "../../types/db.js";
|
||||
import { GetDbType } from "../../tool.js";
|
||||
|
||||
/**
|
||||
* Repository for users, API keys operations
|
||||
@@ -11,11 +20,46 @@ export class UsersRepository extends BaseRepository {
|
||||
return await this.knex("users").count("* as count").first<CountResult>();
|
||||
}
|
||||
|
||||
private readonly userColumns = [
|
||||
"id",
|
||||
"email",
|
||||
"name",
|
||||
"is_active",
|
||||
"is_verified",
|
||||
"is_owner",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
] as const;
|
||||
|
||||
private async enrichWithRoleIds(user: Record<string, unknown>): Promise<UserRecordPublic> {
|
||||
const roleIds = await this.getUserRoleIds(user.id as number);
|
||||
return { ...user, role_ids: roleIds } as UserRecordPublic;
|
||||
}
|
||||
|
||||
private async enrichManyWithRoleIds(users: Record<string, unknown>[]): Promise<UserRecordPublic[]> {
|
||||
if (users.length === 0) return [];
|
||||
const userIds = users.map((u) => u.id as number);
|
||||
const roleRows = await this.knex("users_roles")
|
||||
.join("roles", "users_roles.roles_id", "roles.id")
|
||||
.whereIn("users_roles.users_id", userIds)
|
||||
.where("roles.status", "ACTIVE")
|
||||
.select("users_roles.users_id as users_id", "roles.id as role_id");
|
||||
const roleMap = new Map<number, string[]>();
|
||||
for (const row of roleRows) {
|
||||
const list = roleMap.get(row.users_id) || [];
|
||||
list.push(row.role_id);
|
||||
roleMap.set(row.users_id, list);
|
||||
}
|
||||
return users.map((u) => ({ ...u, role_ids: roleMap.get(u.id as number) || [] }) as UserRecordPublic);
|
||||
}
|
||||
|
||||
async getUserByEmail(email: string): Promise<UserRecordPublic | undefined> {
|
||||
return await this.knex("users")
|
||||
.select("id", "email", "name", "is_active", "is_verified", "is_owner", "role", "created_at", "updated_at")
|
||||
const row = await this.knex("users")
|
||||
.select(...this.userColumns)
|
||||
.where("email", email)
|
||||
.first();
|
||||
if (!row) return undefined;
|
||||
return await this.enrichWithRoleIds(row);
|
||||
}
|
||||
|
||||
async getUserPasswordHashById(id: number): Promise<{ password_hash: string } | undefined> {
|
||||
@@ -28,22 +72,43 @@ export class UsersRepository extends BaseRepository {
|
||||
}
|
||||
|
||||
async getUserById(id: number): Promise<UserRecordPublic | undefined> {
|
||||
return await this.knex("users")
|
||||
.select("id", "email", "name", "is_active", "is_verified", "is_owner", "role", "created_at", "updated_at")
|
||||
const row = await this.knex("users")
|
||||
.select(...this.userColumns)
|
||||
.where("id", id)
|
||||
.first();
|
||||
if (!row) return undefined;
|
||||
return await this.enrichWithRoleIds(row);
|
||||
}
|
||||
|
||||
async insertUser(data: UserRecordInsert): Promise<number[]> {
|
||||
return await this.knex("users").insert({
|
||||
const dbType = GetDbType();
|
||||
|
||||
const insertData = {
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
password_hash: data.password_hash,
|
||||
role: data.role,
|
||||
is_owner: data.is_owner || "NO",
|
||||
created_at: this.knex.fn.now(),
|
||||
updated_at: this.knex.fn.now(),
|
||||
});
|
||||
};
|
||||
|
||||
let userId: number;
|
||||
if (dbType === "postgresql") {
|
||||
const [row] = await this.knex("users").insert(insertData).returning("id");
|
||||
userId = typeof row === "object" ? (row as { id: number }).id : (row as number);
|
||||
} else {
|
||||
const result = await this.knex("users").insert(insertData);
|
||||
userId = result[0];
|
||||
}
|
||||
|
||||
if (data.role_ids && data.role_ids.length > 0) {
|
||||
const roleInserts = data.role_ids.map((roleId) => ({
|
||||
users_id: userId,
|
||||
roles_id: roleId,
|
||||
}));
|
||||
await this.knex("users_roles").insert(roleInserts);
|
||||
}
|
||||
return [userId];
|
||||
}
|
||||
|
||||
async updateUserPassword(data: { id: number; password_hash: string }): Promise<number> {
|
||||
@@ -54,21 +119,31 @@ export class UsersRepository extends BaseRepository {
|
||||
}
|
||||
|
||||
async getAllUsers(): Promise<UserRecordPublic[]> {
|
||||
return await this.knex("users")
|
||||
.select("id", "email", "name", "role", "is_active", "is_verified", "is_owner", "created_at", "updated_at")
|
||||
const rows = await this.knex("users")
|
||||
.select(...this.userColumns)
|
||||
.orderBy("created_at", "desc");
|
||||
return await this.enrichManyWithRoleIds(rows);
|
||||
}
|
||||
|
||||
async getUsersPaginated(page: number, limit: number): Promise<UserRecordPublic[]> {
|
||||
return await this.knex("users")
|
||||
.select("id", "email", "name", "role", "is_active", "is_verified", "is_owner", "created_at", "updated_at")
|
||||
async getUsersPaginated(page: number, limit: number, filter?: { is_active?: number }): Promise<UserRecordPublic[]> {
|
||||
const query = this.knex("users")
|
||||
.select(...this.userColumns)
|
||||
.orderBy("created_at", "desc")
|
||||
.limit(limit)
|
||||
.offset((page - 1) * limit);
|
||||
if (filter?.is_active !== undefined) {
|
||||
query.where("is_active", filter.is_active);
|
||||
}
|
||||
const rows = await query;
|
||||
return await this.enrichManyWithRoleIds(rows);
|
||||
}
|
||||
|
||||
async getTotalUsers(): Promise<CountResult | undefined> {
|
||||
return await this.knex("users").count("* as count").first<CountResult>();
|
||||
async getTotalUsers(filter?: { is_active?: number }): Promise<CountResult | undefined> {
|
||||
const query = this.knex("users").count("* as count");
|
||||
if (filter?.is_active !== undefined) {
|
||||
query.where("is_active", filter.is_active);
|
||||
}
|
||||
return await query.first<CountResult>();
|
||||
}
|
||||
|
||||
async updateUserName(id: number, name: string): Promise<number> {
|
||||
@@ -78,11 +153,18 @@ export class UsersRepository extends BaseRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async updateUserRole(id: number, role: string): Promise<number> {
|
||||
return await this.knex("users").where({ id }).update({
|
||||
role,
|
||||
updated_at: this.knex.fn.now(),
|
||||
});
|
||||
async updateUserRoles(id: number, roleIds: string[]): Promise<void> {
|
||||
await this.knex("users_roles").where("users_id", id).delete();
|
||||
if (roleIds.length > 0) {
|
||||
const inserts = roleIds.map((roleId) => ({
|
||||
users_id: id,
|
||||
roles_id: roleId,
|
||||
created_at: this.knex.fn.now(),
|
||||
updated_at: this.knex.fn.now(),
|
||||
}));
|
||||
await this.knex("users_roles").insert(inserts);
|
||||
}
|
||||
await this.knex("users").where({ id }).update({ updated_at: this.knex.fn.now() });
|
||||
}
|
||||
|
||||
async updateUserIsActive(id: number, is_active: number): Promise<number> {
|
||||
@@ -138,4 +220,140 @@ export class UsersRepository extends BaseRepository {
|
||||
}
|
||||
|
||||
// ============ Invitations ============
|
||||
|
||||
// ============ Roles ============
|
||||
|
||||
async getRoleById(id: string): Promise<RoleRecord | undefined> {
|
||||
return await this.knex("roles").where("id", id).first();
|
||||
}
|
||||
|
||||
async getAllRoles(): Promise<RoleRecord[]> {
|
||||
return await this.knex("roles").orderBy("created_at", "asc");
|
||||
}
|
||||
|
||||
async insertRole(data: { id: string; role_name: string; readonly?: number }): Promise<void> {
|
||||
await this.knex("roles").insert({
|
||||
id: data.id,
|
||||
role_name: data.role_name,
|
||||
readonly: data.readonly ?? 0,
|
||||
status: "ACTIVE",
|
||||
created_at: this.knex.fn.now(),
|
||||
updated_at: this.knex.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async updateRole(id: string, data: { role_name?: string; status?: string }): Promise<number> {
|
||||
const updateData: Record<string, unknown> = { updated_at: this.knex.fn.now() };
|
||||
if (data.role_name !== undefined) updateData.role_name = data.role_name;
|
||||
if (data.status !== undefined) updateData.status = data.status;
|
||||
return await this.knex("roles").where("id", id).update(updateData);
|
||||
}
|
||||
|
||||
async deleteRole(id: string): Promise<number> {
|
||||
return await this.knex("roles").where("id", id).delete();
|
||||
}
|
||||
|
||||
async getUsersCountByRoleId(roleId: string): Promise<number> {
|
||||
const result = await this.knex("users_roles").where("roles_id", roleId).count("* as count").first<CountResult>();
|
||||
return result ? Number(result.count) : 0;
|
||||
}
|
||||
|
||||
async migrateUsersRole(fromRoleId: string, toRoleId: string): Promise<void> {
|
||||
// Find users who already have the target role to avoid duplicate PK
|
||||
const usersWithTarget = this.knex("users_roles").where("roles_id", toRoleId).select("users_id");
|
||||
|
||||
// Update users who don't already have the target role
|
||||
await this.knex("users_roles").where("roles_id", fromRoleId).whereNotIn("users_id", usersWithTarget).update({
|
||||
roles_id: toRoleId,
|
||||
updated_at: this.knex.fn.now(),
|
||||
});
|
||||
|
||||
// Delete remaining assignments (users who already had the target role)
|
||||
await this.knex("users_roles").where("roles_id", fromRoleId).delete();
|
||||
}
|
||||
|
||||
// ============ Role Permissions ============
|
||||
|
||||
async getRolePermissions(roleId: string): Promise<RolePermissionRecord[]> {
|
||||
return await this.knex("roles_permissions").where("roles_id", roleId);
|
||||
}
|
||||
|
||||
async getAllPermissions(): Promise<Array<{ id: string; permission_name: string }>> {
|
||||
return await this.knex("permissions").select("id", "permission_name").orderBy("id", "asc");
|
||||
}
|
||||
|
||||
async addRolePermission(roleId: string, permissionId: string): Promise<void> {
|
||||
await this.knex("roles_permissions").insert({
|
||||
roles_id: roleId,
|
||||
permissions_id: permissionId,
|
||||
status: "ACTIVE",
|
||||
created_at: this.knex.fn.now(),
|
||||
updated_at: this.knex.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async removeRolePermission(roleId: string, permissionId: string): Promise<number> {
|
||||
return await this.knex("roles_permissions").where({ roles_id: roleId, permissions_id: permissionId }).delete();
|
||||
}
|
||||
|
||||
// ============ Role Users ============
|
||||
|
||||
async getUsersByRoleId(roleId: string): Promise<Array<UserRecordPublic & { roles_id: string }>> {
|
||||
const rows = await this.knex("users_roles")
|
||||
.join("users", "users_roles.users_id", "users.id")
|
||||
.where("users_roles.roles_id", roleId)
|
||||
.select(
|
||||
"users.id",
|
||||
"users.email",
|
||||
"users.name",
|
||||
"users.is_active",
|
||||
"users.is_verified",
|
||||
"users.is_owner",
|
||||
"users.created_at",
|
||||
"users.updated_at",
|
||||
"users_roles.roles_id",
|
||||
);
|
||||
const enriched = await this.enrichManyWithRoleIds(rows);
|
||||
return enriched.map((u, i) => ({ ...u, roles_id: rows[i].roles_id }));
|
||||
}
|
||||
|
||||
async addUserToRole(roleId: string, userId: number): Promise<void> {
|
||||
await this.knex("users_roles").insert({
|
||||
roles_id: roleId,
|
||||
users_id: userId,
|
||||
created_at: this.knex.fn.now(),
|
||||
updated_at: this.knex.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async removeUserFromRole(roleId: string, userId: number): Promise<number> {
|
||||
return await this.knex("users_roles").where({ roles_id: roleId, users_id: userId }).delete();
|
||||
}
|
||||
|
||||
async getUserRoleIds(userId: number): Promise<string[]> {
|
||||
const rows = await this.knex("users_roles")
|
||||
.join("roles", function () {
|
||||
this.on("users_roles.roles_id", "roles.id");
|
||||
})
|
||||
.where("users_roles.users_id", userId)
|
||||
.where("roles.status", "ACTIVE")
|
||||
.distinct("roles.id as id")
|
||||
.select();
|
||||
return rows.map((r: { id: string }) => r.id);
|
||||
}
|
||||
|
||||
async getUserPermissionIds(userId: number): Promise<string[]> {
|
||||
const knex = this.knex;
|
||||
const rows = await knex("users_roles")
|
||||
.join("roles", function () {
|
||||
this.on("users_roles.roles_id", "roles.id").andOn("roles.status", knex.raw("?", ["ACTIVE"]));
|
||||
})
|
||||
.join("roles_permissions", function () {
|
||||
this.on("roles_permissions.roles_id", "roles.id").andOn("roles_permissions.status", knex.raw("?", ["ACTIVE"]));
|
||||
})
|
||||
.where("users_roles.users_id", userId)
|
||||
.distinct("roles_permissions.permissions_id as id")
|
||||
.select();
|
||||
return rows.map((r: { id: string }) => r.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import type { PageSettings, PageSettingsPatch } from "$lib/types/api";
|
||||
import GC from "$lib/global-constants";
|
||||
|
||||
// Stored page_settings_json keys differ from the API contract for the meta
|
||||
// fields: the manage UI writes camelCase (metaPageTitle, metaPageDescription,
|
||||
// socialPagePreviewImage) while the v4 API exposes snake_case. The mapping
|
||||
// lives here, at the storage boundary.
|
||||
interface StoredPageSettings {
|
||||
incidents?: unknown;
|
||||
include_maintenances?: unknown;
|
||||
monitor_status_history_days?: { desktop?: number; mobile?: number };
|
||||
monitor_layout_style?: string;
|
||||
metaPageTitle?: string;
|
||||
metaPageDescription?: string;
|
||||
socialPagePreviewImage?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const HISTORY_DAYS_MIN = GC.STATUS_HISTORY_DAYS_MIN;
|
||||
const HISTORY_DAYS_MAX = GC.STATUS_HISTORY_DAYS_MAX;
|
||||
|
||||
export function getDefaultPageSettings(): PageSettings {
|
||||
return {
|
||||
incidents: {
|
||||
enabled: true,
|
||||
ongoing: { show: true },
|
||||
resolved: { show: true, max_count: 5, days_in_past: 7 },
|
||||
},
|
||||
include_maintenances: {
|
||||
enabled: true,
|
||||
ongoing: {
|
||||
show: true,
|
||||
past: { show: true, max_count: 5, days_in_past: 7 },
|
||||
upcoming: { show: true, max_count: 5, days_in_future: 30 },
|
||||
},
|
||||
},
|
||||
monitor_status_history_days: {
|
||||
desktop: GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP,
|
||||
mobile: GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE,
|
||||
},
|
||||
monitor_layout_style: GC.DEFAULT_MONITOR_LAYOUT_STYLE,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStored(storedJson: string | null | undefined): StoredPageSettings {
|
||||
if (!storedJson) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(storedJson);
|
||||
return typeof parsed === "object" && parsed !== null ? (parsed as StoredPageSettings) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// Recursively merges patch into base: objects merge key-by-key, everything
|
||||
// else replaces. Keys absent from the patch — including ones this module does
|
||||
// not know about — are left untouched.
|
||||
function deepMerge(base: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = { ...base };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === undefined) continue;
|
||||
const current = result[key];
|
||||
result[key] = isPlainObject(current) && isPlainObject(value) ? deepMerge(current, value) : value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mergePageSettings(defaults: PageSettings, partial?: PageSettingsPatch): PageSettings {
|
||||
if (!partial) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
const merged: PageSettings = {
|
||||
incidents: {
|
||||
enabled: partial.incidents?.enabled ?? defaults.incidents.enabled,
|
||||
ongoing: {
|
||||
show: partial.incidents?.ongoing?.show ?? defaults.incidents.ongoing.show,
|
||||
},
|
||||
resolved: {
|
||||
show: partial.incidents?.resolved?.show ?? defaults.incidents.resolved.show,
|
||||
max_count: partial.incidents?.resolved?.max_count ?? defaults.incidents.resolved.max_count,
|
||||
days_in_past: partial.incidents?.resolved?.days_in_past ?? defaults.incidents.resolved.days_in_past,
|
||||
},
|
||||
},
|
||||
include_maintenances: {
|
||||
enabled: partial.include_maintenances?.enabled ?? defaults.include_maintenances.enabled,
|
||||
ongoing: {
|
||||
show: partial.include_maintenances?.ongoing?.show ?? defaults.include_maintenances.ongoing.show,
|
||||
past: {
|
||||
show: partial.include_maintenances?.ongoing?.past?.show ?? defaults.include_maintenances.ongoing.past.show,
|
||||
max_count:
|
||||
partial.include_maintenances?.ongoing?.past?.max_count ??
|
||||
defaults.include_maintenances.ongoing.past.max_count,
|
||||
days_in_past:
|
||||
partial.include_maintenances?.ongoing?.past?.days_in_past ??
|
||||
defaults.include_maintenances.ongoing.past.days_in_past,
|
||||
},
|
||||
upcoming: {
|
||||
show:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.show ??
|
||||
defaults.include_maintenances.ongoing.upcoming.show,
|
||||
max_count:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.max_count ??
|
||||
defaults.include_maintenances.ongoing.upcoming.max_count,
|
||||
days_in_future:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.days_in_future ??
|
||||
defaults.include_maintenances.ongoing.upcoming.days_in_future,
|
||||
},
|
||||
},
|
||||
},
|
||||
monitor_status_history_days: {
|
||||
desktop: partial.monitor_status_history_days?.desktop ?? defaults.monitor_status_history_days.desktop,
|
||||
mobile: partial.monitor_status_history_days?.mobile ?? defaults.monitor_status_history_days.mobile,
|
||||
},
|
||||
monitor_layout_style: partial.monitor_layout_style ?? defaults.monitor_layout_style,
|
||||
};
|
||||
|
||||
const metaPageTitle = partial.meta_page_title ?? defaults.meta_page_title;
|
||||
const metaPageDescription = partial.meta_page_description ?? defaults.meta_page_description;
|
||||
const socialPagePreviewImage = partial.social_page_preview_image ?? defaults.social_page_preview_image;
|
||||
if (metaPageTitle !== undefined) merged.meta_page_title = metaPageTitle;
|
||||
if (metaPageDescription !== undefined) merged.meta_page_description = metaPageDescription;
|
||||
if (socialPagePreviewImage !== undefined) merged.social_page_preview_image = socialPagePreviewImage;
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function isValidHistoryDays(value: unknown): boolean {
|
||||
return Number.isInteger(value) && (value as number) >= HISTORY_DAYS_MIN && (value as number) <= HISTORY_DAYS_MAX;
|
||||
}
|
||||
|
||||
const boolOrUndefined = (value: unknown): boolean | undefined => (typeof value === "boolean" ? value : undefined);
|
||||
const countOrUndefined = (value: unknown): number | undefined =>
|
||||
Number.isInteger(value) && (value as number) >= 0 ? (value as number) : undefined;
|
||||
|
||||
// Read-side sanitizers: keep only correctly-typed leaves from stored event
|
||||
// branches so wrong-typed values (e.g. enabled: "yes" from manual edits or
|
||||
// older versions) never override defaults in API responses
|
||||
function sanitizeStoredIncidents(value: unknown): PageSettingsPatch["incidents"] {
|
||||
if (!isPlainObject(value)) return undefined;
|
||||
const ongoing = isPlainObject(value.ongoing) ? value.ongoing : {};
|
||||
const resolved = isPlainObject(value.resolved) ? value.resolved : {};
|
||||
return {
|
||||
enabled: boolOrUndefined(value.enabled),
|
||||
ongoing: { show: boolOrUndefined(ongoing.show) },
|
||||
resolved: {
|
||||
show: boolOrUndefined(resolved.show),
|
||||
max_count: countOrUndefined(resolved.max_count),
|
||||
days_in_past: countOrUndefined(resolved.days_in_past),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeStoredMaintenances(value: unknown): PageSettingsPatch["include_maintenances"] {
|
||||
if (!isPlainObject(value)) return undefined;
|
||||
const ongoing = isPlainObject(value.ongoing) ? value.ongoing : {};
|
||||
const past = isPlainObject(ongoing.past) ? ongoing.past : {};
|
||||
const upcoming = isPlainObject(ongoing.upcoming) ? ongoing.upcoming : {};
|
||||
return {
|
||||
enabled: boolOrUndefined(value.enabled),
|
||||
ongoing: {
|
||||
show: boolOrUndefined(ongoing.show),
|
||||
past: {
|
||||
show: boolOrUndefined(past.show),
|
||||
max_count: countOrUndefined(past.max_count),
|
||||
days_in_past: countOrUndefined(past.days_in_past),
|
||||
},
|
||||
upcoming: {
|
||||
show: boolOrUndefined(upcoming.show),
|
||||
max_count: countOrUndefined(upcoming.max_count),
|
||||
days_in_future: countOrUndefined(upcoming.days_in_future),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isValidLayoutStyle(value: unknown): value is PageSettings["monitor_layout_style"] {
|
||||
return (GC.MONITOR_LAYOUT_STYLES as readonly string[]).includes(value as string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the API view of stored settings: defaults overlaid with stored
|
||||
* values. Stored values that violate the API contract (unknown layout style,
|
||||
* out-of-range days — e.g. from manual edits or older versions) are ignored
|
||||
* so responses stay schema-compliant.
|
||||
*/
|
||||
export function toApiPageSettings(storedJson: string | null | undefined): PageSettings {
|
||||
const stored = parseStored(storedJson);
|
||||
const storedDays = isPlainObject(stored.monitor_status_history_days) ? stored.monitor_status_history_days : {};
|
||||
const fromStore: PageSettingsPatch = {
|
||||
incidents: sanitizeStoredIncidents(stored.incidents),
|
||||
include_maintenances: sanitizeStoredMaintenances(stored.include_maintenances),
|
||||
monitor_status_history_days: {
|
||||
desktop: isValidHistoryDays(storedDays.desktop) ? (storedDays.desktop as number) : undefined,
|
||||
mobile: isValidHistoryDays(storedDays.mobile) ? (storedDays.mobile as number) : undefined,
|
||||
},
|
||||
monitor_layout_style: isValidLayoutStyle(stored.monitor_layout_style) ? stored.monitor_layout_style : undefined,
|
||||
meta_page_title: typeof stored.metaPageTitle === "string" ? stored.metaPageTitle : undefined,
|
||||
meta_page_description: typeof stored.metaPageDescription === "string" ? stored.metaPageDescription : undefined,
|
||||
social_page_preview_image:
|
||||
typeof stored.socialPagePreviewImage === "string" ? stored.socialPagePreviewImage : undefined,
|
||||
};
|
||||
return mergePageSettings(getDefaultPageSettings(), fromStore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-merges a partial API payload into the stored settings JSON and returns
|
||||
* the new JSON string. Only keys present in the patch are written; everything
|
||||
* else in the stored JSON — including nested keys and top-level keys this
|
||||
* module does not know about — is preserved, so an API write can never wipe
|
||||
* settings written by other parts of the app, and clients may persist extra
|
||||
* keys (the schema allows additional properties).
|
||||
*/
|
||||
export function applyPageSettingsPatch(
|
||||
storedJson: string | null | undefined,
|
||||
patch: PageSettingsPatch | undefined,
|
||||
): string {
|
||||
const stored = parseStored(storedJson);
|
||||
if (!patch) {
|
||||
return JSON.stringify(stored);
|
||||
}
|
||||
|
||||
// Map the API's snake_case meta fields to their stored camelCase keys; all
|
||||
// other keys are stored under their API names
|
||||
const { meta_page_title, meta_page_description, social_page_preview_image, ...rest } = patch;
|
||||
const mappedPatch: Record<string, unknown> = { ...rest };
|
||||
if (meta_page_title !== undefined) mappedPatch.metaPageTitle = meta_page_title;
|
||||
if (meta_page_description !== undefined) mappedPatch.metaPageDescription = meta_page_description;
|
||||
if (social_page_preview_image !== undefined) mappedPatch.socialPagePreviewImage = social_page_preview_image;
|
||||
|
||||
return JSON.stringify(deepMerge(stored, mappedPatch));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a partial page_settings payload from the API. Returns an error
|
||||
* message, or null when valid. Bounds mirror the manage UI (history days
|
||||
* 1-365, layout style one of the four shipped styles).
|
||||
*/
|
||||
export function validatePageSettings(partial: unknown): string | null {
|
||||
if (partial === undefined) return null;
|
||||
if (typeof partial !== "object" || partial === null || Array.isArray(partial)) {
|
||||
return "page_settings must be an object";
|
||||
}
|
||||
|
||||
const settings = partial as PageSettingsPatch;
|
||||
|
||||
// The event display branches and their known sub-objects must be objects;
|
||||
// anything else would be deep-merged into storage as-is
|
||||
if (settings.incidents !== undefined) {
|
||||
if (!isPlainObject(settings.incidents)) {
|
||||
return "incidents must be an object";
|
||||
}
|
||||
for (const key of ["ongoing", "resolved"] as const) {
|
||||
if (settings.incidents[key] !== undefined && !isPlainObject(settings.incidents[key])) {
|
||||
return `incidents.${key} must be an object`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.include_maintenances !== undefined) {
|
||||
if (!isPlainObject(settings.include_maintenances)) {
|
||||
return "include_maintenances must be an object";
|
||||
}
|
||||
const ongoing = settings.include_maintenances.ongoing;
|
||||
if (ongoing !== undefined) {
|
||||
if (!isPlainObject(ongoing)) {
|
||||
return "include_maintenances.ongoing must be an object";
|
||||
}
|
||||
for (const key of ["past", "upcoming"] as const) {
|
||||
if (ongoing[key] !== undefined && !isPlainObject(ongoing[key])) {
|
||||
return `include_maintenances.ongoing.${key} must be an object`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Leaf types inside the event branches must match the schema
|
||||
const leafChecks: Array<{ path: readonly string[]; kind: "boolean" | "count" }> = [
|
||||
{ path: ["incidents", "enabled"], kind: "boolean" },
|
||||
{ path: ["incidents", "ongoing", "show"], kind: "boolean" },
|
||||
{ path: ["incidents", "resolved", "show"], kind: "boolean" },
|
||||
{ path: ["incidents", "resolved", "max_count"], kind: "count" },
|
||||
{ path: ["incidents", "resolved", "days_in_past"], kind: "count" },
|
||||
{ path: ["include_maintenances", "enabled"], kind: "boolean" },
|
||||
{ path: ["include_maintenances", "ongoing", "show"], kind: "boolean" },
|
||||
{ path: ["include_maintenances", "ongoing", "past", "show"], kind: "boolean" },
|
||||
{ path: ["include_maintenances", "ongoing", "past", "max_count"], kind: "count" },
|
||||
{ path: ["include_maintenances", "ongoing", "past", "days_in_past"], kind: "count" },
|
||||
{ path: ["include_maintenances", "ongoing", "upcoming", "show"], kind: "boolean" },
|
||||
{ path: ["include_maintenances", "ongoing", "upcoming", "max_count"], kind: "count" },
|
||||
{ path: ["include_maintenances", "ongoing", "upcoming", "days_in_future"], kind: "count" },
|
||||
];
|
||||
for (const { path, kind } of leafChecks) {
|
||||
let value: unknown = settings;
|
||||
for (const key of path) {
|
||||
if (!isPlainObject(value)) {
|
||||
value = undefined;
|
||||
break;
|
||||
}
|
||||
value = value[key];
|
||||
}
|
||||
if (value === undefined) continue;
|
||||
if (kind === "boolean" && typeof value !== "boolean") {
|
||||
return `${path.join(".")} must be a boolean`;
|
||||
}
|
||||
if (kind === "count" && !(Number.isInteger(value) && (value as number) >= 0)) {
|
||||
return `${path.join(".")} must be a non-negative integer`;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.monitor_layout_style !== undefined) {
|
||||
if (!GC.MONITOR_LAYOUT_STYLES.includes(settings.monitor_layout_style)) {
|
||||
return `monitor_layout_style must be one of: ${GC.MONITOR_LAYOUT_STYLES.join(", ")}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.monitor_status_history_days !== undefined) {
|
||||
const days = settings.monitor_status_history_days;
|
||||
if (typeof days !== "object" || days === null || Array.isArray(days)) {
|
||||
return "monitor_status_history_days must be an object";
|
||||
}
|
||||
for (const key of ["desktop", "mobile"] as const) {
|
||||
const value = days[key];
|
||||
if (value !== undefined) {
|
||||
if (!Number.isInteger(value) || value < HISTORY_DAYS_MIN || value > HISTORY_DAYS_MAX) {
|
||||
return `monitor_status_history_days.${key} must be an integer between ${HISTORY_DAYS_MIN} and ${HISTORY_DAYS_MAX}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of ["meta_page_title", "meta_page_description", "social_page_preview_image"] as const) {
|
||||
const value = settings[key];
|
||||
if (value !== undefined && typeof value !== "string") {
|
||||
return `${key} must be a string`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
UpdateMonitorAlertV2Status,
|
||||
} from "../controllers/monitorAlertConfigController.js";
|
||||
import type { IncidentInput } from "../controllers/incidentController.js";
|
||||
import { InsertNewAlert } from "../controllers/controller.js";
|
||||
import { GetMonitorAlertsV2 } from "../controllers/monitorAlertConfigController.js";
|
||||
import db from "../db/db.js";
|
||||
import { getUnixTime, differenceInSeconds } from "date-fns";
|
||||
@@ -34,7 +33,6 @@ import sendEmail from "../notification/email_notification.js";
|
||||
import sendWebhook from "$lib/server/notification/webhook_notification.js";
|
||||
import sendSlack from "$lib/server/notification/slack_notification.js";
|
||||
import sendDiscord from "$lib/server/notification/discord_notification.js";
|
||||
import serverResolver from "../resolver.js";
|
||||
|
||||
import type { SiteDataForNotification, SubscriptionVariableMap } from "../notification/types.js";
|
||||
import mdToHTML from "../../marked.js";
|
||||
|
||||
@@ -7,6 +7,7 @@ import { GetMinuteStartNowTimestampUTC } from "../tool.js";
|
||||
import db from "../db/db.js";
|
||||
import monitorResponseQueue from "./monitorResponseQueue";
|
||||
import GC from "../../global-constants.js";
|
||||
import { resolveConfirmedStatus } from "../services/confirmationThreshold.js";
|
||||
|
||||
let monitorExecuteQueue: Queue | null = null;
|
||||
let worker: Worker | null = null;
|
||||
@@ -25,8 +26,13 @@ const getQueue = () => {
|
||||
return monitorExecuteQueue;
|
||||
};
|
||||
|
||||
async function manualMaintenance(monitor: MonitorRecordTyped): Promise<{ [timestamp: number]: MonitoringResult }> {
|
||||
let startTs = GetMinuteStartNowTimestampUTC();
|
||||
async function manualMaintenance(
|
||||
monitor: MonitorRecordTyped,
|
||||
ts?: number,
|
||||
): Promise<{ [timestamp: number]: MonitoringResult }> {
|
||||
// Key by the job's `ts` (already a minute-start) so the overlay aligns with the realtime/default
|
||||
// rows and the freeze gate; fall back to "now" only when called without a ts.
|
||||
let startTs = ts !== undefined ? ts : GetMinuteStartNowTimestampUTC();
|
||||
let maintenanceArr = await db.getMaintenancesByMonitorTagRealtime(monitor.tag, startTs);
|
||||
|
||||
let impact = "";
|
||||
@@ -66,8 +72,13 @@ async function manualMaintenance(monitor: MonitorRecordTyped): Promise<{ [timest
|
||||
return manualData;
|
||||
}
|
||||
|
||||
async function manualIncident(monitor: MonitorRecordTyped): Promise<{ [timestamp: number]: MonitoringResult }> {
|
||||
let startTs = GetMinuteStartNowTimestampUTC();
|
||||
async function manualIncident(
|
||||
monitor: MonitorRecordTyped,
|
||||
ts?: number,
|
||||
): Promise<{ [timestamp: number]: MonitoringResult }> {
|
||||
// Key by the job's `ts` (already a minute-start) so the overlay aligns with the realtime/default
|
||||
// rows and the freeze gate; fall back to "now" only when called without a ts.
|
||||
let startTs = ts !== undefined ? ts : GetMinuteStartNowTimestampUTC();
|
||||
let incidentArr = await db.getIncidentsByMonitorTagRealtime(monitor.tag, startTs);
|
||||
|
||||
let impact = "";
|
||||
@@ -115,13 +126,43 @@ const addWorker = () => {
|
||||
|
||||
const exeResult = await serviceClient.execute(ts);
|
||||
|
||||
// Fetch overlays AFTER the check runs so a maintenance/incident that starts mid-check is still
|
||||
// detected, and key them by the job's `ts` so the freeze gate (incidentData[ts]) is
|
||||
// timestamp-safe even if the job is delayed or retried (#756).
|
||||
let incidentData: MonitoringResultTS = await manualIncident(monitor, ts);
|
||||
let maintenanceData: MonitoringResultTS = await manualMaintenance(monitor, ts);
|
||||
|
||||
let realtimeData: MonitoringResultTS = {};
|
||||
if (exeResult) {
|
||||
realtimeData[ts] = exeResult;
|
||||
}
|
||||
// Always record what the check actually observed (forensics + grace counting).
|
||||
realtimeData[ts].raw_status = exeResult.status;
|
||||
|
||||
let incidentData: MonitoringResultTS = await manualIncident(monitor);
|
||||
let maintenanceData: MonitoringResultTS = await manualMaintenance(monitor);
|
||||
// Confirmation Threshold damping (#712): scheduled checks only.
|
||||
const threshold = Number(monitor.confirmation_threshold ?? 1);
|
||||
const isScheduledCheck = ([GC.REALTIME, GC.TIMEOUT, GC.ERROR] as string[]).includes(exeResult.type);
|
||||
// Confirmation Threshold freezes while an incident/maintenance overlay is active for this
|
||||
// minute: the overlay wins display and the count must neither advance nor backfill (#756).
|
||||
const overlayActive = incidentData[ts] !== undefined || maintenanceData[ts] !== undefined;
|
||||
if (threshold > 1 && isScheduledCheck && !overlayActive) {
|
||||
const resolved = await resolveConfirmedStatus({
|
||||
monitor_tag: monitor.tag,
|
||||
ts,
|
||||
rawStatus: exeResult.status,
|
||||
threshold,
|
||||
});
|
||||
realtimeData[ts].status = resolved.status;
|
||||
if (resolved.pendingHold) {
|
||||
// Hold the confirmed side for display, but PRESERVE the observed latency and error text —
|
||||
// no diagnostic info is discarded. Tag the row to record that the status is being held
|
||||
// during the grace period; on confirmation the backfill appends the confirmation note (#756).
|
||||
const observedError = realtimeData[ts].error_message;
|
||||
realtimeData[ts].error_message = observedError
|
||||
? `${observedError} | Status held during grace period`
|
||||
: "Status held during grace period";
|
||||
}
|
||||
}
|
||||
}
|
||||
let defaultData: MonitoringResultTS = {};
|
||||
let mergedData: MonitoringResultTS = {};
|
||||
|
||||
@@ -186,6 +227,15 @@ const addWorker = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve raw_status from realtime monitoring (overlays replace the merged object wholesale,
|
||||
// so re-attach the observed value the resolver recorded).
|
||||
for (const timestamp in mergedData) {
|
||||
const ts = parseInt(timestamp);
|
||||
if (realtimeData[ts]?.raw_status !== undefined) {
|
||||
mergedData[ts].raw_status = realtimeData[ts].raw_status;
|
||||
}
|
||||
}
|
||||
|
||||
for (const timestamp in mergedData) {
|
||||
monitorResponseQueue.push(monitor.tag, parseInt(timestamp), mergedData[timestamp]);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ interface JobData {
|
||||
monitorTag: string;
|
||||
ts: number;
|
||||
error_message?: string | null;
|
||||
raw_status?: string | null;
|
||||
}
|
||||
|
||||
const getQueue = () => {
|
||||
@@ -30,7 +31,7 @@ const addWorker = () => {
|
||||
if (worker) return worker;
|
||||
|
||||
worker = q.createWorker(getQueue(), async (job: Job): Promise<MonitoringData | null> => {
|
||||
const { monitorTag, ts, status, latency, type, error_message } = job.data as JobData;
|
||||
const { monitorTag, ts, status, latency, type, error_message, raw_status } = job.data as JobData;
|
||||
|
||||
const dbRes = await InsertMonitoringData({
|
||||
monitor_tag: monitorTag,
|
||||
@@ -39,6 +40,7 @@ const addWorker = () => {
|
||||
latency: latency,
|
||||
type: type,
|
||||
error_message: error_message,
|
||||
raw_status: raw_status,
|
||||
});
|
||||
|
||||
if (!dbRes) {
|
||||
|
||||
@@ -47,7 +47,7 @@ const getRetentionPolicy = async (): Promise<DataRetentionPolicy> => {
|
||||
const runDailyCleanup = async (): Promise<DailyCleanupResult> => {
|
||||
const policy = await getRetentionPolicy();
|
||||
const retentionDays = Math.max(1, Math.floor(policy.retentionDays || defaultPolicy.retentionDays));
|
||||
|
||||
console.log(`Data retention policy: enabled=${policy.enabled}, retentionDays=${retentionDays}`);
|
||||
if (!policy.enabled) {
|
||||
return {
|
||||
skipped: true,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import GC from "../../global-constants.js";
|
||||
import db from "../db/db.js";
|
||||
|
||||
export type Side = "UP" | "DOWN" | null;
|
||||
|
||||
/** Binary side: UP is healthy; DOWN/DEGRADED are unhealthy; everything else (NO_DATA) is neutral. */
|
||||
export function sideOf(status: string | null | undefined): Side {
|
||||
if (status === GC.UP) return "UP";
|
||||
if (status === GC.DOWN || status === GC.DEGRADED) return "DOWN";
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ResolveInput {
|
||||
monitor_tag: string;
|
||||
ts: number;
|
||||
rawStatus: string;
|
||||
threshold: number; // >= 2 to damp; 1 behaves as off (any opposite observation confirms instantly)
|
||||
}
|
||||
|
||||
export interface ResolveResult {
|
||||
/** Effective status to commit for this minute. */
|
||||
status: string;
|
||||
/**
|
||||
* True while the displayed status is the held confirmed side (pending confirmation) rather
|
||||
* than the observed side. The caller keeps the observed latency and error text (tagging the
|
||||
* row) — it does not blank them.
|
||||
*/
|
||||
pendingHold: boolean;
|
||||
}
|
||||
|
||||
/** Minimal data access this resolver needs; defaults to the db singleton, injectable for tests. */
|
||||
export interface ConfirmationDeps {
|
||||
getRecentSamplesForConfirmation: typeof db.getRecentSamplesForConfirmation;
|
||||
getLastObservedStatus: typeof db.getLastObservedStatus;
|
||||
backfillConfirmedStatus: typeof db.backfillConfirmedStatus;
|
||||
}
|
||||
|
||||
// Overlay sample types that freeze the count (must match OVERLAY_TYPES in the monitoring repository).
|
||||
const OVERLAY_TYPES: string[] = [GC.INCIDENT, GC.MAINTENANCE];
|
||||
// Extra lookback rows beyond the threshold for the pending-run scan: headroom for interleaved
|
||||
// overlay rows. NO_DATA observations are excluded by the query, and the anchor is fetched
|
||||
// separately, so the buffer need not scale with NO_DATA density or overlay-window length.
|
||||
const LOOKBACK_BUFFER = 10;
|
||||
|
||||
/**
|
||||
* Resolve the status to commit for one scheduled-check observation under Confirmation
|
||||
* Threshold damping (issue #712).
|
||||
*
|
||||
* IMPORTANT ordering contract: this MUST be called BEFORE the current row at `ts` is
|
||||
* persisted, and only when no incident/maintenance overlay is active for `ts` (the caller
|
||||
* gates that — overlays freeze the count). It anchors on the most recent stored observation
|
||||
* (timestamp < ts).
|
||||
*
|
||||
* Neutral (`NO_DATA`) observations are excluded from the count (neither advance nor reset).
|
||||
* Overlay rows act as a hard boundary: the pending run never crosses one, so monitoring
|
||||
* resumes with a fresh count after an incident/maintenance window.
|
||||
*/
|
||||
export async function resolveConfirmedStatus(
|
||||
input: ResolveInput,
|
||||
deps: ConfirmationDeps = db,
|
||||
): Promise<ResolveResult> {
|
||||
const { monitor_tag, ts, rawStatus, threshold } = input;
|
||||
const observedSide = sideOf(rawStatus);
|
||||
|
||||
// Neutral observation (NO_DATA): pass through untouched — written honestly as grey.
|
||||
if (observedSide === null) {
|
||||
return { status: rawStatus, pendingHold: false };
|
||||
}
|
||||
|
||||
// Anchor = the side currently shown = the most recent real observation's committed status.
|
||||
// Fetched with a dedicated query (not from the windowed scan) so a long incident/maintenance
|
||||
// window can never push the anchor out of range and bypass damping.
|
||||
const confirmedStatus = await deps.getLastObservedStatus(monitor_tag, ts);
|
||||
const confirmedSide = sideOf(confirmedStatus);
|
||||
|
||||
// Cold start / no usable anchor / same side: commit immediately.
|
||||
if (confirmedStatus === null || confirmedSide === null || observedSide === confirmedSide) {
|
||||
return { status: rawStatus, pendingHold: false };
|
||||
}
|
||||
|
||||
// Opposite side: count the trailing pending run (incl. current = 1), stopping at an overlay
|
||||
// boundary (freeze) or at a confirmed-side observation. NO_DATA rows are excluded by the query.
|
||||
const recent = await deps.getRecentSamplesForConfirmation(monitor_tag, ts, threshold + LOOKBACK_BUFFER);
|
||||
let pendingRun = 1;
|
||||
const pendingTimestamps: number[] = [];
|
||||
for (const row of recent) {
|
||||
if (row.type !== null && OVERLAY_TYPES.includes(row.type)) break; // freeze boundary
|
||||
const rawSide = sideOf(row.raw_status);
|
||||
if (rawSide === null) continue; // NO_DATA: neutral (excluded by the query; this is a defensive guard)
|
||||
if (rawSide === observedSide && sideOf(row.status) === confirmedSide) {
|
||||
pendingRun++;
|
||||
pendingTimestamps.push(row.timestamp);
|
||||
} else {
|
||||
break; // hit a confirmed-side observation (the anchor)
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingRun >= threshold) {
|
||||
// Unhealthy confirm passes the count so the backfill can write a per-row severity note
|
||||
// ("Down"/"Degraded confirmed after N…"); recovery passes null (clears the held error text).
|
||||
await deps.backfillConfirmedStatus(monitor_tag, pendingTimestamps, observedSide === "DOWN" ? threshold : null);
|
||||
return { status: rawStatus, pendingHold: false };
|
||||
}
|
||||
|
||||
// Still pending: hold the confirmed side.
|
||||
return { status: confirmedStatus, pendingHold: true };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import DNSResolver from "../dns.js";
|
||||
import { GetLastKnownStatus } from "../controllers/monitorsController.js";
|
||||
import type { NoneMonitor, MonitoringResult } from "../types/monitor.js";
|
||||
import GC from "../../global-constants.js";
|
||||
|
||||
class NoneCall {
|
||||
monitor: NoneMonitor;
|
||||
@@ -8,7 +9,24 @@ class NoneCall {
|
||||
this.monitor = monitor;
|
||||
}
|
||||
|
||||
async execute(): Promise<null> {
|
||||
async execute(): Promise<MonitoringResult | null> {
|
||||
let overrideWithLastKnownStatus = this.monitor.type_data.overrideWithLastKnownStatus;
|
||||
if (!!overrideWithLastKnownStatus) {
|
||||
//get the last known status
|
||||
let lastKnownStatus = await GetLastKnownStatus(this.monitor.tag);
|
||||
if (
|
||||
!!lastKnownStatus &&
|
||||
!!lastKnownStatus.status &&
|
||||
!!lastKnownStatus.type &&
|
||||
lastKnownStatus.type === GC.MANUAL
|
||||
) {
|
||||
return {
|
||||
status: lastKnownStatus.status,
|
||||
latency: lastKnownStatus.latency || 0,
|
||||
type: lastKnownStatus.type,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Server-only database types (based on migrations schema)
|
||||
import type { Knex } from "knex";
|
||||
import type { PageMonitorLayoutStyle } from "$lib/types/api";
|
||||
|
||||
// ============ monitoring_data table ============
|
||||
export interface MonitoringData {
|
||||
@@ -9,6 +10,7 @@ export interface MonitoringData {
|
||||
latency: number | null;
|
||||
type: string | null;
|
||||
error_message?: string | null;
|
||||
raw_status?: string | null;
|
||||
}
|
||||
|
||||
export interface MonitoringDataInsert {
|
||||
@@ -18,6 +20,7 @@ export interface MonitoringDataInsert {
|
||||
latency: number;
|
||||
type: string;
|
||||
error_message?: string | null;
|
||||
raw_status?: string | null;
|
||||
}
|
||||
|
||||
export interface AggregatedMonitoringData {
|
||||
@@ -77,6 +80,7 @@ export interface MonitorRecord {
|
||||
external_url?: string | null;
|
||||
day_degraded_minimum_count?: number | null;
|
||||
day_down_minimum_count?: number | null;
|
||||
confirmation_threshold?: number | null;
|
||||
include_degraded_in_downtime?: string;
|
||||
is_hidden: string;
|
||||
monitor_settings_json: string | null;
|
||||
@@ -134,6 +138,7 @@ export interface MonitorRecordTyped {
|
||||
type_data: Record<string, unknown> | null;
|
||||
day_degraded_minimum_count?: number | null;
|
||||
day_down_minimum_count?: number | null;
|
||||
confirmation_threshold?: number | null;
|
||||
include_degraded_in_downtime?: string;
|
||||
is_hidden: string;
|
||||
monitor_settings_json: MonitorSettings | null;
|
||||
@@ -157,6 +162,7 @@ export interface MonitorRecordInsert {
|
||||
type_data?: string | null;
|
||||
day_degraded_minimum_count?: number | null;
|
||||
day_down_minimum_count?: number | null;
|
||||
confirmation_threshold?: number | null;
|
||||
include_degraded_in_downtime?: string;
|
||||
is_hidden?: string;
|
||||
monitor_settings_json?: string | null;
|
||||
@@ -236,7 +242,7 @@ export interface UserRecord {
|
||||
password_hash: string;
|
||||
is_active: number;
|
||||
is_verified: number;
|
||||
role: string;
|
||||
role_ids: string[]; // Array of role IDs
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -245,9 +251,9 @@ export interface UserRecordInsert {
|
||||
email: string;
|
||||
name: string;
|
||||
password_hash: string;
|
||||
role_ids: string[]; // Array of role IDs
|
||||
is_active?: number;
|
||||
is_verified?: number;
|
||||
role?: string;
|
||||
is_owner?: string;
|
||||
}
|
||||
|
||||
@@ -258,7 +264,7 @@ export interface UserRecordPublic {
|
||||
is_active: number;
|
||||
is_verified: number;
|
||||
is_owner: string;
|
||||
role: string;
|
||||
role_ids: string[];
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -266,6 +272,31 @@ export interface UserRecordDashboard extends UserRecordPublic {
|
||||
has_password: boolean;
|
||||
}
|
||||
|
||||
// ============ roles table ============
|
||||
export interface RoleRecord {
|
||||
id: string;
|
||||
role_name: string;
|
||||
readonly: number;
|
||||
status: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface RolePermissionRecord {
|
||||
roles_id: string;
|
||||
permissions_id: string;
|
||||
status: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface UserRoleRecord {
|
||||
roles_id: string;
|
||||
users_id: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
// ============ api_keys table ============
|
||||
export interface ApiKeyRecord {
|
||||
id: number;
|
||||
@@ -443,7 +474,7 @@ export interface PageSettingsType {
|
||||
desktop: number;
|
||||
mobile: number;
|
||||
};
|
||||
monitor_layout_style: "default-list" | "default-grid" | "compact-list" | "compact-grid";
|
||||
monitor_layout_style: PageMonitorLayoutStyle;
|
||||
metaPageTitle?: string;
|
||||
metaPageDescription?: string;
|
||||
socialPagePreviewImage?: string;
|
||||
|
||||
@@ -8,13 +8,16 @@ export interface MonitoringResult {
|
||||
latency: number;
|
||||
type: string;
|
||||
error_message?: string;
|
||||
raw_status?: string;
|
||||
}
|
||||
|
||||
export interface MonitoringResultTS {
|
||||
[timestamp: number]: MonitoringResult;
|
||||
}
|
||||
|
||||
export interface NoneMonitorTypeData {}
|
||||
export interface NoneMonitorTypeData {
|
||||
overrideWithLastKnownStatus: boolean;
|
||||
}
|
||||
export interface ApiMonitorTypeData {
|
||||
url: string;
|
||||
body?: string;
|
||||
|
||||
+68
-4
@@ -3,6 +3,7 @@
|
||||
|
||||
import type { MonitorRecordTyped } from "$lib/server/types/db";
|
||||
import type { MonitorPublicView } from "$lib/types/monitor";
|
||||
import type GC from "$lib/global-constants";
|
||||
|
||||
export type ApiError = {
|
||||
code: string;
|
||||
@@ -91,6 +92,7 @@ export interface MonitorResponse {
|
||||
type_data: MonitorTypeData | null;
|
||||
include_degraded_in_downtime: string;
|
||||
is_hidden: string;
|
||||
confirmation_threshold?: number | null;
|
||||
monitor_settings_json: MonitorSettings | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -117,6 +119,7 @@ export interface CreateMonitorRequest {
|
||||
type_data?: MonitorTypeData | null;
|
||||
include_degraded_in_downtime?: string;
|
||||
is_hidden?: string;
|
||||
confirmation_threshold?: number | null;
|
||||
monitor_settings_json?: MonitorSettings | null;
|
||||
}
|
||||
|
||||
@@ -136,6 +139,7 @@ export interface UpdateMonitorRequest {
|
||||
type_data?: MonitorTypeData | null;
|
||||
include_degraded_in_downtime?: string;
|
||||
is_hidden?: string;
|
||||
confirmation_threshold?: number | null;
|
||||
monitor_settings_json?: MonitorSettings | null;
|
||||
}
|
||||
|
||||
@@ -143,6 +147,10 @@ export interface UpdateMonitorResponse {
|
||||
monitor: MonitorRecordTyped;
|
||||
}
|
||||
|
||||
export interface DeleteMonitorResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Monitoring Data API types
|
||||
export interface MonitoringDataPoint {
|
||||
monitor_tag: string;
|
||||
@@ -198,6 +206,8 @@ export interface IncidentResponse {
|
||||
monitors: IncidentMonitor[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/** Absolute URL of the public incident page */
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface IncidentDetailResponse extends IncidentResponse {
|
||||
@@ -298,6 +308,13 @@ export interface MaintenanceResponse {
|
||||
monitors: MaintenanceMonitor[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/**
|
||||
* Absolute URL of the public page for this maintenance.
|
||||
* Note: the public /maintenances/<id> route is keyed by maintenance EVENT id
|
||||
* by default, so this URL carries ?type=maintenance. Link via this field,
|
||||
* never by concatenating `id` onto a path. See docs/adr/0002.
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GetMaintenancesListResponse {
|
||||
@@ -344,6 +361,8 @@ export interface MaintenanceEventResponse {
|
||||
status: "SCHEDULED" | "READY" | "ONGOING" | "COMPLETED" | "CANCELLED";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/** Absolute URL of the public page for this maintenance event */
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GetMaintenanceEventsListResponse {
|
||||
@@ -357,8 +376,15 @@ export interface GetMaintenanceEventResponse {
|
||||
}
|
||||
|
||||
export interface UpdateMaintenanceEventRequest {
|
||||
start_date_time: number;
|
||||
end_date_time: number;
|
||||
/** Window edit mode: both times required. Cannot be combined with `status`. */
|
||||
start_date_time?: number;
|
||||
end_date_time?: number;
|
||||
/**
|
||||
* Transition mode: COMPLETED (from ONGOING) or CANCELLED (from SCHEDULED/READY/ONGOING).
|
||||
* Cannot be combined with time fields. Transitioning an ONGOING event moves its
|
||||
* end_date_time to the moment of the transition.
|
||||
*/
|
||||
status?: "COMPLETED" | "CANCELLED";
|
||||
}
|
||||
|
||||
export interface UpdateMaintenanceEventResponse {
|
||||
@@ -386,6 +412,8 @@ export interface MaintenanceEventDetailResponse {
|
||||
maintenance_rrule: string;
|
||||
maintenance_duration_seconds: number;
|
||||
monitors: MaintenanceMonitor[];
|
||||
/** Absolute URL of the public page for this maintenance event */
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GetMaintenanceEventsDetailListResponse {
|
||||
@@ -436,9 +464,40 @@ export interface PageSettingsMaintenances {
|
||||
ongoing: PageSettingsMaintenancesOngoing;
|
||||
}
|
||||
|
||||
export interface PageSettingsHistoryDays {
|
||||
desktop: number;
|
||||
mobile: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive partial, so patch payloads can update any subset of nested fields.
|
||||
* Recursion applies only to plain object maps; arrays and other special object
|
||||
* types pass through unchanged.
|
||||
*/
|
||||
export type DeepPartial<T> = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[K in keyof T]?: T[K] extends (infer U)[] ? U[] : T[K] extends Record<string, any> ? DeepPartial<T[K]> : T[K];
|
||||
};
|
||||
|
||||
/**
|
||||
* Patch payload for page_settings: any subset of nested fields. Provided
|
||||
* fields are deep-merged into the current settings; omitted fields are left
|
||||
* untouched.
|
||||
*/
|
||||
export type PageSettingsPatch = DeepPartial<PageSettings>;
|
||||
|
||||
export type PageMonitorLayoutStyle = (typeof GC.MONITOR_LAYOUT_STYLES)[number];
|
||||
|
||||
export interface PageSettings {
|
||||
incidents: PageSettingsIncidents;
|
||||
include_maintenances: PageSettingsMaintenances;
|
||||
/** Days of status history shown on the page, per device class (1-365). */
|
||||
monitor_status_history_days: PageSettingsHistoryDays;
|
||||
monitor_layout_style: PageMonitorLayoutStyle;
|
||||
/** Per-page meta/social overrides; stored as camelCase keys internally. */
|
||||
meta_page_title?: string;
|
||||
meta_page_description?: string;
|
||||
social_page_preview_image?: string;
|
||||
}
|
||||
|
||||
export interface PageMonitorResponse {
|
||||
@@ -447,6 +506,11 @@ export interface PageMonitorResponse {
|
||||
|
||||
export interface PageResponse {
|
||||
id: number;
|
||||
/**
|
||||
* The page's path segment. The home page (stored path is empty) renders as
|
||||
* the addressable token `~home`; its public URL is the site root.
|
||||
* See docs/adr/0004-home-page-api-token.md.
|
||||
*/
|
||||
page_path: string;
|
||||
page_title: string;
|
||||
page_header: string;
|
||||
@@ -472,7 +536,7 @@ export interface CreatePageRequest {
|
||||
page_header: string;
|
||||
page_subheader?: string | null;
|
||||
page_logo?: string | null;
|
||||
page_settings?: Partial<PageSettings>;
|
||||
page_settings?: PageSettingsPatch;
|
||||
monitors?: string[];
|
||||
}
|
||||
|
||||
@@ -486,7 +550,7 @@ export interface UpdatePageRequest {
|
||||
page_header?: string;
|
||||
page_subheader?: string | null;
|
||||
page_logo?: string | null;
|
||||
page_settings?: Partial<PageSettings>;
|
||||
page_settings?: PageSettingsPatch;
|
||||
monitors?: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,9 @@
|
||||
import { ModeWatcher } from "mode-watcher";
|
||||
import { resolve } from "$app/paths";
|
||||
import { Toaster } from "$lib/components/ui/sonner/index.js";
|
||||
|
||||
let base = resolve("/");
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
|
||||
let { children, data } = $props();
|
||||
|
||||
const colorUp = $derived(data.siteStatusColors.UP);
|
||||
const colorDegraded = $derived(data.siteStatusColors.DEGRADED);
|
||||
const colorDown = $derived(data.siteStatusColors.DOWN);
|
||||
const colorMaintenance = $derived(data.siteStatusColors.MAINTENANCE);
|
||||
import KenerNav from "$lib/components/KenerNav.svelte";
|
||||
</script>
|
||||
|
||||
@@ -21,7 +15,32 @@
|
||||
|
||||
<svelte:head>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
{@html `<style>:root{--up:${colorUp};--degraded:${colorDegraded};--down:${colorDown};--maintenance:${colorMaintenance};}</style>`}
|
||||
<link rel="icon" href={data.favicon} />
|
||||
{#if data.font?.cssSrc}
|
||||
<link rel="stylesheet" href={data.font.cssSrc} />
|
||||
{/if}
|
||||
{@html `
|
||||
<style id="dynamic-styles">
|
||||
body {
|
||||
--up: ${data.siteStatusColors.UP};
|
||||
--degraded: ${data.siteStatusColors.DEGRADED};
|
||||
--down: ${data.siteStatusColors.DOWN};
|
||||
--maintenance: ${data.siteStatusColors.MAINTENANCE};
|
||||
--accent: ${data.siteStatusColors.ACCENT || "#f4f4f5"};
|
||||
--accent-foreground: ${data.siteStatusColors.ACCENT_FOREGROUND || data.siteStatusColors.ACCENT || "#e96e2d"};
|
||||
${data.font?.family ? `--font-family:'${data.font.family}', sans-serif;` : ""}
|
||||
}
|
||||
:is(.dark) body {
|
||||
--up: ${data.siteStatusColorsDark.UP};
|
||||
--degraded: ${data.siteStatusColorsDark.DEGRADED};
|
||||
--down: ${data.siteStatusColorsDark.DOWN};
|
||||
--maintenance: ${data.siteStatusColorsDark.MAINTENANCE};
|
||||
--accent: ${data.siteStatusColorsDark.ACCENT || "#27272a"};
|
||||
--accent-foreground: ${data.siteStatusColorsDark.ACCENT_FOREGROUND || data.siteStatusColorsDark.ACCENT || "#e96e2d"};
|
||||
}
|
||||
${data.customCSS || ""}
|
||||
</style>`}
|
||||
<script src={clientResolver(resolve, "/capture.js")}></script>
|
||||
</svelte:head>
|
||||
<main>
|
||||
<!-- Nav -->
|
||||
|
||||
@@ -59,6 +59,13 @@ export const actions: Actions = {
|
||||
});
|
||||
}
|
||||
|
||||
if (!userDB.role_ids || userDB.role_ids.length === 0) {
|
||||
return fail(403, {
|
||||
error: "Your account has no active roles assigned. Please contact an administrator.",
|
||||
values: { email },
|
||||
});
|
||||
}
|
||||
|
||||
const token = await GenerateToken(userDB);
|
||||
const cookieConfig = CookieConfig();
|
||||
cookies.set(cookieConfig.name, token, {
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
} from "$lib/types/api";
|
||||
import GC from "$lib/global-constants";
|
||||
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
|
||||
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
|
||||
import serverResolver from "$lib/server/resolver";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
if (date instanceof Date) {
|
||||
@@ -56,6 +58,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
}
|
||||
|
||||
// Build response with monitors for each incident
|
||||
const siteUrl = await GetSiteURL();
|
||||
const incidents: IncidentResponse[] = [];
|
||||
for (const incident of rawIncidents) {
|
||||
const monitors = await db.getIncidentMonitorsByIncidentID(incident.id);
|
||||
@@ -72,6 +75,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
})),
|
||||
created_at: formatDateToISO(incident.created_at),
|
||||
updated_at: formatDateToISO(incident.updated_at),
|
||||
url: siteUrl + serverResolver(`/incidents/${incident.id}`),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -207,6 +211,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
})),
|
||||
created_at: formatDateToISO(createdIncident.created_at),
|
||||
updated_at: formatDateToISO(createdIncident.updated_at),
|
||||
url: (await GetSiteURL()) + serverResolver(`/incidents/${createdIncident.id}`),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
BadRequestResponse,
|
||||
} from "$lib/types/api";
|
||||
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
|
||||
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
|
||||
import serverResolver from "$lib/server/resolver";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
if (date instanceof Date) {
|
||||
@@ -43,6 +45,7 @@ async function buildIncidentResponse(incidentId: number): Promise<IncidentDetail
|
||||
})),
|
||||
created_at: formatDateToISO(incident.created_at),
|
||||
updated_at: formatDateToISO(incident.updated_at),
|
||||
url: (await GetSiteURL()) + serverResolver(`/incidents/${incident.id}`),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
GenerateMaintenanceEvents,
|
||||
isOneTimeRrule,
|
||||
} from "$lib/server/controllers/maintenanceController";
|
||||
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
|
||||
import serverResolver from "$lib/server/resolver";
|
||||
import { rrulestr } from "rrule";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
@@ -67,6 +69,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
}
|
||||
|
||||
// Build response with monitors for each maintenance
|
||||
const siteUrl = await GetSiteURL();
|
||||
const maintenances: MaintenanceResponse[] = [];
|
||||
for (const maintenance of rawMaintenances) {
|
||||
const monitors = await db.getMaintenanceMonitors(maintenance.id);
|
||||
@@ -84,6 +87,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
})),
|
||||
created_at: formatDateToISO(maintenance.created_at),
|
||||
updated_at: formatDateToISO(maintenance.updated_at),
|
||||
url: siteUrl + serverResolver(`/maintenances/${maintenance.id}?type=maintenance`),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -267,6 +271,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
})),
|
||||
created_at: formatDateToISO(maintenance.created_at),
|
||||
updated_at: formatDateToISO(maintenance.updated_at),
|
||||
url: (await GetSiteURL()) + serverResolver(`/maintenances/${maintenance.id}?type=maintenance`),
|
||||
};
|
||||
|
||||
const response: CreateMaintenanceResponse = {
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
} from "$lib/types/api";
|
||||
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
|
||||
import { GenerateMaintenanceEvents, isOneTimeRrule } from "$lib/server/controllers/maintenanceController";
|
||||
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
|
||||
import serverResolver from "$lib/server/resolver";
|
||||
import { rrulestr } from "rrule";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
@@ -55,6 +57,7 @@ async function buildMaintenanceResponse(maintenanceId: number): Promise<Maintena
|
||||
})),
|
||||
created_at: formatDateToISO(maintenance.created_at),
|
||||
updated_at: formatDateToISO(maintenance.updated_at),
|
||||
url: (await GetSiteURL()) + serverResolver(`/maintenances/${maintenance.id}?type=maintenance`),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { json, type RequestHandler } from "@sveltejs/kit";
|
||||
import db from "$lib/server/db/db";
|
||||
import type { GetMaintenanceEventsListResponse, MaintenanceEventResponse } from "$lib/types/api";
|
||||
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
|
||||
import serverResolver from "$lib/server/resolver";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
if (date instanceof Date) {
|
||||
@@ -31,14 +33,16 @@ export const GET: RequestHandler = async ({ locals, url }) => {
|
||||
const paginatedEvents = allEvents.slice(offset, offset + limit);
|
||||
|
||||
// Build response
|
||||
const siteUrl = await GetSiteURL();
|
||||
const events: MaintenanceEventResponse[] = paginatedEvents.map((event) => ({
|
||||
id: event.id,
|
||||
maintenance_id: event.maintenance_id,
|
||||
start_date_time: event.start_date_time,
|
||||
end_date_time: event.end_date_time,
|
||||
status: event.status as "SCHEDULED" | "ONGOING" | "COMPLETED" | "CANCELLED",
|
||||
status: event.status as MaintenanceEventResponse["status"],
|
||||
created_at: formatDateToISO(event.created_at),
|
||||
updated_at: formatDateToISO(event.updated_at),
|
||||
url: siteUrl + serverResolver(`/maintenances/${event.id}`),
|
||||
}));
|
||||
|
||||
const response: GetMaintenanceEventsListResponse = {
|
||||
|
||||
@@ -10,6 +10,10 @@ import type {
|
||||
BadRequestResponse,
|
||||
} from "$lib/types/api";
|
||||
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
|
||||
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
|
||||
import { UpdateMaintenanceEventStatus } from "$lib/server/controllers/maintenanceController";
|
||||
import GC from "$lib/global-constants";
|
||||
import serverResolver from "$lib/server/resolver";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
if (date instanceof Date) {
|
||||
@@ -20,7 +24,7 @@ function formatDateToISO(date: Date | string): string {
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
function buildEventResponse(event: {
|
||||
async function buildEventResponse(event: {
|
||||
id: number;
|
||||
maintenance_id: number;
|
||||
start_date_time: number;
|
||||
@@ -28,15 +32,16 @@ function buildEventResponse(event: {
|
||||
status: string;
|
||||
created_at: Date | string;
|
||||
updated_at: Date | string;
|
||||
}): MaintenanceEventResponse {
|
||||
}): Promise<MaintenanceEventResponse> {
|
||||
return {
|
||||
id: event.id,
|
||||
maintenance_id: event.maintenance_id,
|
||||
start_date_time: event.start_date_time,
|
||||
end_date_time: event.end_date_time,
|
||||
status: event.status as "SCHEDULED" | "ONGOING" | "COMPLETED" | "CANCELLED",
|
||||
status: event.status as MaintenanceEventResponse["status"],
|
||||
created_at: formatDateToISO(event.created_at),
|
||||
updated_at: formatDateToISO(event.updated_at),
|
||||
url: (await GetSiteURL()) + serverResolver(`/maintenances/${event.id}`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,7 +72,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
|
||||
}
|
||||
|
||||
const response: GetMaintenanceEventResponse = {
|
||||
event: buildEventResponse(event),
|
||||
event: await buildEventResponse(event),
|
||||
};
|
||||
|
||||
return json(response);
|
||||
@@ -113,7 +118,46 @@ export const PATCH: RequestHandler = async ({ locals, params, request }) => {
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate required fields - both are required for event update
|
||||
// Transition mode: `status` alone, mutually exclusive with window edits
|
||||
if (body.status !== undefined) {
|
||||
if (body.start_date_time !== undefined || body.end_date_time !== undefined) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: {
|
||||
code: "BAD_REQUEST",
|
||||
message: "Cannot update status and start/end times in the same request",
|
||||
},
|
||||
};
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.status !== GC.COMPLETED && body.status !== GC.CANCELLED) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: {
|
||||
code: "BAD_REQUEST",
|
||||
message: `status must be ${GC.COMPLETED} or ${GC.CANCELLED}`,
|
||||
},
|
||||
};
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedEvent = await UpdateMaintenanceEventStatus(eventId, body.status);
|
||||
const response: UpdateMaintenanceEventResponse = {
|
||||
event: await buildEventResponse(updatedEvent),
|
||||
};
|
||||
return json(response);
|
||||
} catch (err) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: {
|
||||
code: "BAD_REQUEST",
|
||||
message: err instanceof Error ? err.message : "Failed to update event status",
|
||||
},
|
||||
};
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Window edit mode: both times required
|
||||
if (body.start_date_time === undefined || body.start_date_time === null) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: {
|
||||
@@ -182,7 +226,7 @@ export const PATCH: RequestHandler = async ({ locals, params, request }) => {
|
||||
}
|
||||
|
||||
const response: UpdateMaintenanceEventResponse = {
|
||||
event: buildEventResponse(updatedEvent),
|
||||
event: await buildEventResponse(updatedEvent),
|
||||
};
|
||||
|
||||
return json(response);
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
MaintenanceMonitor,
|
||||
} from "$lib/types/api";
|
||||
import { GetNowTimestampUTC, GetMinuteStartTimestampUTC } from "$lib/server/tool";
|
||||
import { GetSiteURL } from "$lib/server/controllers/siteDataController";
|
||||
import serverResolver from "$lib/server/resolver";
|
||||
|
||||
const VALID_EVENT_STATUSES = ["SCHEDULED", "ONGOING", "COMPLETED", "CANCELLED", "READY"];
|
||||
|
||||
@@ -70,6 +72,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
});
|
||||
|
||||
// For each event, get the monitors for that maintenance
|
||||
const siteUrl = await GetSiteURL();
|
||||
const events: MaintenanceEventDetailResponse[] = [];
|
||||
for (const event of rawEvents) {
|
||||
const monitors = await db.getMaintenanceMonitors(event.maintenance_id);
|
||||
@@ -83,13 +86,14 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
event_id: event.event_id,
|
||||
event_start_date_time: event.event_start_date_time,
|
||||
event_end_date_time: event.event_end_date_time,
|
||||
event_status: event.event_status as "SCHEDULED" | "ONGOING" | "COMPLETED" | "CANCELLED",
|
||||
event_status: event.event_status as MaintenanceEventDetailResponse["event_status"],
|
||||
maintenance_title: event.maintenance_title,
|
||||
maintenance_description: event.maintenance_description,
|
||||
maintenance_status: event.maintenance_status as "ACTIVE" | "INACTIVE",
|
||||
maintenance_rrule: event.maintenance_rrule,
|
||||
maintenance_duration_seconds: event.maintenance_duration_seconds,
|
||||
monitors: monitorList,
|
||||
url: siteUrl + serverResolver(`/maintenances/${event.event_id}`),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,19 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate confirmation_threshold
|
||||
let confirmationThreshold = 1;
|
||||
if (body.confirmation_threshold !== undefined && body.confirmation_threshold !== null) {
|
||||
const ct = Number(body.confirmation_threshold);
|
||||
if (!Number.isInteger(ct) || ct < 1 || ct > 60) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: { code: "BAD_REQUEST", message: "confirmation_threshold must be an integer between 1 and 60" },
|
||||
};
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
confirmationThreshold = ct;
|
||||
}
|
||||
|
||||
// Prepare monitor data for insertion
|
||||
const monitorData = {
|
||||
tag: body.tag.trim(),
|
||||
@@ -115,6 +128,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
type_data: body.type_data ? JSON.stringify(body.type_data) : null,
|
||||
include_degraded_in_downtime: body.include_degraded_in_downtime ?? "NO",
|
||||
is_hidden: body.is_hidden ?? "NO",
|
||||
confirmation_threshold: confirmationThreshold,
|
||||
monitor_settings_json: body.monitor_settings_json ? JSON.stringify(body.monitor_settings_json) : null,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { json, type RequestHandler } from "@sveltejs/kit";
|
||||
import db from "$lib/server/db/db";
|
||||
import { GetMonitorsParsed } from "$lib/server/controllers/monitorsController";
|
||||
import { GetMonitorsParsed, DeleteMonitorCompletelyUsingTag } from "$lib/server/controllers/monitorsController";
|
||||
import type {
|
||||
GetMonitorResponse,
|
||||
MonitorResponse,
|
||||
UpdateMonitorRequest,
|
||||
UpdateMonitorResponse,
|
||||
DeleteMonitorResponse,
|
||||
BadRequestResponse,
|
||||
} from "$lib/types/api";
|
||||
|
||||
@@ -79,6 +80,22 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
|
||||
updateData.is_hidden = body.is_hidden !== undefined ? body.is_hidden : existingMonitor.is_hidden;
|
||||
|
||||
if (body.confirmation_threshold === null) {
|
||||
// Explicit null resets the grace period to the default (1 = off); undefined keeps the existing value.
|
||||
updateData.confirmation_threshold = 1;
|
||||
} else if (body.confirmation_threshold !== undefined) {
|
||||
const ct = Number(body.confirmation_threshold);
|
||||
if (!Number.isInteger(ct) || ct < 1 || ct > 60) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: { code: "BAD_REQUEST", message: "confirmation_threshold must be an integer between 1 and 60" },
|
||||
};
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
updateData.confirmation_threshold = ct;
|
||||
} else {
|
||||
updateData.confirmation_threshold = existingMonitor.confirmation_threshold ?? 1;
|
||||
}
|
||||
|
||||
// Handle JSON fields - merge with existing data instead of replacing
|
||||
if (body.type_data !== undefined) {
|
||||
if (body.type_data === null) {
|
||||
@@ -143,3 +160,20 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
|
||||
return json(response);
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ locals }) => {
|
||||
// Monitor is validated by middleware and available in locals
|
||||
const monitor = locals.monitor!;
|
||||
|
||||
// Removes the monitor and everything keyed to its tag: monitoring data,
|
||||
// incident/maintenance/page links, alerts, alert configs, group
|
||||
// memberships (with weight rebalancing), and caches. The scheduler drops
|
||||
// the orphaned BullMQ job on its next reconcile.
|
||||
await DeleteMonitorCompletelyUsingTag(monitor.tag);
|
||||
|
||||
const response: DeleteMonitorResponse = {
|
||||
message: `Monitor '${monitor.tag}' deleted successfully`,
|
||||
};
|
||||
|
||||
return json(response);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import GC from "$lib/global-constants";
|
||||
import { UpdateMonitoringData } from "$lib/server/controllers/monitorsController";
|
||||
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
|
||||
import { SetLastMonitoringValue } from "$lib/server/cache/setGet";
|
||||
import alertingQueue from "$lib/server/queues/alertingQueue";
|
||||
|
||||
export const GET: RequestHandler = async ({ locals, url }) => {
|
||||
// Monitor is validated by middleware and available in locals
|
||||
@@ -169,6 +170,18 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
await SetLastMonitoringValue(monitorTag, latestData);
|
||||
}
|
||||
|
||||
// MANUAL samples are alert-visible (docs/adr/0005), so re-evaluate alerts once for the
|
||||
// last written sample — for NONE monitors nothing else would ever trigger evaluation.
|
||||
// UpdateMonitoringData floors both bounds to minute starts and writes through the floored
|
||||
// end inclusive, so the last stored row is always at GetMinuteStartTimestampUTC(end_ts).
|
||||
// Best-effort: the rows are already committed; a queue outage must not fail the request.
|
||||
const lastWrittenTs = GetMinuteStartTimestampUTC(body.end_ts);
|
||||
try {
|
||||
await alertingQueue.push(monitorTag, lastWrittenTs, body.status);
|
||||
} catch (err) {
|
||||
console.error(`Failed to enqueue alert evaluation for ${monitorTag} after MANUAL data write:`, err);
|
||||
}
|
||||
|
||||
// Calculate the number of data points that will be returned by GET
|
||||
// GET uses: timestamp >= start_ts AND timestamp < end_ts
|
||||
// Data is stored at minute-aligned timestamps
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import GC from "$lib/global-constants";
|
||||
import { GetMinuteStartTimestampUTC } from "$lib/server/tool";
|
||||
import { SetLastMonitoringValue } from "$lib/server/cache/setGet";
|
||||
import alertingQueue from "$lib/server/queues/alertingQueue";
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
// Monitor is validated by middleware and available in locals
|
||||
@@ -160,6 +161,15 @@ export const PATCH: RequestHandler = async ({ params, locals, request }) => {
|
||||
await SetLastMonitoringValue(monitorTag, latestData);
|
||||
}
|
||||
|
||||
// MANUAL samples are alert-visible (docs/adr/0005), so re-evaluate alerts for this
|
||||
// sample — for NONE monitors nothing else would ever trigger evaluation.
|
||||
// Best-effort: the row is already committed; a queue outage must not fail the request.
|
||||
try {
|
||||
await alertingQueue.push(monitorTag, timestamp, status);
|
||||
} catch (err) {
|
||||
console.error(`Failed to enqueue alert evaluation for ${monitorTag} after MANUAL data write:`, err);
|
||||
}
|
||||
|
||||
// Fetch the updated data
|
||||
const updatedData = await db.getMonitoringDataAt(monitorTag, timestamp);
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@ import db from "$lib/server/db/db";
|
||||
import type {
|
||||
GetPagesListResponse,
|
||||
PageResponse,
|
||||
PageSettings,
|
||||
CreatePageRequest,
|
||||
CreatePageResponse,
|
||||
BadRequestResponse,
|
||||
} from "$lib/types/api";
|
||||
import type { PageRecord } from "$lib/server/types/db";
|
||||
import GC from "$lib/global-constants";
|
||||
import { toApiPageSettings, applyPageSettingsPatch, validatePageSettings } from "$lib/server/pageSettings";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
if (date instanceof Date) {
|
||||
@@ -19,87 +20,15 @@ function formatDateToISO(date: Date | string): string {
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
function getDefaultPageSettings(): PageSettings {
|
||||
return {
|
||||
incidents: {
|
||||
enabled: true,
|
||||
ongoing: { show: true },
|
||||
resolved: { show: true, max_count: 5, days_in_past: 7 },
|
||||
},
|
||||
include_maintenances: {
|
||||
enabled: true,
|
||||
ongoing: {
|
||||
show: true,
|
||||
past: { show: true, max_count: 5, days_in_past: 7 },
|
||||
upcoming: { show: true, max_count: 5, days_in_future: 30 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergePageSettings(defaults: PageSettings, partial?: Partial<PageSettings>): PageSettings {
|
||||
if (!partial) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
return {
|
||||
incidents: {
|
||||
enabled: partial.incidents?.enabled ?? defaults.incidents.enabled,
|
||||
ongoing: {
|
||||
show: partial.incidents?.ongoing?.show ?? defaults.incidents.ongoing.show,
|
||||
},
|
||||
resolved: {
|
||||
show: partial.incidents?.resolved?.show ?? defaults.incidents.resolved.show,
|
||||
max_count: partial.incidents?.resolved?.max_count ?? defaults.incidents.resolved.max_count,
|
||||
days_in_past: partial.incidents?.resolved?.days_in_past ?? defaults.incidents.resolved.days_in_past,
|
||||
},
|
||||
},
|
||||
include_maintenances: {
|
||||
enabled: partial.include_maintenances?.enabled ?? defaults.include_maintenances.enabled,
|
||||
ongoing: {
|
||||
show: partial.include_maintenances?.ongoing?.show ?? defaults.include_maintenances.ongoing.show,
|
||||
past: {
|
||||
show: partial.include_maintenances?.ongoing?.past?.show ?? defaults.include_maintenances.ongoing.past.show,
|
||||
max_count:
|
||||
partial.include_maintenances?.ongoing?.past?.max_count ??
|
||||
defaults.include_maintenances.ongoing.past.max_count,
|
||||
days_in_past:
|
||||
partial.include_maintenances?.ongoing?.past?.days_in_past ??
|
||||
defaults.include_maintenances.ongoing.past.days_in_past,
|
||||
},
|
||||
upcoming: {
|
||||
show:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.show ??
|
||||
defaults.include_maintenances.ongoing.upcoming.show,
|
||||
max_count:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.max_count ??
|
||||
defaults.include_maintenances.ongoing.upcoming.max_count,
|
||||
days_in_future:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.days_in_future ??
|
||||
defaults.include_maintenances.ongoing.upcoming.days_in_future,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function formatPageResponse(page: PageRecord): Promise<PageResponse> {
|
||||
let pageSettings: PageSettings = getDefaultPageSettings();
|
||||
|
||||
if (page.page_settings_json) {
|
||||
try {
|
||||
const parsed = JSON.parse(page.page_settings_json);
|
||||
pageSettings = mergePageSettings(getDefaultPageSettings(), parsed);
|
||||
} catch {
|
||||
// Use defaults on parse error
|
||||
}
|
||||
}
|
||||
const pageSettings = toApiPageSettings(page.page_settings_json);
|
||||
|
||||
const pageMonitors = await db.getPageMonitors(page.id);
|
||||
|
||||
return {
|
||||
id: page.id,
|
||||
page_path: page.page_path,
|
||||
// The home page's empty page_path renders as the addressable ~home token
|
||||
page_path: page.page_path === "" ? GC.HOME_PAGE_TOKEN : page.page_path,
|
||||
page_title: page.page_title,
|
||||
page_header: page.page_header,
|
||||
page_subheader: page.page_subheader,
|
||||
@@ -204,8 +133,17 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare page settings
|
||||
const pageSettings = mergePageSettings(getDefaultPageSettings(), body.page_settings);
|
||||
// Validate page settings if provided
|
||||
const settingsError = validatePageSettings(body.page_settings);
|
||||
if (settingsError) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: {
|
||||
code: "BAD_REQUEST",
|
||||
message: settingsError,
|
||||
},
|
||||
};
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
|
||||
// Create the page
|
||||
const pageData = {
|
||||
@@ -214,7 +152,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
page_header: body.page_header.trim(),
|
||||
page_subheader: body.page_subheader ?? null,
|
||||
page_logo: body.page_logo ?? null,
|
||||
page_settings_json: JSON.stringify(pageSettings),
|
||||
page_settings_json: applyPageSettingsPatch(null, body.page_settings),
|
||||
};
|
||||
|
||||
const createdPage = await db.createPage(pageData);
|
||||
|
||||
@@ -3,7 +3,6 @@ import db from "$lib/server/db/db";
|
||||
import type {
|
||||
GetPageResponse,
|
||||
PageResponse,
|
||||
PageSettings,
|
||||
UpdatePageRequest,
|
||||
UpdatePageResponse,
|
||||
DeletePageResponse,
|
||||
@@ -11,6 +10,8 @@ import type {
|
||||
NotFoundResponse,
|
||||
} from "$lib/types/api";
|
||||
import type { PageRecord } from "$lib/server/types/db";
|
||||
import GC from "$lib/global-constants";
|
||||
import { toApiPageSettings, applyPageSettingsPatch, validatePageSettings } from "$lib/server/pageSettings";
|
||||
|
||||
function formatDateToISO(date: Date | string): string {
|
||||
if (date instanceof Date) {
|
||||
@@ -21,87 +22,15 @@ function formatDateToISO(date: Date | string): string {
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
function getDefaultPageSettings(): PageSettings {
|
||||
return {
|
||||
incidents: {
|
||||
enabled: true,
|
||||
ongoing: { show: true },
|
||||
resolved: { show: true, max_count: 5, days_in_past: 7 },
|
||||
},
|
||||
include_maintenances: {
|
||||
enabled: true,
|
||||
ongoing: {
|
||||
show: true,
|
||||
past: { show: true, max_count: 5, days_in_past: 7 },
|
||||
upcoming: { show: true, max_count: 5, days_in_future: 30 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergePageSettings(defaults: PageSettings, partial?: Partial<PageSettings>): PageSettings {
|
||||
if (!partial) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
return {
|
||||
incidents: {
|
||||
enabled: partial.incidents?.enabled ?? defaults.incidents.enabled,
|
||||
ongoing: {
|
||||
show: partial.incidents?.ongoing?.show ?? defaults.incidents.ongoing.show,
|
||||
},
|
||||
resolved: {
|
||||
show: partial.incidents?.resolved?.show ?? defaults.incidents.resolved.show,
|
||||
max_count: partial.incidents?.resolved?.max_count ?? defaults.incidents.resolved.max_count,
|
||||
days_in_past: partial.incidents?.resolved?.days_in_past ?? defaults.incidents.resolved.days_in_past,
|
||||
},
|
||||
},
|
||||
include_maintenances: {
|
||||
enabled: partial.include_maintenances?.enabled ?? defaults.include_maintenances.enabled,
|
||||
ongoing: {
|
||||
show: partial.include_maintenances?.ongoing?.show ?? defaults.include_maintenances.ongoing.show,
|
||||
past: {
|
||||
show: partial.include_maintenances?.ongoing?.past?.show ?? defaults.include_maintenances.ongoing.past.show,
|
||||
max_count:
|
||||
partial.include_maintenances?.ongoing?.past?.max_count ??
|
||||
defaults.include_maintenances.ongoing.past.max_count,
|
||||
days_in_past:
|
||||
partial.include_maintenances?.ongoing?.past?.days_in_past ??
|
||||
defaults.include_maintenances.ongoing.past.days_in_past,
|
||||
},
|
||||
upcoming: {
|
||||
show:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.show ??
|
||||
defaults.include_maintenances.ongoing.upcoming.show,
|
||||
max_count:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.max_count ??
|
||||
defaults.include_maintenances.ongoing.upcoming.max_count,
|
||||
days_in_future:
|
||||
partial.include_maintenances?.ongoing?.upcoming?.days_in_future ??
|
||||
defaults.include_maintenances.ongoing.upcoming.days_in_future,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function formatPageResponse(page: PageRecord): Promise<PageResponse> {
|
||||
let pageSettings: PageSettings = getDefaultPageSettings();
|
||||
|
||||
if (page.page_settings_json) {
|
||||
try {
|
||||
const parsed = JSON.parse(page.page_settings_json);
|
||||
pageSettings = mergePageSettings(getDefaultPageSettings(), parsed);
|
||||
} catch {
|
||||
// Use defaults on parse error
|
||||
}
|
||||
}
|
||||
const pageSettings = toApiPageSettings(page.page_settings_json);
|
||||
|
||||
const pageMonitors = await db.getPageMonitors(page.id);
|
||||
|
||||
return {
|
||||
id: page.id,
|
||||
page_path: page.page_path,
|
||||
// The home page's empty page_path renders as the addressable ~home token
|
||||
page_path: page.page_path === "" ? GC.HOME_PAGE_TOKEN : page.page_path,
|
||||
page_title: page.page_title,
|
||||
page_header: page.page_header,
|
||||
page_subheader: page.page_subheader,
|
||||
@@ -160,6 +89,12 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
|
||||
// API responses render the home page's path as ~home, so a read-modify-write
|
||||
// client sends it back unchanged; treat that as "no path change"
|
||||
if (page.page_path === "" && body.page_path === GC.HOME_PAGE_TOKEN) {
|
||||
body.page_path = undefined;
|
||||
}
|
||||
|
||||
// Validate page_path if provided
|
||||
if (body.page_path !== undefined) {
|
||||
if (typeof body.page_path !== "string") {
|
||||
@@ -179,6 +114,18 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9_-]/g, "");
|
||||
|
||||
// The home page's path is fixed; matches the manage UI which disables
|
||||
// the field with "Home page path cannot be changed"
|
||||
if (page.page_path === "" && sanitizedPagePath !== "") {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: {
|
||||
code: "BAD_REQUEST",
|
||||
message: "Home page path cannot be changed",
|
||||
},
|
||||
};
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if page_path is being changed and conflicts with existing page
|
||||
if (sanitizedPagePath !== page.page_path) {
|
||||
const existingPage = await db.getPageByPath(sanitizedPagePath);
|
||||
@@ -239,6 +186,18 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate page_settings if provided
|
||||
const settingsError = validatePageSettings(body.page_settings);
|
||||
if (settingsError) {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: {
|
||||
code: "BAD_REQUEST",
|
||||
message: settingsError,
|
||||
},
|
||||
};
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
|
||||
// Build update data
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const updateData: Record<string, any> = {};
|
||||
@@ -263,21 +222,9 @@ export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
updateData.page_logo = body.page_logo;
|
||||
}
|
||||
|
||||
// Handle page_settings merge
|
||||
// Handle page_settings merge; unknown stored keys are preserved
|
||||
if (body.page_settings !== undefined) {
|
||||
let currentSettings: PageSettings = getDefaultPageSettings();
|
||||
|
||||
if (page.page_settings_json) {
|
||||
try {
|
||||
const parsed = JSON.parse(page.page_settings_json);
|
||||
currentSettings = mergePageSettings(getDefaultPageSettings(), parsed);
|
||||
} catch {
|
||||
// Use defaults on parse error
|
||||
}
|
||||
}
|
||||
|
||||
const mergedSettings = mergePageSettings(currentSettings, body.page_settings);
|
||||
updateData.page_settings_json = JSON.stringify(mergedSettings);
|
||||
updateData.page_settings_json = applyPageSettingsPatch(page.page_settings_json, body.page_settings);
|
||||
}
|
||||
|
||||
// Update page if there are changes
|
||||
@@ -334,6 +281,18 @@ export const DELETE: RequestHandler = async ({ locals }) => {
|
||||
return json(errorResponse, { status: 404 });
|
||||
}
|
||||
|
||||
// The home page must always exist; DeletePage in pagesController enforces
|
||||
// the same invariant for the manage UI
|
||||
if (page.page_path === "") {
|
||||
const errorResponse: BadRequestResponse = {
|
||||
error: {
|
||||
code: "BAD_REQUEST",
|
||||
message: "Cannot delete the home page",
|
||||
},
|
||||
};
|
||||
return json(errorResponse, { status: 400 });
|
||||
}
|
||||
|
||||
// Delete all page monitors first
|
||||
await db.deletePageMonitorsByPageId(page.id);
|
||||
|
||||
@@ -341,7 +300,7 @@ export const DELETE: RequestHandler = async ({ locals }) => {
|
||||
await db.deletePage(page.id);
|
||||
|
||||
const response: DeletePageResponse = {
|
||||
message: `Page '${page.page_path}' deleted successfully`,
|
||||
message: `Page '${page.page_path || GC.HOME_PAGE_TOKEN}' deleted successfully`,
|
||||
};
|
||||
|
||||
return json(response);
|
||||
|
||||
@@ -19,10 +19,3 @@
|
||||
<main>
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
/* Apply the global font family using the CSS variable */
|
||||
* {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -81,6 +81,10 @@
|
||||
"title": "Overview",
|
||||
"content": "v4/monitors/overview"
|
||||
},
|
||||
{
|
||||
"title": "Grace Period",
|
||||
"content": "v4/monitors/grace-period"
|
||||
},
|
||||
{
|
||||
"title": "API Monitors",
|
||||
"content": "v4/monitors/api"
|
||||
@@ -303,6 +307,14 @@
|
||||
"group": "v4.x",
|
||||
"collapsible": false,
|
||||
"pages": [
|
||||
{
|
||||
"title": "v4.1.0",
|
||||
"content": "v4/changelogs/v4.1.0"
|
||||
},
|
||||
{
|
||||
"title": "v4.0.23",
|
||||
"content": "v4/changelogs/v4.0.23"
|
||||
},
|
||||
{
|
||||
"title": "v4.0.22",
|
||||
"content": "v4/changelogs/v4.0.22"
|
||||
|
||||
+518
-719
File diff suppressed because it is too large
Load Diff
@@ -223,9 +223,7 @@
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="rounded-none border-0 {tab.key === getActiveTabKey()
|
||||
? 'border-b-accent-foreground! border-b!'
|
||||
: ''}"
|
||||
class="rounded-none border-0 {tab.key === getActiveTabKey() ? 'border-b-primary! border-b!' : ''}"
|
||||
onclick={() => selectTab(tab)}
|
||||
>
|
||||
{tab.name}
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
|
||||
<style>
|
||||
.active {
|
||||
color: var(--accent-foreground);
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
type TickStatus = "up" | "degraded";
|
||||
|
||||
interface Tick {
|
||||
id: number;
|
||||
status: TickStatus;
|
||||
}
|
||||
|
||||
const WINDOW = 64;
|
||||
/** A degraded check still serves most requests; mirrors how Kener scores a degraded bucket. */
|
||||
const DEGRADED_VALUE = 97;
|
||||
/** Every CYCLE seconds the demo dips for two ticks, then recovers. */
|
||||
const CYCLE = 22;
|
||||
const DIP_AT = 14;
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
function seedTicks(): Tick[] {
|
||||
// Seed with one healed dip mid-history so the bar tells its story at first paint.
|
||||
return Array.from({ length: WINDOW }, (_, i) => ({
|
||||
id: nextId++,
|
||||
status: i === 22 || i === 23 ? "degraded" : "up"
|
||||
}));
|
||||
}
|
||||
|
||||
let ticks = $state<Tick[]>(seedTicks());
|
||||
let clock = $state(0);
|
||||
|
||||
const isDegraded = $derived(ticks[ticks.length - 1]?.status === "degraded");
|
||||
const uptime = $derived(
|
||||
(ticks.reduce((sum, t) => sum + (t.status === "up" ? 100 : DEGRADED_VALUE), 0) / ticks.length).toFixed(3)
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
if (reduceMotion.matches) return; // static seeded bar, no live march
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const tick = () => {
|
||||
clock += 1;
|
||||
const phase = clock % CYCLE;
|
||||
const status: TickStatus = phase === DIP_AT || phase === DIP_AT + 1 ? "degraded" : "up";
|
||||
ticks = [...ticks.slice(1), { id: nextId++, status }];
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (timer === undefined) timer = setInterval(tick, 1000);
|
||||
};
|
||||
const stop = () => {
|
||||
if (timer !== undefined) {
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Run only while visible: on-screen and tab focused.
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => (entry.isIntersecting && !document.hidden ? start() : stop()),
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
observer.observe(strip);
|
||||
|
||||
const onVisibility = () => (document.hidden ? stop() : start());
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
observer.disconnect();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
});
|
||||
|
||||
let strip: HTMLElement;
|
||||
</script>
|
||||
|
||||
<div class="demo-strip" bind:this={strip}>
|
||||
<div class="demo-head">
|
||||
<div class="demo-state" class:degraded={isDegraded}>
|
||||
<span class="demo-dot" aria-hidden="true"></span>
|
||||
<span class="demo-label">
|
||||
{isDegraded ? "Degraded performance" : "All systems operational"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="demo-uptime">
|
||||
<span class="demo-uptime-value">{uptime}%</span>
|
||||
<span class="demo-uptime-meta">uptime</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-bar" aria-hidden="true">
|
||||
{#each ticks as tick (tick.id)}
|
||||
<span class="demo-tick" class:degraded={tick.status === "degraded"}></span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="demo-meta" aria-hidden="true">
|
||||
<span>demo monitor · HTTP</span>
|
||||
<span>last {WINDOW} checks</span>
|
||||
</div>
|
||||
<p class="sr-only">Demo of a Kener monitor: a rolling uptime bar that records a check every second.</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.demo-strip {
|
||||
border: 1px solid color-mix(in oklch, var(--foreground) 10%, transparent);
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
background: var(--card);
|
||||
padding: 1.125rem 1.25rem 1rem;
|
||||
}
|
||||
|
||||
.demo-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
.demo-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.demo-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--demo-up);
|
||||
flex: none;
|
||||
transition: background 0.4s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.demo-dot {
|
||||
animation: demoPing 2.4s cubic-bezier(0.32, 0.72, 0, 1) infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes demoPing {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 color-mix(in oklch, var(--demo-up) 45%, transparent);
|
||||
}
|
||||
70%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 7px transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.demo-state.degraded .demo-dot {
|
||||
background: var(--primary);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.demo-label {
|
||||
font-family: "Geist Mono", ui-monospace, monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.demo-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
.demo-state.degraded .demo-label {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.demo-uptime {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.demo-uptime-value {
|
||||
font-family: "Geist Mono", ui-monospace, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.demo-uptime-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.demo-bar {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.demo-tick {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--demo-up);
|
||||
}
|
||||
|
||||
.demo-tick.degraded {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
/* Newest tick announces itself, then settles. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.demo-tick:last-child {
|
||||
animation: tickIn 0.5s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tickIn {
|
||||
from {
|
||||
transform: scaleY(0.4);
|
||||
opacity: 0.4;
|
||||
}
|
||||
to {
|
||||
transform: scaleY(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.demo-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-top: 0.75rem;
|
||||
font-family: "Geist Mono", ui-monospace, monospace;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Hide a third of the ticks on narrow screens so each tick keeps presence. */
|
||||
@media (max-width: 480px) {
|
||||
.demo-tick:nth-child(3n) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,281 +0,0 @@
|
||||
---
|
||||
title: Monitors API
|
||||
description: Create, read, update, and delete monitors via the REST API
|
||||
---
|
||||
|
||||
Create, read, update, and delete monitors via the API.
|
||||
|
||||
## List Monitors
|
||||
|
||||
Get all monitors with their current status.
|
||||
|
||||
```http
|
||||
GET /api/monitors
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------- | ------------------------------------ |
|
||||
| `page` | integer | Page number (default: 1) |
|
||||
| `limit` | integer | Items per page (default: 20) |
|
||||
| `status` | string | Filter by status: up, down, degraded |
|
||||
| `type` | string | Filter by type: API, PING, TCP, etc. |
|
||||
|
||||
### Example Request
|
||||
|
||||
```bash
|
||||
curl -X GET "https://your-kener.com/api/monitors?status=up&limit=10" \
|
||||
-H "Authorization: Bearer YOUR_API_KEY"
|
||||
```
|
||||
|
||||
### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"monitors": [
|
||||
{
|
||||
"id": "mon_abc123",
|
||||
"name": "API Server",
|
||||
"type": "API",
|
||||
"url": "https://api.example.com/health",
|
||||
"status": "up",
|
||||
"uptime": 99.95,
|
||||
"lastChecked": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"total": 25
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Get Monitor
|
||||
|
||||
Get details for a specific monitor.
|
||||
|
||||
```http
|
||||
GET /api/monitors/:id
|
||||
```
|
||||
|
||||
### Example Request
|
||||
|
||||
```bash
|
||||
curl -X GET https://your-kener.com/api/monitors/mon_abc123 \
|
||||
-H "Authorization: Bearer YOUR_API_KEY"
|
||||
```
|
||||
|
||||
### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "mon_abc123",
|
||||
"name": "API Server",
|
||||
"type": "API",
|
||||
"url": "https://api.example.com/health",
|
||||
"method": "GET",
|
||||
"expectedStatusCode": 200,
|
||||
"interval": 60,
|
||||
"timeout": 10000,
|
||||
"status": "up",
|
||||
"uptime": {
|
||||
"day": 100,
|
||||
"week": 99.95,
|
||||
"month": 99.87
|
||||
},
|
||||
"responseTime": {
|
||||
"current": 145,
|
||||
"average": 152
|
||||
},
|
||||
"lastChecked": "2024-01-15T10:30:00Z",
|
||||
"createdAt": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Create Monitor
|
||||
|
||||
Create a new monitor.
|
||||
|
||||
```http
|
||||
POST /api/monitors
|
||||
```
|
||||
|
||||
### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Production API",
|
||||
"type": "API",
|
||||
"url": "https://api.example.com/health",
|
||||
"method": "GET",
|
||||
"expectedStatusCode": 200,
|
||||
"interval": 60,
|
||||
"timeout": 10000,
|
||||
"headers": {
|
||||
"Authorization": "Bearer token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Monitor Types
|
||||
|
||||
#### API Monitor
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "API Health",
|
||||
"type": "API",
|
||||
"url": "https://api.example.com",
|
||||
"method": "GET",
|
||||
"expectedStatusCode": 200,
|
||||
"headers": {},
|
||||
"body": null
|
||||
}
|
||||
```
|
||||
|
||||
#### Ping Monitor
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Server Ping",
|
||||
"type": "PING",
|
||||
"host": "server.example.com"
|
||||
}
|
||||
```
|
||||
|
||||
#### TCP Monitor
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Database Port",
|
||||
"type": "TCP",
|
||||
"host": "db.example.com",
|
||||
"port": 5432
|
||||
}
|
||||
```
|
||||
|
||||
#### DNS Monitor
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "DNS Check",
|
||||
"type": "DNS",
|
||||
"host": "example.com",
|
||||
"recordType": "A"
|
||||
}
|
||||
```
|
||||
|
||||
### Example Request
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-kener.com/api/monitors \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Production API",
|
||||
"type": "API",
|
||||
"url": "https://api.example.com/health",
|
||||
"interval": 60
|
||||
}'
|
||||
```
|
||||
|
||||
### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "mon_xyz789",
|
||||
"name": "Production API",
|
||||
"type": "API",
|
||||
"status": "pending",
|
||||
"createdAt": "2024-01-15T10:30:00Z"
|
||||
},
|
||||
"message": "Monitor created successfully"
|
||||
}
|
||||
```
|
||||
|
||||
## Update Monitor
|
||||
|
||||
Update an existing monitor.
|
||||
|
||||
```http
|
||||
PUT /api/monitors/:id
|
||||
```
|
||||
|
||||
### Example Request
|
||||
|
||||
```bash
|
||||
curl -X PUT https://your-kener.com/api/monitors/mon_abc123 \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Updated API Name",
|
||||
"interval": 120
|
||||
}'
|
||||
```
|
||||
|
||||
## Delete Monitor
|
||||
|
||||
Delete a monitor.
|
||||
|
||||
```http
|
||||
DELETE /api/monitors/:id
|
||||
```
|
||||
|
||||
### Example Request
|
||||
|
||||
```bash
|
||||
curl -X DELETE https://your-kener.com/api/monitors/mon_abc123 \
|
||||
-H "Authorization: Bearer YOUR_API_KEY"
|
||||
```
|
||||
|
||||
## Monitor History
|
||||
|
||||
Get historical data for a monitor.
|
||||
|
||||
```http
|
||||
GET /api/monitors/:id/history
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| ------------ | ------ | ---------------------------------- |
|
||||
| `start` | string | Start date (ISO 8601) |
|
||||
| `end` | string | End date (ISO 8601) |
|
||||
| `resolution` | string | Data resolution: minute, hour, day |
|
||||
|
||||
### Example Request
|
||||
|
||||
```bash
|
||||
curl -X GET "https://your-kener.com/api/monitors/mon_abc123/history?resolution=hour" \
|
||||
-H "Authorization: Bearer YOUR_API_KEY"
|
||||
```
|
||||
|
||||
## Pause/Resume Monitor
|
||||
|
||||
```http
|
||||
POST /api/monitors/:id/pause
|
||||
POST /api/monitors/:id/resume
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
# Pause
|
||||
curl -X POST https://your-kener.com/api/monitors/mon_abc123/pause \
|
||||
-H "Authorization: Bearer YOUR_API_KEY"
|
||||
|
||||
# Resume
|
||||
curl -X POST https://your-kener.com/api/monitors/mon_abc123/resume \
|
||||
-H "Authorization: Bearer YOUR_API_KEY"
|
||||
```
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: v4.0.23 Changelog
|
||||
description: See what's new in Kener v4.0.23, including new features, improvements, and bug fixes
|
||||
---
|
||||
|
||||
## New features {#new-features}
|
||||
|
||||
### Role-based access control (RBAC) {#rbac}
|
||||
|
||||
Kener now uses a full RBAC system with roles, permissions, and user-role assignments. This replaces the previous single-role-per-user model with a flexible, permission-driven approach.
|
||||
|
||||
- **Permissions** follow a `domain.action` format (e.g. `monitors.read`, `incidents.write`). There are 30+ permissions covering all domains: monitors, incidents, maintenances, pages, triggers, alerts, API keys, users, settings, subscribers, email templates, images, and roles.
|
||||
- **Built-in roles** — `admin`, `editor`, and `member` — are seeded automatically and cannot be edited or deleted. Admin gets all permissions, editor gets all except `api_keys.delete`, and member gets read-only access.
|
||||
- **Custom roles** can be created, edited, deactivated, and deleted from the new **Manage → Roles** page. Permissions can be cloned from an existing role during creation.
|
||||
- **Multi-role assignment** — users can now be assigned multiple roles simultaneously. A user's effective permissions are the union of all their roles' permissions.
|
||||
- Permissions are enforced at both the **route level** (page access) and the **action level** (API operations).
|
||||
|
||||
New database tables: `roles`, `permissions`, `roles_permissions`, `users_roles`. Existing users are automatically migrated from the old `users.role` column to the new `users_roles` table.
|
||||
|
||||
See [User Management](/docs/v4/user-management) for full details.
|
||||
|
||||
### Roles management UI {#roles-management-ui}
|
||||
|
||||
A new **Manage → Roles** page provides full role administration:
|
||||
|
||||
- View all roles with their status and type (readonly or custom).
|
||||
- **Permissions panel** — toggle individual permissions grouped by domain. Readonly roles show permissions in read-only mode.
|
||||
- **Users panel** — view, add, and remove users assigned to each role.
|
||||
- **Duplicate role** — create a new role by cloning permissions from an existing one.
|
||||
- **Delete role** — choose to remove user assignments or migrate users to another role before deletion.
|
||||
|
||||
### Login role validation {#login-role-validation}
|
||||
|
||||
Users must have at least one active role to sign in. If a user's account exists but has no active roles assigned, login is blocked with a descriptive error message directing them to contact an administrator.
|
||||
|
||||
## Improvements {#improvements}
|
||||
|
||||
### Multi-role user invitations {#multi-role-invitations}
|
||||
|
||||
The **Add User** dialog now shows checkboxes for all active roles instead of a single role dropdown. At least one role must be selected when inviting a new user. All selected roles are validated to be active before the invitation is sent.
|
||||
|
||||
### Permission-based UI visibility {#permission-based-ui}
|
||||
|
||||
Sidebar navigation and action buttons throughout the manage dashboard are now driven by the current user's permissions. Pages and actions that the user lacks permission for are hidden rather than showing access-denied errors.
|
||||
|
||||
## Breaking changes {#breaking-changes}
|
||||
|
||||
### Vault page removed {#vault-removed}
|
||||
|
||||
The **Manage → Vault** page has been removed from the admin dashboard. The vault route and its associated permission (`vault`) have been dropped from the route permission map.
|
||||
|
||||
### User role column migration {#role-column-migration}
|
||||
|
||||
The `role` column on the `users` table is migrated to the `users_roles` junction table. A down migration re-creates the `role` column by backfilling from `users_roles` if you need to roll back. Existing user roles are preserved during the migration.
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
title: v4.1.0 Changelog
|
||||
description: See what's new in Kener v4.1.0, including new features, improvements, and bug fixes
|
||||
---
|
||||
|
||||
## New features {#new-features}
|
||||
|
||||
### Confirmation Threshold (grace period) {#confirmation-threshold}
|
||||
|
||||
Monitors can now require **N consecutive checks** before a status change is committed, so transient blips no longer dent uptime % or the day bars.
|
||||
|
||||
- Set a per-monitor **Grace period** (1–60, default **1 = off**) in **Monitor → General Settings**, or via `confirmation_threshold` on the v4 monitor create/update API.
|
||||
- Damping happens once in the scheduled-check write path. A new `monitoring_data.raw_status` column records what each check actually observed, while `status` holds the confirmed side.
|
||||
- On the Nth consecutive opposite observation the status flips and the pending window is **retroactively backfilled** — a confirmed outage reads as down from its first failing check, and recovery is symmetric.
|
||||
- Everything downstream — uptime %, day bars, badges, alert evaluation, group scoring — inherits the damped `status` automatically. Alerts compose by `max(grace period, failure threshold)`.
|
||||
|
||||
See [Grace Period](/docs/v4/monitors/grace-period) for full details.
|
||||
|
||||
### Manual maintenance event transitions {#maintenance-manual-transitions}
|
||||
|
||||
Individual maintenance events can now be **manually completed or cancelled** from the maintenance management UI, letting the recorded timeline reflect what actually happened.
|
||||
|
||||
- New **Complete** and **Cancel** actions move an event to a terminal `COMPLETED` or `CANCELLED` status and adjust `end_date_time` accordingly.
|
||||
- Terminal statuses are final — a completed or cancelled event can't be edited further.
|
||||
- The v4 API supports status transitions alongside window edits, with the two enforced as mutually exclusive.
|
||||
- Subscribers are notified of the transition.
|
||||
|
||||
See [Maintenance Events](/docs/v4/maintenances/events).
|
||||
|
||||
### Last known status for "None" monitors {#none-last-known-status}
|
||||
|
||||
The **None** monitor type can now optionally reuse the **last known status** (status, latency, and source) instead of reporting no data when no value is pushed. Enable it with the new checkbox in the monitor configuration UI.
|
||||
|
||||
### Page settings {#page-settings}
|
||||
|
||||
Status pages gained per-page presentation controls, configurable in the manage UI and over the v4 pages API:
|
||||
|
||||
- **Per-page status history** — separate day counts for **mobile** (default 30) and **desktop** (default 90), each bounded to 1–365.
|
||||
- **Monitor layout styles** — choose between `default-list`, `default-grid`, `compact-list`, and `compact-grid`.
|
||||
- **Meta / social overrides** — per-page title, description, and preview image.
|
||||
|
||||
Page settings accept deep-merge `PATCH` payloads over the API, and invalid payloads are now rejected with validation errors.
|
||||
|
||||
### Inline monitor toggles {#inline-monitor-toggles}
|
||||
|
||||
The **Manage → Monitors** list now lets you flip a monitor's **active/inactive** state and **visibility** inline, without opening the full edit page for a one-bit change.
|
||||
|
||||
### API: address the home page with `~home` {#api-home-token}
|
||||
|
||||
The home page is stored with an empty path and previously couldn't be addressed over the REST API. `GET`/`PATCH`/`DELETE /api/v4/pages/~home` now targets it, and list and single-page responses render its `page_path` as `~home` so what you read is exactly what you can address. `PATCH` accepts every field except the (fixed) home page path, and `DELETE` is rejected with a 400. Unmatched `/api/` routes now return JSON 404s instead of falling through to an HTML page.
|
||||
|
||||
### API: absolute `url` on incident and maintenance responses {#api-absolute-url}
|
||||
|
||||
v4 API responses now carry an absolute `url` field so consumers link to the right page instead of concatenating ids onto paths:
|
||||
|
||||
| Response | `url` |
|
||||
| :---------------------------------- | :------------------------------------------- |
|
||||
| Incident | `<site>/incidents/<id>` |
|
||||
| Maintenance (list/get/create/update) | `<site>/maintenances/<id>?type=maintenance` |
|
||||
| Maintenance event | `<site>/maintenances/<event_id>` |
|
||||
|
||||
The URL is built from the configured Site URL plus base path.
|
||||
|
||||
### API: delete a monitor by tag {#api-delete-monitor}
|
||||
|
||||
A new **`DELETE /api/v4/monitors/{monitor_tag}`** endpoint removes a monitor through the same path the manage UI uses. Monitor deletion now also explicitly cleans up alert-config rows (v2 alerts and the trigger/monitor junctions), fixing orphaned rows left behind on SQLite — which never enforced the `ON DELETE CASCADE` foreign keys. Shared alert configs survive until their last monitor is detached.
|
||||
|
||||
## Improvements {#improvements}
|
||||
|
||||
### Rich maintenance descriptions on list views {#maintenance-html-descriptions}
|
||||
|
||||
Maintenance descriptions now render as **sanitized HTML** on list views — the home page, custom pages, monitor pages, events pages, and embeds — matching the detail page. HTML formatting and line breaks display correctly instead of showing literal tags, with sanitization kept since these render to anonymous visitors.
|
||||
|
||||
### Searchable monitor picker for groups {#group-monitor-search}
|
||||
|
||||
The group monitor picker now has a **search bar**, making it practical to find and add monitors on instances with hundreds of them instead of scrolling a flat list.
|
||||
|
||||
### Database connection pool tuning and health checks {#db-pool-tuning}
|
||||
|
||||
The database layer now tunes connection-pool sizing, clamps the pool bounds to safe values, guards the Redis probe, and hardens the error page. The configured database type is logged at startup for clearer diagnostics.
|
||||
|
||||
### Broader alert evaluation {#alert-evaluation}
|
||||
|
||||
Consecutive status and latency checks now count **all alert-visible sample types**, improving alert reliability, and manually submitted data triggers a best-effort alert re-evaluation after updates.
|
||||
|
||||
### Localization {#localization}
|
||||
|
||||
- Added a **Ukrainian** translation.
|
||||
- Fixed syntax errors in the **French** locale strings.
|
||||
- Updated the default v4 **alert body** description, which still referenced pre-v4 template variables.
|
||||
|
||||
## Bug fixes {#bug-fixes}
|
||||
|
||||
- **Overall status now follows worst-wins ordering** (`DOWN > DEGRADED > MAINTENANCE > UP`) consistently across components, so an active maintenance no longer masks a real outage in the aggregated status.
|
||||
- **Social preview images use absolute URLs** — `og:image` and `twitter:image` meta tags now prepend the Site URL, fixing previews that failed because of relative paths.
|
||||
- **Fixed overlapping header sections** on the public page by adding a single frosted backdrop behind the fixed nav and the sticky theme bar, so page content no longer shows through while scrolling.
|
||||
@@ -196,7 +196,7 @@ status.yourdomain.com {
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Set up [Monitors](/docs/monitors)
|
||||
- Configure [Incidents](/docs/incidents)
|
||||
- Set up [Monitors](/docs/v4/monitors)
|
||||
- Configure [Incidents](/docs/v4/incidents)
|
||||
- Configure [Sharing Monitors](/docs/v4/sharing)
|
||||
- Explore the [API Reference](/docs/api-reference)
|
||||
- Explore the [API Reference](/docs/spec/v4/)
|
||||
|
||||
@@ -148,45 +148,62 @@ This provides complete context about:
|
||||
|
||||
When the alert resolves, a detailed closure update is added:
|
||||
|
||||
```markdown
|
||||
The alert has been resolved, Total duration: 47 minutes
|
||||
|
||||
#### Alert Details
|
||||
|
||||
title: Auto-Generated Incidents
|
||||
description: Quick reference for incident creation via alerting
|
||||
| Setting | Value |
|
||||
| :-------------------- | :---------- |
|
||||
| **Monitor Name** | Payment API |
|
||||
Kener can auto-create incidents from alert configurations.
|
||||
| **Monitor Tag** | payment-api |
|
||||
|
||||
## How it works {#how-it-works}
|
||||
|
||||
| **Alert Value** | DOWN |
|
||||
|
||||
1. Alert triggers.
|
||||
2. Alert has **Create Incident = YES**.
|
||||
3. Incident is created and monitor is attached.
|
||||
4. When alert resolves, incident is resolved automatically.
|
||||
| **Failure Threshold** | 3 |
|
||||
|
||||
## Where to configure {#where-to-configure}
|
||||
| **Monitor Name** | Payment API |
|
||||
| **Incident Status** | RESOLVED |
|
||||
| **Monitor Tag** | payment-api |
|
||||
| **Alert Type** | STATUS |
|
||||
| **Alert Value** | DOWN |
|
||||
| **Severity** | CRITICAL |
|
||||
| **Failure Threshold** | 3 |
|
||||
| **Success Threshold** | 5 |
|
||||
```
|
||||
|
||||
This includes:
|
||||
Use **Manage → Alerts → Alert Configurations**.
|
||||
|
||||
- Total incident duration
|
||||
|
||||
## Notes {#notes}
|
||||
|
||||
- Alert configuration
|
||||
- Resolution confirmation
|
||||
- Auto-generated incidents are ideal for critical, user-facing alerts.
|
||||
- Tune thresholds to avoid noisy incident creation.
|
||||
- Success threshold that was met
|
||||
|
||||
## See also {#see-also}
|
||||
## Configuration Requirements {#configuration-requirements}
|
||||
|
||||
- [Alert Configurations](/docs/v4/alerting/alert-configurations)
|
||||
- [Triggers](/docs/v4/alerting/triggers)
|
||||
- [Creating and Managing Incidents](/docs/v4/incidents/creating-managing)
|
||||
To enable auto-generated incidents:
|
||||
|
||||
### 1. Create Alert Configuration {#create-alert-config}
|
||||
|
||||
Navigate to **Manage > Alerts > Create Alert**
|
||||
|
||||
**Configure:**
|
||||
|
||||
- Monitor to watch
|
||||
- Alert type (STATUS, LATENCY, UPTIME)
|
||||
- Alert value (threshold)
|
||||
- Failure threshold
|
||||
- Success threshold
|
||||
- Severity (CRITICAL or WARNING)
|
||||
|
||||
**Enable Incident Creation:**
|
||||
|
||||
- Set **Create Incident** to **YES**
|
||||
|
||||
See [Alert Configurations](/docs/v4/alerting/alert-configurations) for complete details.
|
||||
|
||||
### 2. Configure Triggers (Optional) {#configure-triggers}
|
||||
|
||||
While triggers are optional for incident creation, they enable notifications:
|
||||
|
||||
**Create Triggers:**
|
||||
|
||||
- Discord
|
||||
- Slack
|
||||
- Email
|
||||
- Webhook
|
||||
|
||||
**Attach to Alert:**
|
||||
@@ -194,7 +211,7 @@ Use **Manage → Alerts → Alert Configurations**.
|
||||
- Select triggers when creating/editing alert
|
||||
- Triggers fire on both TRIGGERED and RESOLVED events
|
||||
|
||||
See [Triggers](/docs/alerting/triggers) for setup.
|
||||
See [Triggers](/docs/v4/alerting/triggers) for setup.
|
||||
|
||||
## Manual vs Auto-Generated {#manual-vs-auto}
|
||||
|
||||
@@ -289,11 +306,13 @@ A monitor can have multiple alert configurations:
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
Monitor: api-gateway
|
||||
|
||||
Alert 1: STATUS - DOWN (failure: 1)
|
||||
Alert 2: LATENCY - 1000ms (failure: 5)
|
||||
Alert 3: UPTIME - 99.9% (failure: 10)
|
||||
```
|
||||
|
||||
**Each Alert:**
|
||||
|
||||
@@ -330,7 +349,7 @@ When incident is auto-resolved:
|
||||
- Uses configured templates
|
||||
- Variable: `is_resolved = true`
|
||||
|
||||
See [Triggers](/docs/alerting/triggers) and [Templates](/docs/alerting/templates) for customization.
|
||||
See [Triggers](/docs/v4/alerting/triggers) and [Templates](/docs/v4/alerting/templates) for customization.
|
||||
|
||||
### Subscriber Notifications {#subscriber-notifications}
|
||||
|
||||
@@ -427,7 +446,7 @@ Every alert trigger creates an alert event log:
|
||||
3. Trigger credentials valid
|
||||
4. Check trigger logs for errors
|
||||
|
||||
See [Troubleshooting Triggers](/docs/alerting/triggers#troubleshooting-triggers) for detailed diagnosing.
|
||||
See [Troubleshooting Triggers](/docs/v4/alerting/triggers#troubleshooting-triggers) for detailed diagnosing.
|
||||
|
||||
## Best Practices {#best-practices}
|
||||
|
||||
@@ -465,11 +484,13 @@ See [Troubleshooting Triggers](/docs/alerting/triggers#troubleshooting-triggers)
|
||||
|
||||
**Workflow:**
|
||||
|
||||
```
|
||||
1. Alert triggers → Auto-creates incident
|
||||
2. You investigate → Add IDENTIFIED update
|
||||
3. You deploy fix → Add MONITORING update
|
||||
4. Alert resolves → Auto-adds RESOLVED update
|
||||
5. You add post-mortem → Add final manual update
|
||||
```
|
||||
|
||||
### Threshold Tuning {#threshold-tuning}
|
||||
|
||||
@@ -493,7 +514,7 @@ See [Troubleshooting Triggers](/docs/alerting/triggers#troubleshooting-triggers)
|
||||
|
||||
## Next Steps {#next-steps}
|
||||
|
||||
- [Alert Configurations](/docs/alerting/alert-configurations) - Set up alerts that create incidents
|
||||
- [Triggers](/docs/alerting/triggers) - Configure notification channels
|
||||
- [Incident Updates](/docs/incidents/updates) - Add manual updates to auto-generated incidents
|
||||
- [Incident Overview](/docs/incidents/overview) - Understand incident basics
|
||||
- [Alert Configurations](/docs/v4/alerting/alert-configurations) - Set up alerts that create incidents
|
||||
- [Triggers](/docs/v4/alerting/triggers) - Configure notification channels
|
||||
- [Incident Updates](/docs/v4/incidents/updates) - Add manual updates to auto-generated incidents
|
||||
- [Incident Overview](/docs/v4/incidents/overview) - Understand incident basics
|
||||
|
||||
@@ -60,32 +60,69 @@ The incident state after this update. This is **crucial** because:
|
||||
- State progression drives the incident lifecycle
|
||||
|
||||
**Available States:**
|
||||
title: Incident Updates
|
||||
description: Quick reference for posting timeline updates on incidents
|
||||
|
||||
- **INVESTIGATING** - Initial investigation
|
||||
- **IDENTIFIED** - Root cause found
|
||||
- **MONITORING** - Fix applied, watching for stability
|
||||
Incident updates are timeline entries used to communicate progress and move incident state.
|
||||
- **RESOLVED** - Issue fully resolved
|
||||
|
||||
## Quick reference {#quick-reference}
|
||||
**Important:** When you set state to **RESOLVED**, Kener automatically sets the incident's `end_date_time` to the update's timestamp.
|
||||
|
||||
When posting an update, choose one state:
|
||||
### Timestamp (Required) {#timestamp}
|
||||
|
||||
- `INVESTIGATING`
|
||||
- `IDENTIFIED`
|
||||
- `MONITORING`
|
||||
- `RESOLVED`
|
||||
When this update was made.
|
||||
|
||||
Setting `RESOLVED` closes the incident and sets end time.
|
||||
**Default:** Current date and time
|
||||
|
||||
**Can Be Modified:**
|
||||
|
||||
- Useful for backdating updates
|
||||
- Aligning timeline with actual events
|
||||
Use concise, user-facing text and include only meaningful changes.
|
||||
- Recording updates made after the fact
|
||||
|
||||
## See also {#see-also}
|
||||
**Display:** Shown in your local timezone but stored as UTC
|
||||
|
||||
- [Creating and Managing Incidents](/docs/v4/incidents/creating-managing)
|
||||
- [Impact on Monitoring](/docs/v4/incidents/impact-on-monitoring)
|
||||
**Important State Changes:**
|
||||
## Creating Updates {#creating-updates}
|
||||
|
||||
### During Incident Creation {#during-creation}
|
||||
|
||||
When creating a new incident, you can optionally provide an **Initial Update**:
|
||||
|
||||
1. Fill out the incident details
|
||||
2. Add text in the "Initial Update" field
|
||||
3. Uses Markdown formatting
|
||||
4. Automatically created with state "INVESTIGATING"
|
||||
5. Timestamp matches incident start time
|
||||
|
||||
### Adding Updates to Existing Incidents {#adding-updates}
|
||||
|
||||
1. Navigate to the incident detail page
|
||||
2. Click **Add Update** button
|
||||
3. Enter your message (Markdown supported)
|
||||
4. Select the new state
|
||||
5. Optionally adjust the timestamp
|
||||
6. Click **Add Update**
|
||||
|
||||
**What Happens:**
|
||||
|
||||
- Update is added to the incident timeline
|
||||
- Incident state changes to the update's state
|
||||
- If state is RESOLVED, end_date_time is set
|
||||
- Update appears immediately on status page
|
||||
- Subscribers receive notifications (if configured)
|
||||
|
||||
## Editing Updates {#editing-updates}
|
||||
|
||||
You can edit any existing update:
|
||||
|
||||
1. Find the update in the incident timeline
|
||||
2. Click the **Edit** (pencil icon) button
|
||||
3. Modify the message
|
||||
4. Change the state if needed
|
||||
5. Adjust the timestamp if needed
|
||||
6. Click **Save**
|
||||
|
||||
**Important State Changes:**
|
||||
|
||||
**Moving to RESOLVED:**
|
||||
|
||||
@@ -401,6 +438,6 @@ See [Subscription documentation](/docs/v4/subscriptions) for setup.
|
||||
|
||||
## Next Steps {#next-steps}
|
||||
|
||||
- [Incident Impact on Monitoring](/docs/incidents/impact-on-monitoring) - How incident state affects monitor status
|
||||
- [Creating and Managing Incidents](/docs/incidents/creating-managing) - Back to incident management basics
|
||||
- [Auto-Generated Incidents](/docs/incidents/auto-generated) - How alerts create and update incidents automatically
|
||||
- [Incident Impact on Monitoring](/docs/v4/incidents/impact-on-monitoring) - How incident state affects monitor status
|
||||
- [Creating and Managing Incidents](/docs/v4/incidents/creating-managing) - Back to incident management basics
|
||||
- [Auto-Generated Incidents](/docs/v4/incidents/auto-generated) - How alerts create and update incidents automatically
|
||||
|
||||
@@ -491,7 +491,7 @@ Add buffer time to your estimates:
|
||||
|
||||
- Ensure BYDAY is set for weekly frequency
|
||||
- Check interval is positive number
|
||||
- Verify RRULE syntax (see [RRULE Patterns](/docs/maintenances/rrule-patterns))
|
||||
- Verify RRULE syntax (see [RRULE Patterns](/docs/v4/maintenances/rrule-patterns))
|
||||
|
||||
**Problem:** Events not generated for recurring maintenance
|
||||
|
||||
@@ -511,6 +511,6 @@ Add buffer time to your estimates:
|
||||
|
||||
## Next Steps {#next-steps}
|
||||
|
||||
- [Maintenance Events](/docs/maintenances/events) - Learn about event lifecycle and automatic transitions
|
||||
- [Maintenance Impact on Monitoring](/docs/maintenances/impact-on-monitoring) - How maintenances affect monitor status display
|
||||
- [RRULE Patterns](/docs/maintenances/rrule-patterns) - Advanced scheduling patterns and examples
|
||||
- [Maintenance Events](/docs/v4/maintenances/events) - Learn about event lifecycle and automatic transitions
|
||||
- [Maintenance Impact on Monitoring](/docs/v4/maintenances/impact-on-monitoring) - How maintenances affect monitor status display
|
||||
- [RRULE Patterns](/docs/v4/maintenances/rrule-patterns) - Advanced scheduling patterns and examples
|
||||
|
||||
@@ -179,63 +179,66 @@ Action: Override monitor statuses
|
||||
|
||||
```
|
||||
READY → ONGOING
|
||||
title: Maintenance Events
|
||||
description: How maintenance events are generated and shown to users
|
||||
Condition: current_time >= start_time AND current_time < end_time
|
||||
Executed by: Status update scheduler (runs every minute)
|
||||
```
|
||||
|
||||
A maintenance event is one occurrence of a maintenance window.
|
||||
#### COMPLETED {#completed-state}
|
||||
|
||||
## Event generation {#event-generation}
|
||||
**When:** Current time is past end time, or an admin completes an ONGOING event early
|
||||
|
||||
### One-time maintenance {#one-time-maintenance}
|
||||
**Meaning:**
|
||||
|
||||
- Creates one event.
|
||||
- Triggered when maintenance is created.
|
||||
- Maintenance has finished
|
||||
- Monitor statuses restored to realtime values
|
||||
- Historical record
|
||||
|
||||
### Recurring maintenance {#recurring-maintenance}
|
||||
**Displayed As:** "Completed"
|
||||
|
||||
- Creates upcoming events from RRULE.
|
||||
- Scheduler refreshes upcoming occurrences.
|
||||
- Duplicate event start times are skipped.
|
||||
**Notification:** "Maintenance Completed" notification sent
|
||||
|
||||
## Event statuses {#event-statuses}
|
||||
**Example:**
|
||||
|
||||
- `SCHEDULED`
|
||||
- `READY`
|
||||
- `ONGOING`
|
||||
- `COMPLETED`
|
||||
- `CANCELLED`
|
||||
|
||||
Status transitions are time-based and automatic.
|
||||
```
|
||||
Current Time: May 15, 4:05 PM
|
||||
|
||||
## User-visible behavior {#user-visible-behavior}
|
||||
|
||||
Event End: May 15, 4:00 PM
|
||||
Status: COMPLETED (finished 5 minutes ago)
|
||||
|
||||
- Ongoing events affect monitor display according to impact settings.
|
||||
- Upcoming and past visibility depends on site/page event display settings.
|
||||
|
||||
Action: Restore monitor statuses
|
||||
```
|
||||
## Manual actions {#manual-actions}
|
||||
|
||||
**Automatic Transition:**
|
||||
- You can cancel/delete events from maintenance management screens.
|
||||
- Edit the parent maintenance to regenerate future schedule behavior.
|
||||
|
||||
```
|
||||
ONGOING → COMPLETED
|
||||
Condition: current_time >= end_time
|
||||
Executed by: Status update scheduler (runs every minute)
|
||||
```
|
||||
|
||||
## Related guides {#related-guides}
|
||||
**Manual Completion:**
|
||||
|
||||
Condition: current_time >= end_time
|
||||
An ONGOING event can be completed early from the maintenance edit page or via the API (see [Completing an Event Early](#completing-events)). Its end time is moved to the moment it was completed, so the recorded window reflects what actually happened.
|
||||
|
||||
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing)
|
||||
- [Impact on Monitoring](/docs/v4/maintenances/impact-on-monitoring)
|
||||
- [RRULE Patterns](/docs/v4/maintenances/rrule-patterns)
|
||||
**Displayed As:** "Cancelled"
|
||||
#### CANCELLED {#cancelled-state}
|
||||
|
||||
**Notification:** No automatic notification
|
||||
**When:** Manually cancelled by an admin (allowed for SCHEDULED, READY, and ONGOING events)
|
||||
|
||||
**Manual Action:** User clicks delete/cancel on event
|
||||
**Meaning:**
|
||||
|
||||
- The occurrence was called off — before or during its window
|
||||
- Monitor statuses are not (or no longer) overridden
|
||||
- Kept in history, explicitly marked as cancelled vs completed
|
||||
|
||||
**Time Handling:**
|
||||
|
||||
- Cancelled before start: the planned window is kept unchanged
|
||||
- Cancelled while ONGOING: the end time is moved to the moment of cancellation
|
||||
|
||||
**Displayed As:** "Cancelled"
|
||||
|
||||
**Notification:** "Maintenance Cancelled" notification sent (controlled by the same "ended" notification setting as completion)
|
||||
|
||||
> [!NOTE]
|
||||
> COMPLETED and CANCELLED are terminal — an event cannot be moved out of them. For recurring maintenances, a cancelled occurrence is never regenerated, so cancelling is how you skip one occurrence while keeping the schedule.
|
||||
|
||||
## Automatic Status Transitions {#automatic-transitions}
|
||||
|
||||
@@ -323,9 +326,20 @@ When: At start time
|
||||
Subject: Maintenance Completed
|
||||
Body: "{title} has been completed"
|
||||
Monitors: List of affected monitors with impacts
|
||||
When: At end time
|
||||
When: At end time, or immediately when completed early
|
||||
```
|
||||
|
||||
**Cancelled (manual cancellation):**
|
||||
|
||||
```
|
||||
Subject: Maintenance Cancelled
|
||||
Body: "{title} has been cancelled"
|
||||
Monitors: List of affected monitors with impacts
|
||||
When: Immediately when an admin cancels the event
|
||||
```
|
||||
|
||||
Completed and Cancelled notifications share the same "ended" notification setting.
|
||||
|
||||
### Notification Channels {#notification-channels}
|
||||
|
||||
Notifications are sent based on your subscription configuration:
|
||||
@@ -390,22 +404,35 @@ Users see events on the public status page:
|
||||
|
||||
## Managing Events Manually {#managing-events-manually}
|
||||
|
||||
### Completing an Event Early {#completing-events}
|
||||
|
||||
If the maintenance work finishes before the scheduled end:
|
||||
|
||||
1. Find the ONGOING event on the maintenance edit page
|
||||
2. Click **Complete**
|
||||
3. Confirm
|
||||
|
||||
The event moves to COMPLETED, its end time is set to the current time, monitor statuses return to realtime values, and the completion notification is sent.
|
||||
|
||||
Via the API: `PATCH /api/v4/maintenances/{maintenance_id}/events/{event_id}` with body `{"status": "COMPLETED"}`.
|
||||
|
||||
### Cancelling Events {#cancelling-events}
|
||||
|
||||
From the maintenance edit page:
|
||||
|
||||
1. Find the event in the list
|
||||
2. Click the trash icon
|
||||
3. Confirm cancellation
|
||||
4. Event status changes to CANCELLED
|
||||
1. Find the event in the list (SCHEDULED, READY, or ONGOING)
|
||||
2. Click **Cancel**
|
||||
3. Confirm
|
||||
|
||||
Via the API: `PATCH /api/v4/maintenances/{maintenance_id}/events/{event_id}` with body `{"status": "CANCELLED"}`.
|
||||
|
||||
**When to Cancel:**
|
||||
|
||||
- Maintenance no longer needed
|
||||
- Rescheduling to different time
|
||||
- Discovered conflict
|
||||
- Maintenance was aborted partway through
|
||||
- Skipping one occurrence of a recurring maintenance
|
||||
|
||||
**Note:** Cancelled events remain in history but are not executed.
|
||||
**Note:** Cancelled events remain in history but are not executed. A cancelled occurrence of a recurring maintenance is never regenerated.
|
||||
|
||||
### Deleting Events {#deleting-events}
|
||||
|
||||
@@ -425,9 +452,9 @@ Events can be permanently deleted:
|
||||
|
||||
**Best Practice:** Cancel rather than delete to preserve history.
|
||||
|
||||
### Cannot Edit Events {#cannot-edit-events}
|
||||
### Editing Event Times {#cannot-edit-events}
|
||||
|
||||
Individual events cannot be edited directly. To change event timing or duration:
|
||||
Individual event times cannot be edited from the dashboard. To change event timing or duration:
|
||||
|
||||
1. Edit the parent maintenance
|
||||
2. Update start time, RRULE, or duration
|
||||
@@ -437,7 +464,9 @@ Individual events cannot be edited directly. To change event timing or duration:
|
||||
|
||||
- Future SCHEDULED events deleted
|
||||
- New events generated with updated settings
|
||||
- ONGOING/COMPLETED events preserved
|
||||
- ONGOING/COMPLETED/CANCELLED events preserved
|
||||
|
||||
API consumers can edit a single event's window directly: `PATCH /api/v4/maintenances/{maintenance_id}/events/{event_id}` with both `start_date_time` and `end_date_time`. A request cannot combine time fields with a `status` transition.
|
||||
|
||||
## Event Retention {#event-retention}
|
||||
|
||||
@@ -472,7 +501,7 @@ During ONGOING events:
|
||||
- Realtime monitoring continues in background
|
||||
- Status page shows maintenance impact
|
||||
|
||||
See [Maintenance Impact on Monitoring](/docs/maintenances/impact-on-monitoring) for details.
|
||||
See [Maintenance Impact on Monitoring](/docs/v4/maintenances/impact-on-monitoring) for details.
|
||||
|
||||
## Troubleshooting {#troubleshooting}
|
||||
|
||||
@@ -511,6 +540,6 @@ See [Maintenance Impact on Monitoring](/docs/maintenances/impact-on-monitoring)
|
||||
|
||||
## Next Steps {#next-steps}
|
||||
|
||||
- [Maintenance Impact on Monitoring](/docs/maintenances/impact-on-monitoring) - How events affect monitor status display
|
||||
- [RRULE Patterns](/docs/maintenances/rrule-patterns) - Advanced scheduling patterns for recurring maintenances
|
||||
- [Creating and Managing Maintenances](/docs/maintenances/creating-managing) - Learn how to create and edit maintenances
|
||||
- [Maintenance Impact on Monitoring](/docs/v4/maintenances/impact-on-monitoring) - How events affect monitor status display
|
||||
- [RRULE Patterns](/docs/v4/maintenances/rrule-patterns) - Advanced scheduling patterns for recurring maintenances
|
||||
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing) - Learn how to create and edit maintenances
|
||||
|
||||
@@ -66,63 +66,79 @@ Displayed Status: MAINTENANCE
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
Monitor: Database
|
||||
Realtime Status: UP (checks passing now)
|
||||
Incident: OPEN
|
||||
Monitor Impact: DOWN
|
||||
|
||||
Displayed Status: DOWN
|
||||
title: Impact on Monitoring
|
||||
description: How maintenance events affect displayed monitor status
|
||||
```
|
||||
|
||||
**Rationale:** Incident status takes precedence over current checks during active issues.
|
||||
|
||||
During an **ONGOING** maintenance event, maintenance impact can override monitor status shown to users.
|
||||
**Note:** Maintenance status takes precedence over incident status. If both exist, maintenance wins.
|
||||
|
||||
## Status precedence {#status-precedence}
|
||||
#### 3. Realtime Monitoring Data {#realtime-priority}
|
||||
|
||||
Kener resolves status in this order (later overrides earlier):
|
||||
**When:** No maintenance or incident affecting this monitor
|
||||
|
||||
`default status → realtime monitor result → incident impact → maintenance impact`
|
||||
**Display:** Show latest monitoring check result (UP, DOWN, DEGRADED)
|
||||
|
||||
So maintenance has the highest effective priority when active.
|
||||
|
||||
## Impact values {#impact-values}
|
||||
**Example:**
|
||||
|
||||
```
|
||||
Monitor: Web Server
|
||||
Set per monitor in a maintenance:
|
||||
Latest Check: DOWN (connection timeout)
|
||||
No Maintenance: ✓
|
||||
No Incident: ✓
|
||||
|
||||
- `MAINTENANCE` (recommended)
|
||||
- `DOWN`
|
||||
- `DEGRADED`
|
||||
- `UP`
|
||||
Displayed Status: DOWN
|
||||
```
|
||||
|
||||
Choose the value that matches expected user impact during the window.
|
||||
#### 4. Default Monitor Status (Lowest Priority) {#default-priority}
|
||||
|
||||
## Event lifecycle behavior {#event-lifecycle-behavior}
|
||||
**When:** No monitoring data exists yet
|
||||
|
||||
- `SCHEDULED` / `READY`: no override yet
|
||||
- `ONGOING`: override active
|
||||
- `COMPLETED` / `CANCELLED`: override removed
|
||||
**Display:** Monitor's configured default status
|
||||
|
||||
## Realtime monitoring still runs {#realtime-monitoring-still-runs}
|
||||
**Example:**
|
||||
|
||||
```
|
||||
Monitor: New Service
|
||||
Even during maintenance, checks continue and data is recorded.
|
||||
No Checks Run: (just created)
|
||||
Default Status: UP
|
||||
Maintenance changes **displayed/effective** status, not monitor execution.
|
||||
|
||||
Displayed Status: UP
|
||||
```
|
||||
|
||||
## Practical guidance {#practical-guidance}
|
||||
## Monitor Impact Levels {#impact-levels}
|
||||
|
||||
- Prefer `MAINTENANCE` for planned work communication.
|
||||
- Use `DOWN` only when service is expected to be unavailable.
|
||||
- Avoid overlapping maintenances on the same monitor.
|
||||
When configuring a maintenance, you specify the impact for each affected monitor:
|
||||
|
||||
## Related guides {#related-guides}
|
||||
### MAINTENANCE {#maintenance-impact}
|
||||
|
||||
- [Maintenances Overview](/docs/v4/maintenances/overview)
|
||||
- [Maintenance Events](/docs/v4/maintenances/events)
|
||||
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing)
|
||||
**Visual:** Orange/yellow, wrench icon
|
||||
|
||||
**Meaning:** Service is under planned maintenance
|
||||
|
||||
**When to Use:**
|
||||
|
||||
- General maintenance work
|
||||
- Service available but under maintenance
|
||||
- Default/recommended choice
|
||||
|
||||
**User Interpretation:** "Service may be affected due to planned work"
|
||||
|
||||
**Example:**
|
||||
|
||||
```yaml
|
||||
Monitor: API Server
|
||||
Impact: MAINTENANCE
|
||||
During Event: Shows orange "Under Maintenance"
|
||||
```
|
||||
|
||||
### DOWN {#down-impact}
|
||||
|
||||
**Visual:** Red, X icon
|
||||
|
||||
@@ -514,6 +530,6 @@ Description: "Database will be completely offline during this window"
|
||||
|
||||
## Next Steps {#next-steps}
|
||||
|
||||
- [Maintenance Events](/docs/maintenances/events) - Learn about event lifecycle and status transitions
|
||||
- [Creating and Managing Maintenances](/docs/maintenances/creating-managing) - How to configure monitor impacts
|
||||
- [RRULE Patterns](/docs/maintenances/rrule-patterns) - Advanced scheduling patterns
|
||||
- [Maintenance Events](/docs/v4/maintenances/events) - Learn about event lifecycle and status transitions
|
||||
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing) - How to configure monitor impacts
|
||||
- [RRULE Patterns](/docs/v4/maintenances/rrule-patterns) - Advanced scheduling patterns
|
||||
|
||||
@@ -90,75 +90,92 @@ Kener uses the industry-standard [iCalendar RRULE](http://www.kanzaki.com/docs/i
|
||||
FREQ=frequency;[INTERVAL=n;][BYDAY=days;][COUNT=n;]
|
||||
```
|
||||
|
||||
title: Maintenances Overview
|
||||
description: Plan and communicate scheduled service work with one-time or recurring maintenance windows
|
||||
| Pattern | RRULE | Meaning |
|
||||
**Example Patterns:**
|
||||
|
||||
| Pattern | RRULE | Meaning |
|
||||
| :---------------------- | :--------------------------------- | :-------------------- |
|
||||
Maintenances are planned service windows. Use them to communicate expected downtime or degradation before work starts.
|
||||
| Every 2 weeks on Monday | `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO` | Bi-weekly on Monday |
|
||||
| Every Sunday | `FREQ=WEEKLY;BYDAY=SU` | Weekly on Sunday |
|
||||
| Every 2 weeks on Monday | `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO` | Bi-weekly on Monday |
|
||||
| Every day | `FREQ=DAILY` | Daily |
|
||||
| First of each month | `FREQ=MONTHLY;BYMONTHDAY=1` | Monthly on day 1 |
|
||||
| Weekdays only | `FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR` | Monday through Friday |
|
||||
|
||||
## What a maintenance includes {#what-a-maintenance-includes}
|
||||
Learn more in [RRULE Patterns](/docs/v4/maintenances/rrule-patterns).
|
||||
|
||||
| First of each month | `FREQ=MONTHLY;BYMONTHDAY=1` | Monthly on day 1 |
|
||||
Each maintenance has:
|
||||
## Maintenance Events {#maintenance-events}
|
||||
|
||||
When you create a maintenance, Kener automatically generates **maintenance events** based on the RRULE:
|
||||
|
||||
- Title and optional description
|
||||
- Start time
|
||||
- Duration
|
||||
- Schedule (`one-time` or recurring `RRULE`)
|
||||
- Affected monitors with impact
|
||||
- Status (`ACTIVE` / `INACTIVE`)
|
||||
- **One-Time Maintenances:** Generate 1 event at creation time
|
||||
- **Recurring Maintenances:** Generate events for the next 7 days, refreshed hourly
|
||||
|
||||
## Maintenance vs incident {#maintenance-vs-incident}
|
||||
Each event represents a single occurrence of the maintenance window and tracks:
|
||||
|
||||
| Aspect | Maintenance | Incident |
|
||||
| ---------- | --------------------------- | ----------------------------- |
|
||||
| Nature | Planned | Unplanned |
|
||||
| Timing | Scheduled in advance | Created when issue occurs |
|
||||
| Recurrence | Can recur with RRULE | Typically one-off |
|
||||
| Purpose | Communicate expected impact | Communicate active disruption |
|
||||
- Start date/time
|
||||
- End date/time (calculated from duration)
|
||||
- Status (SCHEDULED → READY → ONGOING → COMPLETED, or CANCELLED)
|
||||
|
||||
## One-time vs recurring {#one-time-vs-recurring}
|
||||
Events are covered in detail in [Maintenance Events](/docs/v4/maintenances/events).
|
||||
|
||||
## Maintenance Status {#maintenance-status}
|
||||
|
||||
Maintenances have two levels of status:
|
||||
|
||||
- **One-time**: single event (`FREQ=MINUTELY;COUNT=1`)
|
||||
- **Recurring**: repeated events from RRULE (for example weekly/monthly patterns)
|
||||
|
||||
### Maintenance-Level Status {#maintenance-level-status}
|
||||
|
||||
## Monitor impact during maintenance {#monitor-impact-during-maintenance}
|
||||
|
||||
Controls whether the maintenance is active in the system:
|
||||
Set per-monitor impact for the maintenance window:
|
||||
|
||||
- **ACTIVE** - Maintenance is enabled and will generate events
|
||||
- `MAINTENANCE` (recommended for planned work)
|
||||
- `DOWN`
|
||||
- `DEGRADED`
|
||||
- `UP` (rare)
|
||||
- **INACTIVE** - Maintenance is disabled and will not affect monitors
|
||||
|
||||
When an event is ongoing, this impact can override realtime status shown to users.
|
||||
**Note:** Changing a maintenance to INACTIVE does not cancel already scheduled events. You must manually cancel or delete those events.
|
||||
|
||||
## Event lifecycle {#event-lifecycle}
|
||||
### Event-Level Status {#event-level-status}
|
||||
|
||||
Each occurrence is a maintenance event that moves through statuses:
|
||||
Tracks the lifecycle of each individual maintenance occurrence:
|
||||
|
||||
- `SCHEDULED`
|
||||
- `READY` (starting soon)
|
||||
- `ONGOING`
|
||||
- `COMPLETED`
|
||||
- `CANCELLED`
|
||||
- **SCHEDULED** - Event created, more than 60 minutes away
|
||||
- **READY** - Event starts within 60 minutes (notification sent)
|
||||
|
||||
## Related guides {#related-guides}
|
||||
|
||||
- **ONGOING** - Event is currently in progress
|
||||
- **COMPLETED** - Event has finished
|
||||
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing)
|
||||
- [Maintenance Events](/docs/v4/maintenances/events)
|
||||
- [Impact on Monitoring](/docs/v4/maintenances/impact-on-monitoring)
|
||||
- [RRULE Patterns](/docs/v4/maintenances/rrule-patterns)
|
||||
- **CANCELLED** - Event was manually cancelled
|
||||
|
||||
Events automatically transition through states based on the current time. An ONGOING event can also be completed early, and SCHEDULED/READY/ONGOING events can be cancelled — see [Managing Events Manually](/docs/v4/maintenances/events#managing-events-manually).
|
||||
|
||||
## Affected Monitors {#affected-monitors}
|
||||
|
||||
Each maintenance specifies which monitors are affected and their expected status during the maintenance window:
|
||||
|
||||
**Monitor Impact Options:**
|
||||
|
||||
- **MAINTENANCE** - Show as under maintenance (recommended)
|
||||
- **DOWN** - Show as completely unavailable
|
||||
- **DEGRADED** - Show as partially available
|
||||
- **UP** - Show as operational (rare, for non-disruptive maintenance)
|
||||
|
||||
When a maintenance event is ONGOING, the specified impact **overrides** the monitor's realtime status on the status page.
|
||||
|
||||
Learn more in [Maintenance Impact on Monitoring](/docs/v4/maintenances/impact-on-monitoring).
|
||||
|
||||
## Public Visibility {#public-visibility}
|
||||
|
||||
Maintenances are visible to users on your public status page:
|
||||
|
||||
### Upcoming Maintenances {#upcoming-maintenances}
|
||||
|
||||
- Shown on the home page
|
||||
- Displays upcoming events (configurable days ahead)
|
||||
- Shows affected monitors and maintenance window
|
||||
|
||||
### Ongoing Maintenances {#ongoing-maintenances}
|
||||
|
||||
- Prominently displayed during the maintenance window
|
||||
- Affected monitors show maintenance status
|
||||
- Duration and progress indicators
|
||||
|
||||
### Past Maintenances {#past-maintenances}
|
||||
|
||||
- Listed on the events/history page
|
||||
- Shows completed maintenance events
|
||||
- Configurable retention period
|
||||
|
||||
@@ -214,11 +231,16 @@ When maintenance events change status, notifications can be sent:
|
||||
- "Maintenance in progress" notification
|
||||
- Confirms maintenance has begun
|
||||
|
||||
**COMPLETED (when finished):**
|
||||
**COMPLETED (when finished, or completed early):**
|
||||
|
||||
- "Maintenance completed" notification
|
||||
- Confirms services are back to normal
|
||||
|
||||
**CANCELLED (when manually cancelled):**
|
||||
|
||||
- "Maintenance cancelled" notification
|
||||
- Shares the same "ended" notification setting as completion
|
||||
|
||||
Notifications respect your subscription configuration and trigger settings.
|
||||
|
||||
## Use Cases {#use-cases}
|
||||
@@ -255,7 +277,7 @@ Impact: DOWN
|
||||
|
||||
## Next Steps {#next-steps}
|
||||
|
||||
- [Creating and Managing Maintenances](/docs/maintenances/creating-managing) - Learn how to create and configure maintenances
|
||||
- [Maintenance Events](/docs/maintenances/events) - Understand event lifecycle and management
|
||||
- [Maintenance Impact on Monitoring](/docs/maintenances/impact-on-monitoring) - How maintenances affect status display
|
||||
- [RRULE Patterns](/docs/maintenances/rrule-patterns) - Advanced scheduling patterns and examples
|
||||
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing) - Learn how to create and configure maintenances
|
||||
- [Maintenance Events](/docs/v4/maintenances/events) - Understand event lifecycle and management
|
||||
- [Maintenance Impact on Monitoring](/docs/v4/maintenances/impact-on-monitoring) - How maintenances affect status display
|
||||
- [RRULE Patterns](/docs/v4/maintenances/rrule-patterns) - Advanced scheduling patterns and examples
|
||||
|
||||
@@ -18,71 +18,63 @@ FREQ=frequency[;INTERVAL=n][;BYDAY=days][;BYMONTHDAY=day][;COUNT=n][;UNTIL=date]
|
||||
### Supported Components {#supported-components}
|
||||
|
||||
**FREQ** (Required)
|
||||
title: RRULE Patterns
|
||||
description: Common RRULE patterns for recurring maintenance schedules
|
||||
|
||||
- `DAILY` - Daily recurrence
|
||||
- `WEEKLY` - Weekly recurrence
|
||||
- `MONTHLY` - Monthly recurrence
|
||||
Kener uses iCalendar RRULE strings to schedule recurring maintenances.
|
||||
**INTERVAL** (Optional)
|
||||
|
||||
## RRULE basics {#rrule-basics}
|
||||
**INTERVAL** (Optional)
|
||||
|
||||
- Default: 1
|
||||
Format:
|
||||
- Integer value: 2 = every other, 3 = every third, etc.
|
||||
|
||||
```text
|
||||
FREQ=...;[INTERVAL=n];[BYDAY=...];[BYMONTHDAY=...]
|
||||
```
|
||||
**BYDAY** (Optional, for WEEKLY)
|
||||
|
||||
- Day codes: `MO`, `TU`, `WE`, `TH`, `FR`, `SA`, `SU`
|
||||
- Multiple days: `MO,WE,FR`
|
||||
|
||||
For one-time maintenances, Kener uses:
|
||||
**BYMONTHDAY** (Optional, for MONTHLY)
|
||||
|
||||
```text
|
||||
FREQ=MINUTELY;COUNT=1
|
||||
```
|
||||
|
||||
- Day of month: 1-31
|
||||
- Example: `BYMONTHDAY=1` (first of month)
|
||||
|
||||
## Common patterns {#common-patterns}
|
||||
|
||||
| Use case | RRULE |
|
||||
| ----------------------- | ---------------------------------- |
|
||||
| Every day | `FREQ=DAILY` |
|
||||
| Every Sunday | `FREQ=WEEKLY;BYDAY=SU` |
|
||||
| Weekdays | `FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR` |
|
||||
| Every 2 weeks on Monday | `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO` |
|
||||
| First day of each month | `FREQ=MONTHLY;BYMONTHDAY=1` |
|
||||
**COUNT** (For one-time only)
|
||||
|
||||
- `COUNT=1` - Single occurrence
|
||||
- Used for one-time maintenances
|
||||
|
||||
## Day codes {#day-codes}
|
||||
|
||||
## Common Patterns {#common-patterns}
|
||||
|
||||
- `MO` Monday
|
||||
- `TU` Tuesday
|
||||
- `WE` Wednesday
|
||||
- `TH` Thursday
|
||||
- `FR` Friday
|
||||
- `SA` Saturday
|
||||
- `SU` Sunday
|
||||
### Daily Patterns {#daily-patterns}
|
||||
|
||||
## Tips {#tips}
|
||||
#### Every Day {#every-day}
|
||||
|
||||
- Use simple, readable patterns.
|
||||
- Verify next occurrences in the maintenance UI preview.
|
||||
- Avoid overlapping schedules for the same monitor.
|
||||
**Description:** Maintenance occurs every single day at the configured time
|
||||
**RRULE:** `FREQ=DAILY`
|
||||
|
||||
## Related guides {#related-guides}
|
||||
**Description:** Maintenance occurs every single day at the configured time
|
||||
|
||||
**Use Case:** Daily backup windows, nightly cleanup tasks
|
||||
|
||||
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing)
|
||||
- [Maintenance Events](/docs/v4/maintenances/events)
|
||||
- [Maintenances Overview](/docs/v4/maintenances/overview)
|
||||
**Description:** Maintenance occurs every 2 days
|
||||
**Example:**
|
||||
|
||||
```
|
||||
Title: Nightly Database Backup
|
||||
Start: 2:00 AM (any day)
|
||||
RRULE: FREQ=DAILY
|
||||
Duration: 30 minutes
|
||||
|
||||
Occurrences:
|
||||
- Today at 2:00 AM
|
||||
- Tomorrow at 2:00 AM
|
||||
- Day after at 2:00 AM
|
||||
- Continues daily...
|
||||
```
|
||||
|
||||
#### Every Other Day {#every-other-day}
|
||||
|
||||
**RRULE:** `FREQ=DAILY;INTERVAL=2`
|
||||
|
||||
**Description:** Maintenance occurs every 2 days
|
||||
|
||||
**Use Case:** Bi-daily tasks
|
||||
|
||||
@@ -625,6 +617,6 @@ Description: |
|
||||
|
||||
## Next Steps {#next-steps}
|
||||
|
||||
- [Creating and Managing Maintenances](/docs/maintenances/creating-managing) - Apply these patterns in the dashboard
|
||||
- [Maintenance Events](/docs/maintenances/events) - Understand how RRULE generates events
|
||||
- [Maintenance Overview](/docs/maintenances/overview) - Learn maintenance fundamentals
|
||||
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing) - Apply these patterns in the dashboard
|
||||
- [Maintenance Events](/docs/v4/maintenances/events) - Understand how RRULE generates events
|
||||
- [Maintenance Overview](/docs/v4/maintenances/overview) - Learn maintenance fundamentals
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: Grace Period (Confirmation Threshold)
|
||||
description: Require N consecutive checks before a status change is recorded, so transient blips don't count as downtime
|
||||
---
|
||||
|
||||
A grace period makes a monitor ignore transient flapping: its recorded status changes only after several consecutive checks agree. A single failed ping no longer registers as downtime or dents your uptime %.
|
||||
|
||||
## How it works {#how-it-works}
|
||||
|
||||
The grace period is a count of consecutive checks (internally the **Confirmation Threshold**). With a grace period of `N`, a monitor's recorded status flips between healthy (`UP`) and unhealthy (`DOWN`/`DEGRADED`) only after `N` consecutive checks land on the new side. Shorter runs are treated as transient and never appear as a status change.
|
||||
|
||||
It is **count-based, not time-based**: `5` means 5 consecutive checks. On an every-minute cron that is about 5 minutes.
|
||||
|
||||
Example — grace period `5`, every-minute checks:
|
||||
|
||||
```
|
||||
11:05 ping fails → still UP (1/5)
|
||||
11:06 ping fails → still UP (2/5)
|
||||
11:07 ping fails → still UP (3/5)
|
||||
11:08 ping fails → still UP (4/5)
|
||||
11:09 ping fails → DOWN, recorded from 11:05 (5/5)
|
||||
```
|
||||
|
||||
If the check recovers before the 5th failure, the whole stretch stays `UP`.
|
||||
|
||||
> [!NOTE]
|
||||
> The change is **retroactive**. When an outage is confirmed it is recorded from its first failing check, not the moment of confirmation, so start times stay accurate. Recovery is symmetric — the monitor returns to `UP` from the first recovering check, again after `N` consecutive successes.
|
||||
|
||||
## Enable it {#enable}
|
||||
|
||||
Set it per monitor in **Manage → Monitors → _(monitor)_ → General Settings → Grace period**.
|
||||
|
||||
| Setting | Values | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| Grace period (`confirmation_threshold`) | integer `1`–`60` | `1` | Consecutive checks required to confirm a status change. `1` disables damping (every check is recorded immediately). |
|
||||
|
||||
> [!NOTE]
|
||||
> A grace period of `1` is the default and reproduces Kener's previous behavior exactly — no damping, no delay.
|
||||
|
||||
## API {#api}
|
||||
|
||||
Set `confirmation_threshold` when creating or updating a monitor:
|
||||
|
||||
```json
|
||||
PATCH /api/v4/monitors/{monitor_tag}
|
||||
{ "confirmation_threshold": 5 }
|
||||
```
|
||||
|
||||
It is validated as an integer between `1` and `60`. See the [API Reference](/docs/v4/api-reference).
|
||||
|
||||
## What gets damped {#scope}
|
||||
|
||||
The grace period damps the **recorded timeline** — status bars, current status, and uptime % — not just alerts. Everything that reads a monitor's status sees the confirmed value.
|
||||
|
||||
- Applies to **all monitor types** uniformly (a no-op for monitors that never flap).
|
||||
- Counts only scheduled checks.
|
||||
- `DEGRADED` counts as "not `UP`": a service flapping between `DOWN` and `DEGRADED` still confirms unhealthy. Changing severity *within* an already-unhealthy stretch (`DOWN` ↔ `DEGRADED`) is recorded immediately.
|
||||
|
||||
## During the grace window {#during-window}
|
||||
|
||||
While a change is pending confirmation, the monitor keeps showing its current confirmed status (for example, still `UP`). No diagnostic data is lost: the held sample keeps its **real latency** and its **error text**, tagged `| Status held during grace period`. Once confirmed, the backfilled samples append `| Down confirmed after N consecutive checks`.
|
||||
|
||||
## Interactions {#interactions}
|
||||
|
||||
- **Alerts** — [alert failure/success thresholds](/docs/v4/alerting/alert-configurations) evaluate the already-damped timeline, so the effective delay is the **larger** of the grace period and the alert threshold (a max, not a sum).
|
||||
- **Maintenance & incidents** — while a [maintenance](/docs/v4/maintenances/impact-on-monitoring) or incident overlay is active, it wins the display and the grace count **freezes**; a fresh count begins when normal monitoring resumes.
|
||||
- **NO_DATA** — a no-data check stays grey, is **neutral** to the count (neither advances nor resets it), and is never rewritten.
|
||||
- **Manual / API pushes & default status** — data-API pushes and default-status fill pass through undamped and do not move the count.
|
||||
- **Group monitors** — a [group](/docs/v4/monitors/group) scores on its members' confirmed status, so member grace periods propagate automatically. Set a grace period on the group itself only if you also want to damp the aggregate.
|
||||
- **Heartbeat** — stacks on top of the heartbeat's own down/degraded timing.
|
||||
|
||||
## Verify {#verify}
|
||||
|
||||
1. Set a grace period of `3` on a test monitor and save.
|
||||
2. Make the target fail for 2 checks → status stays `UP`.
|
||||
3. Let it fail a 3rd consecutive check → status flips to `DOWN` and the prior 2 samples backfill to `DOWN`.
|
||||
4. Restore the target → after 3 consecutive successes it returns to `UP`.
|
||||
|
||||
> [!TIP]
|
||||
> Pick a grace period slightly longer than your typical transient blip but short enough to still catch real outages quickly. For an every-minute monitor, `3`–`5` is a good starting point.
|
||||
|
||||
## Related docs {#related}
|
||||
|
||||
- [Monitors Overview](/docs/v4/monitors/overview)
|
||||
- [Alert Configurations](/docs/v4/alerting/alert-configurations)
|
||||
- [Maintenance impact on monitoring](/docs/v4/maintenances/impact-on-monitoring)
|
||||
@@ -54,6 +54,7 @@ UP + MAINTENANCE + DEGRADED + DOWN
|
||||
|
||||
## Related docs {#related-docs}
|
||||
|
||||
- [Grace Period](/docs/v4/monitors/grace-period) — require N consecutive checks before a status change is recorded
|
||||
- [Monitors (general)](/docs/v4/monitors)
|
||||
- [Sharing Monitors](/docs/v4/sharing)
|
||||
- [Alert Configurations](/docs/v4/alerting/alert-configurations)
|
||||
|
||||
@@ -97,6 +97,10 @@ Example:
|
||||
delete services
|
||||
```
|
||||
|
||||
## Manage pages via API {#manage-pages-via-api}
|
||||
|
||||
Pages support full CRUD through the v4 REST API (`/api/v4/pages`), including assigning monitors. The home page has an empty stored path, so the API addresses it with the special segment `~home` (for example `PATCH /api/v4/pages/~home`); its path cannot be changed and it cannot be deleted. See the [API Reference](/docs/spec/v4/) for endpoints and schemas.
|
||||
|
||||
## Tips {#tips}
|
||||
|
||||
- Keep page paths short and stable (changing links later is disruptive).
|
||||
|
||||
@@ -89,6 +89,26 @@ Use when MySQL/MariaDB is your standard stack.
|
||||
DATABASE_URL=mysql://kener:password@localhost:3306/kener
|
||||
```
|
||||
|
||||
## Connection pool tuning {#connection-pool-tuning}
|
||||
|
||||
For PostgreSQL and MySQL, Kener ships fail-fast, self-healing pool defaults: no permanently-idle connections, TCP keepalive on, and 15-second connection timeouts. This protects deployments on cloud networks (Railway, Docker Swarm overlays, Kubernetes) that silently drop idle TCP connections, which otherwise causes 500s after idle periods and can require a restart after a database outage.
|
||||
|
||||
Override only if your setup needs it:
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ----------------------------- | --------------------------------------------------------------- | ------- |
|
||||
| `DATABASE_POOL_MIN` | Minimum pool connections (0 lets idle connections be reclaimed) | `0` |
|
||||
| `DATABASE_POOL_MAX` | Maximum pool connections | `10` |
|
||||
| `DATABASE_ACQUIRE_TIMEOUT_MS` | How long a query waits for a free connection before failing | `15000` |
|
||||
| `DATABASE_CREATE_TIMEOUT_MS` | How long a new connection attempt waits before failing | `15000` |
|
||||
| `DATABASE_IDLE_TIMEOUT_MS` | How long a connection may sit idle before being closed | `30000` |
|
||||
| `DATABASE_KEEPALIVE` | TCP keepalive on connections (`true`/`false`) | `true` |
|
||||
|
||||
> [!TIP]
|
||||
> If your database is slow to accept connections (cold starts, cross-region), raise `DATABASE_ACQUIRE_TIMEOUT_MS` and `DATABASE_CREATE_TIMEOUT_MS` instead of disabling keepalive or raising `DATABASE_POOL_MIN`.
|
||||
|
||||
These variables have no effect on SQLite.
|
||||
|
||||
## Switching databases {#switching-databases}
|
||||
|
||||
1. Backup/export data.
|
||||
@@ -103,9 +123,13 @@ DATABASE_URL=mysql://kener:password@localhost:3306/kener
|
||||
- Connection failed: verify host, port, credentials, firewall.
|
||||
- Migration failed: ensure DB exists and user can `CREATE`/`ALTER`.
|
||||
- SQLite write error: ensure directory exists and is writable.
|
||||
- `KnexTimeoutError: Timeout acquiring a connection`: the database is unreachable or too slow to accept connections — check database health first, then see [Connection pool tuning](#connection-pool-tuning).
|
||||
- `Connection terminated unexpectedly` after idle periods: the network dropped an idle connection; keepalive (on by default) prevents this — verify `DATABASE_KEEPALIVE` is not set to `false`.
|
||||
|
||||
## Environment variables {#environment-variables}
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
| -------------- | -------------------------- | ------------------------------------- | -------- |
|
||||
| `DATABASE_URL` | Database connection string | `sqlite://./database/kener.sqlite.db` | No |
|
||||
|
||||
Pool tuning variables are listed in [Connection pool tuning](#connection-pool-tuning).
|
||||
|
||||
@@ -285,10 +285,21 @@ curl -fsS https://your-domain/healthcheck
|
||||
|
||||
Expected response body:
|
||||
|
||||
```text
|
||||
ok
|
||||
```json
|
||||
{ "status": "ok", "db": true, "redis": true }
|
||||
```
|
||||
|
||||
`status` is `degraded` when the database or Redis is unreachable. The endpoint always returns HTTP 200 so healthcheck-driven restarters do not bounce the app while a dependency is down.
|
||||
|
||||
For orchestrators that should act on dependency health (load balancer readiness, alerting), pass `?strict=1` to get HTTP 503 when any component is down:
|
||||
|
||||
```bash
|
||||
curl -fsS https://your-domain/healthcheck?strict=1
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> Do not point a restart-on-failure healthcheck (Docker `HEALTHCHECK`, Railway) at `?strict=1` — restarting Kener can not fix a dead database and will loop for the whole outage.
|
||||
|
||||
## Next steps {#next-steps}
|
||||
|
||||
- For reverse proxy and TLS setup, continue with [Reverse Proxy Setup](/docs/v4/guides/reverse-proxy).
|
||||
|
||||
@@ -238,9 +238,15 @@ SMTP_SECURE=1
|
||||
|
||||
### Database Configuration {#database-configuration}
|
||||
|
||||
| Variable | Description | Default |
|
||||
| :------------- | :------------------------------ | :----------------------------- |
|
||||
| `DATABASE_URL` | Full database connection string | `sqlite://./database/kener.db` |
|
||||
| Variable | Description | Default |
|
||||
| :---------------------------- | :----------------------------------------------------------- | :------------------------------------ |
|
||||
| `DATABASE_URL` | Full database connection string | `sqlite://./database/kener.sqlite.db` |
|
||||
| `DATABASE_POOL_MIN` | Minimum pool connections (PostgreSQL/MySQL) | `0` |
|
||||
| `DATABASE_POOL_MAX` | Maximum pool connections (PostgreSQL/MySQL) | `10` |
|
||||
| `DATABASE_ACQUIRE_TIMEOUT_MS` | Wait for a free connection before failing (PostgreSQL/MySQL) | `15000` |
|
||||
| `DATABASE_CREATE_TIMEOUT_MS` | Wait for a new connection before failing (PostgreSQL/MySQL) | `15000` |
|
||||
| `DATABASE_IDLE_TIMEOUT_MS` | Idle time before a connection is closed (PostgreSQL/MySQL) | `30000` |
|
||||
| `DATABASE_KEEPALIVE` | TCP keepalive on connections (PostgreSQL/MySQL) | `true` |
|
||||
|
||||
**Supported Databases**:
|
||||
|
||||
@@ -252,7 +258,7 @@ SMTP_SECURE=1
|
||||
|
||||
```bash
|
||||
# SQLite (default)
|
||||
DATABASE_URL=sqlite://./database/kener.db
|
||||
DATABASE_URL=sqlite://./database/kener.sqlite.db
|
||||
|
||||
# PostgreSQL
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/kener
|
||||
@@ -261,7 +267,7 @@ DATABASE_URL=postgresql://user:password@localhost:5432/kener
|
||||
DATABASE_URL=mysql://user:password@localhost:3306/kener
|
||||
```
|
||||
|
||||
📖 **See**: [Database Setup Guide](/docs/v4/setup/database-setup) for migration guides and best practices.
|
||||
📖 **See**: [Database Setup Guide](/docs/v4/setup/database-setup) for migration guides and [connection pool tuning](/docs/v4/setup/database-setup#connection-pool-tuning) for when to change the pool variables.
|
||||
|
||||
### Redis Configuration {#redis-configuration}
|
||||
|
||||
@@ -478,7 +484,7 @@ Create a `.env` file in the project root:
|
||||
```bash
|
||||
# .env
|
||||
KENER_SECRET_KEY=dev-secret-key
|
||||
DATABASE_URL=sqlite://./database/kener.db
|
||||
DATABASE_URL=sqlite://./database/kener.sqlite.db
|
||||
|
||||
# Custom variables
|
||||
API_KEY=test-key-123
|
||||
|
||||
@@ -18,6 +18,26 @@ From **Manage → Badges**, you can generate:
|
||||
|
||||
You can customize style, label, colors, and time range.
|
||||
|
||||
### All-monitors badge {#all-monitors-badge}
|
||||
|
||||
Status and dot badges accept `_` instead of a monitor tag:
|
||||
|
||||
```
|
||||
/badge/_/status
|
||||
/badge/_/dot
|
||||
```
|
||||
|
||||
The `_` badge shows the overall status of **every active, non-hidden monitor site-wide**. It is not scoped to a page, so on a multi-page setup it may not match an individual page's banner.
|
||||
|
||||
### Overall status priority {#overall-status-priority}
|
||||
|
||||
When multiple monitor statuses collapse into one overall status (the page banner and the `_` badge), the worst state wins:
|
||||
|
||||
`DOWN` > `DEGRADED` > `MAINTENANCE` > `UP`
|
||||
|
||||
> [!NOTE]
|
||||
> Maintenance never masks an active problem: a page with one monitor under maintenance and another down or degraded reports the outage, not the maintenance. `NO_DATA` is shown only when no monitor has reported any data.
|
||||
|
||||
### Embeds {#embeds}
|
||||
|
||||
From **Manage → Embed**, you can generate:
|
||||
|
||||
@@ -1,75 +1,81 @@
|
||||
---
|
||||
title: User Management
|
||||
description: Manage users, roles, invitations, and role permissions in Kener
|
||||
description: Manage users, roles, permissions, and invitations in Kener
|
||||
---
|
||||
|
||||
Use **Manage → Users** to invite teammates, control access, and manage account status.
|
||||
Use **Manage → Users** to invite teammates and manage account status. Use **Manage → Roles** to control access with fine-grained permissions.
|
||||
|
||||
## Roles overview {#roles-overview}
|
||||
## Roles and permissions {#roles-and-permissions}
|
||||
|
||||
Kener uses three roles:
|
||||
Kener uses a role-based access control (RBAC) system. Each user can be assigned one or more **roles**, and each role has a set of **permissions** that determine what actions the user can perform.
|
||||
|
||||
| Role | What it means |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `admin` | Full access, including user administration and vault/API-key level operations |
|
||||
| `editor` | Can run day-to-day operations (monitors, incidents, maintenances, site settings) but cannot administer users |
|
||||
| `member` | Limited access; cannot administer users or change system settings |
|
||||
### Built-in roles {#built-in-roles}
|
||||
|
||||
## What each role can do {#what-each-role-can-do}
|
||||
Three readonly roles are seeded automatically:
|
||||
|
||||
### Admin {#admin}
|
||||
| Role | Permissions | Notes |
|
||||
| -------- | ----------- | ----- |
|
||||
| `admin` | All permissions | Full access including `api_keys.delete` |
|
||||
| `editor` | All except `api_keys.delete` | Day-to-day operations |
|
||||
| `member` | All `.read` permissions only | View-only access |
|
||||
|
||||
Admin can:
|
||||
Built-in roles cannot be edited or deleted.
|
||||
|
||||
- invite users
|
||||
- resend invitations
|
||||
- change user role
|
||||
- activate/deactivate users
|
||||
- send verification email to any user
|
||||
- perform all editor-level operational actions
|
||||
- manage admin-only areas like vault and certain privileged API actions
|
||||
### Custom roles {#custom-roles}
|
||||
|
||||
Admin invite permissions:
|
||||
From **Manage → Roles**, users with the `roles.write` permission can create custom roles:
|
||||
|
||||
- admin can invite `admin`, `editor`, and `member`
|
||||
1. Click **Create Role**.
|
||||
2. Enter a role ID (lowercase, numbers, underscores, hyphens) and display name.
|
||||
3. Optionally clone permissions from an existing role.
|
||||
4. After creation, assign permissions in the **Permissions** panel.
|
||||
|
||||
Admin user-management restrictions:
|
||||
Custom roles can be edited, deactivated, or deleted. When deleting a custom role, you can either remove user assignments or migrate them to another role.
|
||||
|
||||
- non-owner admin cannot modify other admins
|
||||
- owner admin can modify other admins (role update and activate/deactivate)
|
||||
### Permission domains {#permission-domains}
|
||||
|
||||
### Editor {#editor}
|
||||
Permissions follow a `domain.action` format:
|
||||
|
||||
Editor can:
|
||||
| Domain | Actions |
|
||||
| ------ | ------- |
|
||||
| `monitors` | `read`, `write` |
|
||||
| `incidents` | `read`, `write` |
|
||||
| `maintenances` | `read`, `write` |
|
||||
| `pages` | `read`, `write` |
|
||||
| `triggers` | `read`, `write` |
|
||||
| `alerts` | `read`, `write` |
|
||||
| `api_keys` | `read`, `write`, `delete` |
|
||||
| `users` | `read`, `write` |
|
||||
| `settings` | `read`, `write` |
|
||||
| `subscribers` | `read`, `write` |
|
||||
| `email_templates` | `read`, `write` |
|
||||
| `images` | `write` |
|
||||
| `roles` | `read`, `write`, `assign_permissions`, `assign_users` |
|
||||
|
||||
- invite users
|
||||
Permissions are enforced at both the **route level** (page access) and the **action level** (API operations).
|
||||
|
||||
### Managing role permissions {#managing-role-permissions}
|
||||
|
||||
From the roles table, click **Permissions** on any role to view or edit its permissions. Permissions are grouped by domain and can be toggled individually. Readonly (built-in) roles show permissions in read-only mode.
|
||||
|
||||
### Managing role users {#managing-role-users}
|
||||
|
||||
Click **Users** on any role to see assigned users. Users with `roles.assign_users` permission can add or remove users from roles.
|
||||
|
||||
## User management {#user-management}
|
||||
|
||||
Users with the `users.write` permission can:
|
||||
|
||||
- invite new users
|
||||
- resend invitation emails
|
||||
- manage monitors, incidents, maintenances, alerts, triggers, pages, subscriptions, and site data
|
||||
|
||||
Editor invite permissions:
|
||||
|
||||
- editor can invite `editor` and `member`
|
||||
|
||||
Editor cannot:
|
||||
|
||||
- change user roles
|
||||
- update user roles
|
||||
- activate/deactivate users
|
||||
- perform admin-only user administration actions
|
||||
- send verification emails
|
||||
|
||||
### Member {#member}
|
||||
Owner-specific restrictions:
|
||||
|
||||
Member can:
|
||||
|
||||
- sign in and use allowed views
|
||||
- send verification email for their own account (if unverified)
|
||||
|
||||
Member cannot:
|
||||
|
||||
- invite users
|
||||
- resend invitations
|
||||
- change roles
|
||||
- activate/deactivate other users
|
||||
- perform admin/editor configuration actions
|
||||
- the owner must always retain the `admin` role
|
||||
- the owner account cannot be deactivated
|
||||
|
||||
## Invite flow {#invite-flow}
|
||||
|
||||
@@ -79,19 +85,14 @@ Member cannot:
|
||||
From **Manage → Users**:
|
||||
|
||||
1. Click **Add User**.
|
||||
2. Enter name, email, and role.
|
||||
2. Enter name, email, and select one or more roles.
|
||||
3. Invitation email is sent with a secure token link.
|
||||
|
||||
Role options in **Add User** are filtered by your role:
|
||||
|
||||
- admin: `admin`, `editor`, `member`
|
||||
- editor: `editor`, `member`
|
||||
- member: no access to Add User
|
||||
|
||||
Current behavior:
|
||||
|
||||
- invited user is created with inactive account and empty password
|
||||
- invitation token expires after 7 days
|
||||
- all selected roles must be active
|
||||
|
||||
## How users accept invitation {#how-users-accept-invitation}
|
||||
|
||||
@@ -106,19 +107,20 @@ If link is invalid, expired, or already used, invitation page shows an error and
|
||||
|
||||
## Verification emails {#verification-emails}
|
||||
|
||||
- Admin/editor can send verification email to users.
|
||||
- Member can only trigger verification for their own account.
|
||||
- Users with `users.write` permission can send verification emails to other users.
|
||||
- Any user can trigger verification for their own account (if unverified).
|
||||
|
||||
## Common user management tasks {#common-user-management-tasks}
|
||||
## Common tasks {#common-tasks}
|
||||
|
||||
- **Promote/demote user**: admin updates role in user settings sheet. Non-owner admins cannot change other admins.
|
||||
- **Deactivate user**: admin toggles account inactive (session access removed). Non-owner admins cannot deactivate other admins.
|
||||
- **Change user roles**: open user settings sheet, toggle roles, and click **Update Roles**. Users can be assigned multiple roles simultaneously.
|
||||
- **Deactivate user**: toggle account inactive in user settings sheet. Existing sessions are invalidated.
|
||||
- **Re-invite user**: resend invitation if user has not set password yet.
|
||||
|
||||
## UI behavior notes {#ui-behavior-notes}
|
||||
|
||||
- The current signed-in user is highlighted in the users table.
|
||||
- For non-owner admins, admin targets do not show admin-management actions.
|
||||
- Users table can be filtered by active/inactive status.
|
||||
- Role badges show the user's assigned role IDs.
|
||||
|
||||
## Requirements and dependencies {#requirements-and-dependencies}
|
||||
|
||||
|
||||
@@ -7,29 +7,29 @@
|
||||
import IncidentItem from "$lib/components/IncidentItem.svelte";
|
||||
import MaintenanceItem from "$lib/components/MaintenanceItem.svelte";
|
||||
import mdToHTML from "$lib/marked.js";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import clientResolver, { absoluteResolve } from "$lib/client/resolver.js";
|
||||
import { resolve } from "$app/paths";
|
||||
import { selectedTimezone } from "$lib/stores/timezone";
|
||||
import { getEndOfDayAtTz } from "$lib/client/datetime";
|
||||
import { requestMonitorBar } from "$lib/client/monitor-bar-client";
|
||||
import type { MonitorBarResponse } from "$lib/server/api-server/monitor-bar/get";
|
||||
import { SveltePurify } from "@humanspeak/svelte-purify";
|
||||
import type { PageMonitorLayoutStyle } from "$lib/types/api";
|
||||
import GC from "$lib/global-constants.js";
|
||||
|
||||
let { data } = $props();
|
||||
let pageSettings = $derived(data.pageDetails.page_settings);
|
||||
let barCount = $derived.by(() =>
|
||||
data.isMobile
|
||||
? pageSettings?.monitor_status_history_days.mobile || 30
|
||||
: pageSettings?.monitor_status_history_days.desktop || 90
|
||||
? pageSettings?.monitor_status_history_days.mobile || GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE
|
||||
: pageSettings?.monitor_status_history_days.desktop || GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP
|
||||
);
|
||||
let endOfDayTodayAtTz = $derived(getEndOfDayAtTz($selectedTimezone));
|
||||
|
||||
let monitorBarDataByTag = $state<Record<string, MonitorBarResponse>>({});
|
||||
let monitorBarErrorByTag = $state<Record<string, string>>({});
|
||||
let requestVersion = 0;
|
||||
let viewType = $derived<"compact-list" | "default-list" | "default-grid" | "compact-grid" | undefined>(
|
||||
pageSettings?.monitor_layout_style
|
||||
);
|
||||
let viewType = $derived<PageMonitorLayoutStyle | undefined>(pageSettings?.monitor_layout_style);
|
||||
let isCompact = $derived(viewType === "compact-list" || viewType === "compact-grid");
|
||||
|
||||
function getGridItemSpanClass(index: number, total: number, type: typeof viewType): string {
|
||||
@@ -136,8 +136,8 @@
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{#if data.socialPagePreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
|
||||
<meta property="og:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPagePreviewImage)} />
|
||||
<meta name="twitter:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPagePreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
|
||||
@@ -7,29 +7,29 @@
|
||||
import IncidentItem from "$lib/components/IncidentItem.svelte";
|
||||
import MaintenanceItem from "$lib/components/MaintenanceItem.svelte";
|
||||
import mdToHTML from "$lib/marked.js";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import clientResolver, { absoluteResolve } from "$lib/client/resolver.js";
|
||||
import { resolve } from "$app/paths";
|
||||
import { selectedTimezone } from "$lib/stores/timezone";
|
||||
import { getEndOfDayAtTz } from "$lib/client/datetime";
|
||||
import { requestMonitorBar } from "$lib/client/monitor-bar-client";
|
||||
import type { MonitorBarResponse } from "$lib/server/api-server/monitor-bar/get";
|
||||
import { SveltePurify } from "@humanspeak/svelte-purify";
|
||||
import type { PageMonitorLayoutStyle } from "$lib/types/api";
|
||||
import GC from "$lib/global-constants.js";
|
||||
|
||||
let { data } = $props();
|
||||
let pageSettings = $derived(data.pageDetails.page_settings);
|
||||
let barCount = $derived.by(() =>
|
||||
data.isMobile
|
||||
? pageSettings?.monitor_status_history_days.mobile || 30
|
||||
: pageSettings?.monitor_status_history_days.desktop || 90
|
||||
? pageSettings?.monitor_status_history_days.mobile || GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE
|
||||
: pageSettings?.monitor_status_history_days.desktop || GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP
|
||||
);
|
||||
let endOfDayTodayAtTz = $derived(getEndOfDayAtTz($selectedTimezone));
|
||||
|
||||
let monitorBarDataByTag = $state<Record<string, MonitorBarResponse>>({});
|
||||
let monitorBarErrorByTag = $state<Record<string, string>>({});
|
||||
let requestVersion = 0;
|
||||
let viewType = $derived<"compact-list" | "default-list" | "default-grid" | "compact-grid" | undefined>(
|
||||
pageSettings?.monitor_layout_style
|
||||
);
|
||||
let viewType = $derived<PageMonitorLayoutStyle | undefined>(pageSettings?.monitor_layout_style);
|
||||
let isCompact = $derived(viewType === "compact-list" || viewType === "compact-grid");
|
||||
|
||||
function getGridItemSpanClass(index: number, total: number, type: typeof viewType): string {
|
||||
@@ -136,8 +136,8 @@
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{#if data.socialPagePreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
|
||||
<meta property="og:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPagePreviewImage)} />
|
||||
<meta name="twitter:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPagePreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import { formatDate } from "$lib/stores/datetime";
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import clientResolver, { absoluteResolve } from "$lib/client/resolver.js";
|
||||
import { format, parse, addMonths, subMonths, getUnixTime, startOfDay, formatDistanceStrict } from "date-fns";
|
||||
import { page } from "$app/state";
|
||||
import type { IncidentForMonitorListWithComments, MaintenanceEventsMonitorList } from "$lib/server/types/db";
|
||||
@@ -165,8 +165,8 @@
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta property="og:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import { formatDate } from "$lib/stores/datetime";
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import clientResolver, { absoluteResolve } from "$lib/client/resolver.js";
|
||||
import { format, parse, addMonths, subMonths, getUnixTime, startOfDay, formatDistanceStrict } from "date-fns";
|
||||
import { page } from "$app/state";
|
||||
import type { IncidentForMonitorListWithComments, MaintenanceEventsMonitorList } from "$lib/server/types/db";
|
||||
@@ -165,8 +165,8 @@
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta property="og:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { SveltePurify } from "@humanspeak/svelte-purify";
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import { formatDate, formatDuration } from "$lib/stores/datetime";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import clientResolver, { absoluteResolve } from "$lib/client/resolver.js";
|
||||
import { page } from "$app/state";
|
||||
|
||||
let { data } = $props();
|
||||
@@ -28,8 +28,8 @@
|
||||
<meta property="og:description" content={data.comments[0].comment} />
|
||||
{/if}
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta property="og:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import STATUS_ICON from "$lib/icons";
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import { formatDate, formatDuration } from "$lib/stores/datetime";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import clientResolver, { absoluteResolve } from "$lib/client/resolver.js";
|
||||
import { SveltePurify } from "@humanspeak/svelte-purify";
|
||||
import { page } from "$app/state";
|
||||
|
||||
@@ -73,8 +73,8 @@
|
||||
<meta property="og:description" content={data.maintenance.description} />
|
||||
{/if}
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta property="og:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
|
||||
@@ -83,11 +83,13 @@ export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
}
|
||||
}
|
||||
|
||||
let maxDays = parentData.isMobile ? 30 : 90;
|
||||
let maxDays: number = parentData.isMobile
|
||||
? GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE
|
||||
: GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP;
|
||||
if (monitor.monitor_settings_json?.monitor_status_history_days) {
|
||||
maxDays = parentData.isMobile
|
||||
? monitor.monitor_settings_json.monitor_status_history_days.mobile || 30
|
||||
: monitor.monitor_settings_json.monitor_status_history_days.desktop || 90;
|
||||
? monitor.monitor_settings_json.monitor_status_history_days.mobile || GC.DEFAULT_STATUS_HISTORY_DAYS_MOBILE
|
||||
: monitor.monitor_settings_json.monitor_status_history_days.desktop || GC.DEFAULT_STATUS_HISTORY_DAYS_DESKTOP;
|
||||
}
|
||||
return {
|
||||
...{
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import ThemePlus from "$lib/components/ThemePlus.svelte";
|
||||
import MonitorOverview from "$lib/components/MonitorOverview.svelte";
|
||||
import ArrowUpRight from "@lucide/svelte/icons/arrow-up-right";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import clientResolver, { absoluteResolve } from "$lib/client/resolver.js";
|
||||
import { resolve } from "$app/paths";
|
||||
import trackEvent from "$lib/beacon";
|
||||
import IncidentItem from "$lib/components/IncidentItem.svelte";
|
||||
@@ -37,8 +37,8 @@
|
||||
<meta property="og:description" content={data.monitorDescription} />
|
||||
{/if}
|
||||
{#if data.socialPreviewImage}
|
||||
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
|
||||
<meta property="og:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
|
||||
<meta name="twitter:image" content={absoluteResolve(resolve, data.siteUrl, data.socialPreviewImage)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
<div class="flex flex-col gap-3">
|
||||
|
||||
@@ -2,8 +2,11 @@ import { redirect } from "@sveltejs/kit";
|
||||
import MobileDetect from "mobile-detect";
|
||||
import type { LayoutServerLoad } from "./$types";
|
||||
import { IsEmailSetup } from "$lib/server/controllers/controller.js";
|
||||
import { RequirePermission } from "$lib/server/controllers/userController.js";
|
||||
import seedSiteData from "$lib/server/db/seedSiteData.js";
|
||||
import serverResolve from "$lib/server/resolver.js";
|
||||
import { ROUTE_PERMISSION_MAP } from "$lib/allPerms.js";
|
||||
import { error } from "@sveltejs/kit";
|
||||
|
||||
import { resolve } from "$app/paths";
|
||||
import {
|
||||
@@ -12,8 +15,9 @@ import {
|
||||
GetLoggedInSession,
|
||||
GetLocaleFromCookie,
|
||||
} from "$lib/server/controllers/controller.js";
|
||||
import { GetUserPermissions } from "$lib/server/controllers/userController.js";
|
||||
|
||||
export const load: LayoutServerLoad = async ({ cookies }) => {
|
||||
export const load: LayoutServerLoad = async ({ cookies, route }) => {
|
||||
let isSetupComplete = await IsSetupComplete();
|
||||
if (!isSetupComplete) {
|
||||
throw redirect(302, serverResolve(`/account/signin`));
|
||||
@@ -27,13 +31,27 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
|
||||
}
|
||||
|
||||
const siteData = await GetAllSiteData();
|
||||
const userPermissions = await GetUserPermissions(loggedInUser.id);
|
||||
const routeId = route.id || "";
|
||||
|
||||
const requiredPermission = ROUTE_PERMISSION_MAP[routeId];
|
||||
if (requiredPermission === undefined) {
|
||||
throw error(403, "Forbidden");
|
||||
}
|
||||
if (requiredPermission !== null) {
|
||||
try {
|
||||
RequirePermission(userPermissions, requiredPermission);
|
||||
} catch {
|
||||
throw error(403, "Forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
const siteStatusColors = siteData.colors;
|
||||
const siteStatusColorsDark = siteData.colorsDark || siteStatusColors;
|
||||
const font = siteData.font || { cssSrc: "", family: "" };
|
||||
// const emailSubscriptionTrigger = await GetSubscriptionTriggerByEmail();
|
||||
return {
|
||||
userDb: loggedInUser,
|
||||
userPermissions: [...userPermissions],
|
||||
siteStatusColors,
|
||||
siteStatusColorsDark,
|
||||
font,
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import BookOpenIcon from "@lucide/svelte/icons/book-open";
|
||||
import KeyIcon from "@lucide/svelte/icons/key";
|
||||
import UsersIcon from "@lucide/svelte/icons/users";
|
||||
import ShieldIcon from "@lucide/svelte/icons/shield";
|
||||
import Columns3CogIcon from "@lucide/svelte/icons/columns-3-cog";
|
||||
import SiteHeader from "./manage/site-header.svelte";
|
||||
import TemplateIcon from "@lucide/svelte/icons/layout-template";
|
||||
@@ -30,9 +31,12 @@
|
||||
|
||||
import { Toaster } from "$lib/components/ui/sonner/index.js";
|
||||
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
|
||||
import { ROUTE_PERMISSION_MAP } from "$lib/allPerms.js";
|
||||
|
||||
let { children, data } = $props();
|
||||
|
||||
// Navigation items - single source of truth
|
||||
const navItems = [
|
||||
const allNavItems = [
|
||||
{ title: "Site Configurations", url: "/manage/app/site-configurations", icon: Settings2Icon },
|
||||
{ title: "Internationalization", url: "/manage/app/internationalization", icon: GlobeIcon },
|
||||
{ title: "Customizations", url: "/manage/app/customizations", icon: Columns3CogIcon },
|
||||
@@ -45,17 +49,26 @@
|
||||
{ title: "Alerts", url: "/manage/app/alerts", icon: SirenIcon },
|
||||
{ title: "Subscriptions", url: "/manage/app/subscriptions", icon: BellIcon },
|
||||
{ title: "Users", url: "/manage/app/users", icon: UsersIcon },
|
||||
{ title: "Roles", url: "/manage/app/roles", icon: ShieldIcon },
|
||||
{ title: "Triggers", url: "/manage/app/triggers", icon: MailboxIcon },
|
||||
{ title: "Templates", url: "/manage/app/templates", icon: TemplateIcon },
|
||||
{ title: "Badges", url: "/manage/app/badges", icon: BadgeIcon },
|
||||
{ title: "Embed", url: "/manage/app/embed", icon: CodeIcon },
|
||||
{ title: "API Keys", url: "/manage/app/api-keys", icon: KeyIcon }
|
||||
].map((item) => ({ ...item, url: clientResolver(resolve, item.url) }));
|
||||
];
|
||||
|
||||
const navItems = allNavItems
|
||||
.filter((item) => {
|
||||
const routeId = `/(manage)${item.url}`;
|
||||
const requiredPermission = ROUTE_PERMISSION_MAP[routeId];
|
||||
if (requiredPermission === undefined) return false;
|
||||
if (requiredPermission === null) return true;
|
||||
return (data.userPermissions ?? []).includes(requiredPermission);
|
||||
})
|
||||
.map((item) => ({ ...item, url: clientResolver(resolve, item.url) }));
|
||||
|
||||
// Derive page title from current URL
|
||||
let pageTitle = $derived(navItems.find((item) => page.url.pathname.startsWith(item.url))?.title || "Dashboard");
|
||||
|
||||
let { children, data } = $props();
|
||||
</script>
|
||||
|
||||
<ModeWatcher />
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
GetMaintenanceEventById,
|
||||
GetMaintenanceEventsByMaintenanceId,
|
||||
UpdateMaintenanceEvent,
|
||||
UpdateMaintenanceEventStatus,
|
||||
DeleteMaintenanceEvent,
|
||||
AddMonitorToMaintenance,
|
||||
RemoveMonitorFromMaintenance,
|
||||
@@ -111,6 +112,20 @@ import {
|
||||
GetGeneralEmailTemplateById,
|
||||
UpdateGeneralEmailTemplate,
|
||||
} from "$lib/server/controllers/generalTemplateController.js";
|
||||
import {
|
||||
GetAllRoles,
|
||||
GetAllPermissions,
|
||||
GetRolePermissions,
|
||||
UpdateRolePermissions,
|
||||
GetRoleUsers,
|
||||
AddUserToRole,
|
||||
RemoveUserFromRole,
|
||||
CreateRole,
|
||||
UpdateRole,
|
||||
DeleteRole,
|
||||
GetUserPermissions,
|
||||
RequirePermission,
|
||||
} from "$lib/server/controllers/userController.js";
|
||||
import type { SiteDataForNotification } from "$lib/server/notification/types";
|
||||
import { alertToVariables, siteDataToVariables } from "$lib/server/notification/notification_utils";
|
||||
import type { TriggerMeta } from "$lib/server/types/db.js";
|
||||
@@ -120,29 +135,7 @@ 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) {
|
||||
if (role !== "admin") {
|
||||
throw new Error("Only Admins can perform this action");
|
||||
}
|
||||
}
|
||||
|
||||
function EditorCan(role: string) {
|
||||
if (role !== "editor") {
|
||||
throw new Error("Only Editors can perform this action");
|
||||
}
|
||||
}
|
||||
function MemberCan(role: string) {
|
||||
if (role !== "member") {
|
||||
throw new Error("Only Member can perform this action");
|
||||
}
|
||||
}
|
||||
|
||||
function AdminEditorCan(role: string) {
|
||||
if (role !== "admin" && role !== "editor") {
|
||||
throw new Error("Only Admins and Editors can perform this action");
|
||||
}
|
||||
}
|
||||
import { ACTION_PERMISSION_MAP } from "$lib/allPerms.js";
|
||||
|
||||
export async function POST({ request, cookies }) {
|
||||
const payload = await request.json();
|
||||
@@ -155,6 +148,22 @@ export async function POST({ request, cookies }) {
|
||||
return json({ error: "User not logged in" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Fetch user permissions once for the entire request
|
||||
const userPermissions = await GetUserPermissions(userDB.id);
|
||||
|
||||
// Check permission for the action
|
||||
const requiredPermission = ACTION_PERMISSION_MAP[action];
|
||||
if (requiredPermission === undefined) {
|
||||
return json({ error: "Unknown action" }, { status: 400 });
|
||||
}
|
||||
if (requiredPermission !== null) {
|
||||
try {
|
||||
RequirePermission(userPermissions, requiredPermission);
|
||||
} catch {
|
||||
return json({ error: "You do not have permission to perform this action" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (action == "updateUser") {
|
||||
data.userID = userDB.id;
|
||||
@@ -162,68 +171,69 @@ export async function POST({ request, cookies }) {
|
||||
} else if (action == "getAllSiteData") {
|
||||
resp = await GetAllSiteData();
|
||||
} else if (action == "manualUpdate") {
|
||||
await ManualUpdateUserData(userDB, data.id, data);
|
||||
await ManualUpdateUserData(data.id, data);
|
||||
resp = await GetUserByIDDashboard(data.id);
|
||||
} else if (action == "updatePassword") {
|
||||
data.userID = userDB.id;
|
||||
resp = await UpdatePassword(data);
|
||||
} else if (action == "createNewUser") {
|
||||
await SendInvitationEmail(data.email, data.role, data.name, userDB.role);
|
||||
await SendInvitationEmail(data.email, data.role_ids, data.name);
|
||||
resp = await GetUserByEmail(data.email);
|
||||
} else if (action == "resendInvitation") {
|
||||
AdminEditorCan(userDB.role);
|
||||
await ResendInvitationEmail(data.email, userDB.role);
|
||||
await ResendInvitationEmail(data.email);
|
||||
resp = { success: true };
|
||||
} else if (action == "sendVerificationEmail") {
|
||||
const toId = parseInt(String(data.toId));
|
||||
if (!toId) {
|
||||
throw new Error("User ID is required");
|
||||
}
|
||||
await SendVerificationEmail(toId, { id: userDB.id, role: userDB.role });
|
||||
// Non-self verification requires users.write permission
|
||||
if (toId !== userDB.id) {
|
||||
if (!userPermissions.has("users.write")) {
|
||||
return json({ error: "You do not have permission to perform this action" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
await SendVerificationEmail(toId, userDB.id);
|
||||
resp = { success: true };
|
||||
} else if (action == "getUsers") {
|
||||
const page = parseInt(String(data.page)) || 1;
|
||||
const limit = parseInt(String(data.limit)) || 10;
|
||||
const users = await GetAllUsersPaginatedDashboard({ page, limit });
|
||||
const totalResult = await GetUsersCount();
|
||||
const filter: { is_active?: number } = {};
|
||||
if (data.is_active !== undefined && data.is_active !== null) {
|
||||
filter.is_active = parseInt(String(data.is_active));
|
||||
}
|
||||
const hasFilter = Object.keys(filter).length > 0 ? filter : undefined;
|
||||
const users = await GetAllUsersPaginatedDashboard({ page, limit }, hasFilter);
|
||||
const totalResult = await GetUsersCount(hasFilter);
|
||||
const total = totalResult ? Number(totalResult.count) : 0;
|
||||
resp = { users, total };
|
||||
} else if (action === "storeSiteData") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await storeSiteData(data);
|
||||
} else if (action == "storeMonitorData") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await CreateUpdateMonitor(data);
|
||||
} else if (action == "updateMonitoringData") {
|
||||
AdminEditorCan(userDB.role);
|
||||
data.type = GC.MANUAL;
|
||||
resp = await UpdateMonitoringData(data);
|
||||
} else if (action == "getMonitors") {
|
||||
resp = await GetMonitors(data);
|
||||
} else if (action == "deleteMonitor") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await DeleteMonitorCompletelyUsingTag(data.tag);
|
||||
} else if (action == "deleteMonitorData") {
|
||||
AdminEditorCan(userDB.role);
|
||||
await db.deleteMonitorDataByTag(data.tag || undefined, data.start, data.end);
|
||||
resp = { success: true };
|
||||
} else if (action == "cloneMonitor") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await CloneMonitor({
|
||||
sourceTag: String(data.sourceTag || ""),
|
||||
newTag: String(data.newTag || ""),
|
||||
newName: String(data.newName || ""),
|
||||
});
|
||||
} else if (action == "createUpdateTrigger") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await CreateUpdateTrigger(data);
|
||||
} else if (action == "getTriggers") {
|
||||
resp = await GetAllTriggers(data);
|
||||
} else if (action == "updateMonitorTriggers") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await UpdateTriggerData(data);
|
||||
} else if (action == "deleteTrigger") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await DeleteTrigger(data.trigger_id);
|
||||
} else if (action == "getAllAlertsPaginated") {
|
||||
const page = parseInt(String(data.page)) || 1;
|
||||
@@ -249,13 +259,10 @@ export async function POST({ request, cookies }) {
|
||||
} else if (action == "getAPIKeys") {
|
||||
resp = await GetAllAPIKeys();
|
||||
} else if (action == "createNewApiKey") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await CreateNewAPIKey(data);
|
||||
} else if (action == "updateApiKeyStatus") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await UpdateApiKeyStatus(data);
|
||||
} else if (action == "deleteApiKey") {
|
||||
AdminCan(userDB.role);
|
||||
const deleted = await DeleteApiKey(data);
|
||||
if (!deleted) {
|
||||
throw new Error("API key not found");
|
||||
@@ -269,33 +276,24 @@ export async function POST({ request, cookies }) {
|
||||
throw new Error("Incident not found");
|
||||
}
|
||||
} else if (action == "createIncident") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await CreateIncident(data);
|
||||
} else if (action == "updateIncident") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await UpdateIncident(data.id, data);
|
||||
} else if (action == "deleteIncident") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await DeleteIncident(data.incident_id);
|
||||
} else if (action == "addMonitor") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await AddIncidentMonitor(data.incident_id, data.monitor_tag, data.monitor_impact);
|
||||
} else if (action == "removeMonitor") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await RemoveIncidentMonitor(data.incident_id, data.monitor_tag);
|
||||
} else if (action == "getComments") {
|
||||
resp = await GetIncidentActiveComments(data.incident_id);
|
||||
} else if (action == "addComment") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await AddIncidentComment(data.incident_id, data.comment, data.state, data.commented_at);
|
||||
} else if (action == "deleteComment") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await UpdateCommentStatusByID(data.incident_id, data.comment_id, "INACTIVE");
|
||||
} else if (action == "updateComment") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await UpdateCommentByID(data.incident_id, data.comment_id, data.comment, data.state, data.commented_at);
|
||||
} else if (action == "testTrigger") {
|
||||
AdminEditorCan(userDB.role);
|
||||
const trigger = await GetTriggerByID(data.trigger_id);
|
||||
const siteData = await GetAllSiteData();
|
||||
if (!trigger || !siteData) {
|
||||
@@ -370,7 +368,6 @@ export async function POST({ request, cookies }) {
|
||||
throw new Error("Unsupported trigger type for testing");
|
||||
}
|
||||
} else if (action == "testMonitor") {
|
||||
AdminEditorCan(userDB.role);
|
||||
let monitorID = data.monitor_id;
|
||||
let monitors = await GetMonitorsParsed({ id: monitorID });
|
||||
let monitor = monitors[0];
|
||||
@@ -386,10 +383,8 @@ export async function POST({ request, cookies }) {
|
||||
const serviceClient = new Service(monitorReducedType);
|
||||
resp = await serviceClient.execute();
|
||||
} else if (action == "uploadImage") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await uploadImage(data);
|
||||
} else if (action == "deleteImage") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await db.deleteImage(data.id);
|
||||
} else if (action == "getPages") {
|
||||
const pages = await GetAllPages();
|
||||
@@ -402,26 +397,20 @@ export async function POST({ request, cookies }) {
|
||||
);
|
||||
resp = pagesWithMonitors;
|
||||
} else if (action == "createPage") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await CreatePage(data);
|
||||
} else if (action == "updatePage") {
|
||||
AdminEditorCan(userDB.role);
|
||||
const { id, ...updateData } = data;
|
||||
resp = await UpdatePage(id, updateData);
|
||||
} else if (action == "deletePage") {
|
||||
AdminEditorCan(userDB.role);
|
||||
await DeletePage(data.id);
|
||||
resp = { success: true };
|
||||
} else if (action == "addMonitorToPage") {
|
||||
AdminEditorCan(userDB.role);
|
||||
await AddMonitorToPage(data.page_id, data.monitor_tag);
|
||||
resp = { success: true };
|
||||
} else if (action == "removeMonitorFromPage") {
|
||||
AdminEditorCan(userDB.role);
|
||||
await RemoveMonitorFromPage(data.page_id, data.monitor_tag);
|
||||
resp = { success: true };
|
||||
} else if (action == "reorderPageMonitors") {
|
||||
AdminEditorCan(userDB.role);
|
||||
await ReorderPageMonitors(data.page_id, data.monitor_tags);
|
||||
resp = { success: true };
|
||||
}
|
||||
@@ -434,15 +423,12 @@ export async function POST({ request, cookies }) {
|
||||
throw new Error("Maintenance not found");
|
||||
}
|
||||
} else if (action == "createMaintenance") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await CreateMaintenance(data);
|
||||
} else if (action == "updateMaintenance") {
|
||||
AdminEditorCan(userDB.role);
|
||||
const { id, ...updateData } = data;
|
||||
await UpdateMaintenance(id, updateData);
|
||||
resp = { success: true };
|
||||
} else if (action == "deleteMaintenance") {
|
||||
AdminEditorCan(userDB.role);
|
||||
await DeleteMaintenance(data.id);
|
||||
resp = { success: true };
|
||||
} else if (action == "getMaintenanceEvents") {
|
||||
@@ -453,38 +439,32 @@ export async function POST({ request, cookies }) {
|
||||
throw new Error("Maintenance event not found");
|
||||
}
|
||||
} else if (action == "createMaintenanceEvent") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await CreateMaintenanceEvent(data);
|
||||
} else if (action == "updateMaintenanceEvent") {
|
||||
AdminEditorCan(userDB.role);
|
||||
const { id, ...updateData } = data;
|
||||
await UpdateMaintenanceEvent(id, updateData);
|
||||
resp = { success: true };
|
||||
} else if (action == "updateMaintenanceEventStatus") {
|
||||
resp = await UpdateMaintenanceEventStatus(data.id, data.status);
|
||||
} else if (action == "deleteMaintenanceEvent") {
|
||||
AdminEditorCan(userDB.role);
|
||||
await DeleteMaintenanceEvent(data.id);
|
||||
resp = { success: true };
|
||||
} else if (action == "addMonitorToMaintenance") {
|
||||
AdminEditorCan(userDB.role);
|
||||
await AddMonitorToMaintenance(data.maintenance_id, data.monitor_tag);
|
||||
resp = { success: true };
|
||||
} else if (action == "removeMonitorFromMaintenance") {
|
||||
AdminEditorCan(userDB.role);
|
||||
await RemoveMonitorFromMaintenance(data.maintenance_id, data.monitor_tag);
|
||||
resp = { success: true };
|
||||
} else if (action == "getMaintenanceMonitors") {
|
||||
resp = await GetMaintenanceMonitors(data.maintenance_id);
|
||||
} else if (action == "updateMaintenanceMonitorImpact") {
|
||||
AdminEditorCan(userDB.role);
|
||||
await UpdateMaintenanceMonitorImpact(data.maintenance_id, data.monitor_tag, data.monitor_impact);
|
||||
resp = { success: true };
|
||||
}
|
||||
// ============ Monitor Alert Config Actions ============
|
||||
else if (action == "createMonitorAlertConfig") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await CreateMonitorAlertConfig(data);
|
||||
} else if (action == "updateMonitorAlertConfig") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await UpdateMonitorAlertConfig(data);
|
||||
} else if (action == "getMonitorAlertConfig" || action == "getMonitorAlertConfigById") {
|
||||
resp = await GetMonitorAlertConfigById(data.id);
|
||||
@@ -494,11 +474,9 @@ export async function POST({ request, cookies }) {
|
||||
} else if (action == "getMonitorAlertConfigsByMonitorTag") {
|
||||
resp = await GetMonitorAlertConfigsByMonitorTag(data.monitor_tag);
|
||||
} else if (action == "deleteMonitorAlertConfig") {
|
||||
AdminEditorCan(userDB.role);
|
||||
await DeleteMonitorAlertConfig(data.id);
|
||||
resp = { success: true };
|
||||
} else if (action == "toggleMonitorAlertConfigStatus") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await ToggleMonitorAlertConfigStatus(data.id);
|
||||
} else if (action == "getAlertConfigsPaginated") {
|
||||
const page = parseInt(String(data.page)) || 1;
|
||||
@@ -510,7 +488,6 @@ export async function POST({ request, cookies }) {
|
||||
if (data.alert_for) filter.alert_for = data.alert_for as "STATUS" | "LATENCY" | "UPTIME";
|
||||
resp = await GetMonitorAlertConfigsPaginated(page, limit, Object.keys(filter).length > 0 ? filter : undefined);
|
||||
} else if (action == "deleteMonitorAlertV2") {
|
||||
AdminEditorCan(userDB.role);
|
||||
const deleteIncident = data.deleteIncident === true;
|
||||
// If deleteIncident is true, delete the incident first
|
||||
if (deleteIncident && data.incident_id) {
|
||||
@@ -518,7 +495,6 @@ export async function POST({ request, cookies }) {
|
||||
}
|
||||
resp = await DeleteMonitorAlertV2(data.id);
|
||||
} else if (action == "updateMonitorAlertV2Status") {
|
||||
AdminEditorCan(userDB.role);
|
||||
resp = await UpdateMonitorAlertV2Status(data.id, data.status);
|
||||
}
|
||||
|
||||
@@ -565,14 +541,12 @@ export async function POST({ request, cookies }) {
|
||||
} else if (action == "getSubscriberCountsByMethod") {
|
||||
resp = await GetSubscriberCountsByMethod();
|
||||
} else if (action == "deleteUserSubscription") {
|
||||
AdminCan(userDB.role);
|
||||
const { subscriptionId } = data;
|
||||
if (!subscriptionId) {
|
||||
throw new Error("subscriptionId is required");
|
||||
}
|
||||
resp = await DeleteUserSubscription(subscriptionId);
|
||||
} else if (action == "updateUserSubscriptionStatus") {
|
||||
AdminCan(userDB.role);
|
||||
const { subscriptionId, status } = data;
|
||||
if (!subscriptionId || !status) {
|
||||
throw new Error("subscriptionId and status are required");
|
||||
@@ -594,7 +568,6 @@ export async function POST({ request, cookies }) {
|
||||
throw new Error("Template not found");
|
||||
}
|
||||
} else if (action == "updateGeneralEmailTemplate") {
|
||||
AdminEditorCan(userDB.role);
|
||||
const { templateId, template_subject, template_html_body, template_text_body } = data;
|
||||
if (!templateId) {
|
||||
throw new Error("Template ID is required");
|
||||
@@ -614,7 +587,6 @@ export async function POST({ request, cookies }) {
|
||||
const limit = parseInt(String(data.limit)) || 10;
|
||||
resp = await GetAdminSubscribersPaginated(page, limit);
|
||||
} else if (action == "adminUpdateSubscriptionStatus") {
|
||||
AdminEditorCan(userDB.role);
|
||||
const { methodId, eventType, enabled } = data;
|
||||
if (!methodId || !eventType) {
|
||||
throw new Error("Method ID and event type are required");
|
||||
@@ -624,7 +596,6 @@ export async function POST({ request, cookies }) {
|
||||
throw new Error(resp.error);
|
||||
}
|
||||
} else if (action == "adminDeleteSubscriber") {
|
||||
AdminEditorCan(userDB.role);
|
||||
const { methodId } = data;
|
||||
if (!methodId) {
|
||||
throw new Error("Method ID is required");
|
||||
@@ -634,7 +605,6 @@ export async function POST({ request, cookies }) {
|
||||
throw new Error(resp.error);
|
||||
}
|
||||
} else if (action == "adminAddSubscriber") {
|
||||
AdminEditorCan(userDB.role);
|
||||
const { email, incidents, maintenances } = data;
|
||||
if (!email) {
|
||||
throw new Error("Email is required");
|
||||
@@ -644,7 +614,6 @@ export async function POST({ request, cookies }) {
|
||||
throw new Error(resp.error);
|
||||
}
|
||||
} else if (action == "getSubscriptionsConfig") {
|
||||
AdminCan(userDB.role);
|
||||
let subscriptionsSettings = await GetSiteDataByKey("subscriptionsSettings");
|
||||
if (!!!subscriptionsSettings) {
|
||||
subscriptionsSettings = {
|
||||
@@ -669,8 +638,27 @@ export async function POST({ request, cookies }) {
|
||||
}
|
||||
resp = siteData;
|
||||
} else if (action == "updateSubscriptionsConfig") {
|
||||
AdminCan(userDB.role);
|
||||
resp = await InsertKeyValue("subscriptionsSettings", JSON.stringify(data));
|
||||
} else if (action == "getRoles") {
|
||||
resp = await GetAllRoles();
|
||||
} else if (action == "getAllPermissions") {
|
||||
resp = await GetAllPermissions();
|
||||
} else if (action == "getRolePermissions") {
|
||||
resp = await GetRolePermissions(data.roleId);
|
||||
} else if (action == "updateRolePermissions") {
|
||||
resp = await UpdateRolePermissions(data.roleId, data.permissionIds);
|
||||
} else if (action == "getRoleUsers") {
|
||||
resp = await GetRoleUsers(data.roleId);
|
||||
} else if (action == "addUserToRole") {
|
||||
resp = await AddUserToRole(data.roleId, data.userId);
|
||||
} else if (action == "removeUserFromRole") {
|
||||
resp = await RemoveUserFromRole(data.roleId, data.userId);
|
||||
} else if (action == "createRole") {
|
||||
resp = await CreateRole({ role_id: data.role_id, name: data.name });
|
||||
} else if (action == "updateRole") {
|
||||
resp = await UpdateRole(data.roleId, { name: data.name, status: data.status });
|
||||
} else if (action == "deleteRole") {
|
||||
resp = await DeleteRole(data.roleId, data.options);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
|
||||
@@ -273,7 +273,7 @@
|
||||
<Table.Cell class="pr-4 text-right">
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={page.data.userDb.role !== "admin"}
|
||||
disabled={!page.data.userPermissions?.includes("api_keys.delete")}
|
||||
size="sm"
|
||||
onclick={() => openDeleteDialog(apiKey)}
|
||||
>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user