mirror of
https://github.com/rajnandan1/kener.git
synced 2026-06-23 04:10:22 +00:00
refactor: Update site configuration documentation and enhance global page visibility settings
- Revise site configuration documentation to clarify page visibility behavior. - Introduce global page visibility settings with detailed descriptions and functionality. - Modify API server to dynamically select the correct specification path based on environment. - Streamline event fetching logic in event pages to improve performance and maintainability. - Remove unused vault secret management code from the manage API. - Enhance customizations page to support global page visibility settings. - Create new guides for adding custom fonts and custom JavaScript/CSS. - Implement server-side logic for handling events by month with improved date validation.
This commit is contained in:
@@ -1,188 +0,0 @@
|
||||
---
|
||||
name: code-architecture
|
||||
description: Persistent project memory via a `.codearch/` folder.
|
||||
user-invokable: false
|
||||
metadata:
|
||||
category: architecture
|
||||
---
|
||||
|
||||
# code-architecture — Project Memory Skill
|
||||
|
||||
Use this skill at the START and END of every coding session, agentic task, or multi-step file operation. Trigger whenever the user asks you to work on a codebase, fix a bug, refactor, review code, or continue work from a previous session. Also trigger if the user mentions "context," "memory," "remember this," "pick up where we left off," or references prior work. This skill tells you how to read relevant context before acting and how to compress + save what you learned after acting. Always use it — skipping it loses hard-won context.
|
||||
|
||||
`.codearch/` is a folder that lives at the project root. It stores compressed context files so you can resume work without re-deriving what was already figured out.
|
||||
|
||||
---
|
||||
|
||||
## Before Every Run — Load Context
|
||||
|
||||
**Do this before writing any code or answering any question about the codebase.**
|
||||
|
||||
### Step 1: Scan the folder
|
||||
|
||||
```bash
|
||||
ls .codearch/
|
||||
```
|
||||
|
||||
If the folder doesn't exist, skip to the run. You'll create it after.
|
||||
|
||||
### Step 2: Find relevant files
|
||||
|
||||
Two approaches — use both if unsure:
|
||||
|
||||
**A. File name scan** — Read the file names. Match against the current task (e.g., working on auth? look for `auth.md`, `session.md`, `jwt.md`).
|
||||
|
||||
**B. Text search** — Search inside files for keywords from the task.
|
||||
|
||||
```bash
|
||||
grep -ril "<keyword>" .codearch/
|
||||
```
|
||||
|
||||
Run multiple greps if the task spans several concepts.
|
||||
|
||||
### Step 3: Read relevant files
|
||||
|
||||
Read only the files that match. Don't load the entire folder into context unnecessarily.
|
||||
|
||||
```bash
|
||||
cat .codearch/<relevant-file>.md
|
||||
```
|
||||
|
||||
### Step 4: Apply the context
|
||||
|
||||
Use what you read to inform your approach. Don't re-derive decisions already documented. If a file says "we chose X over Y because of Z," respect that unless the user explicitly overrides it.
|
||||
|
||||
---
|
||||
|
||||
## After Every Run — Save Context
|
||||
|
||||
**Do this after completing the task, before ending the session.**
|
||||
|
||||
### Step 1: Write a summary
|
||||
|
||||
Compress what happened into a short summary. Cover:
|
||||
|
||||
- What the task was
|
||||
- What you changed or built
|
||||
- Key decisions made (and why)
|
||||
- Gotchas, constraints, or warnings for next time
|
||||
- Files/modules touched
|
||||
- Anything left unresolved
|
||||
|
||||
Keep it under 200 words. Dense is better than verbose.
|
||||
|
||||
**Example summary:**
|
||||
|
||||
```
|
||||
Task: Refactored auth middleware to support refresh tokens.
|
||||
Changes: Modified src/middleware/auth.go and src/handlers/refresh.go.
|
||||
Decisions: Used Redis for token storage (not DB) — avoids write contention under load.
|
||||
Gotcha: JWT secret is loaded from env at startup only; restart required after rotation.
|
||||
Unresolved: Rate limiting on /refresh endpoint not yet implemented.
|
||||
```
|
||||
|
||||
### Step 2: Find the right file to update
|
||||
|
||||
Check if the summary fits an existing `.codearch/` file:
|
||||
|
||||
```bash
|
||||
ls .codearch/
|
||||
grep -ril "<topic keyword>" .codearch/
|
||||
```
|
||||
|
||||
**Match logic:**
|
||||
|
||||
| Situation | Action |
|
||||
| ---------------------------------- | --------------------------- |
|
||||
| File exists and topic matches | Append summary to that file |
|
||||
| File exists but topic is a stretch | Create a new file |
|
||||
| No file matches | Create a new file |
|
||||
|
||||
### Step 3: Write or update the file
|
||||
|
||||
**Appending to an existing file:**
|
||||
|
||||
```bash
|
||||
cat >> .codearch/<existing-file>.md << 'EOF'
|
||||
|
||||
---
|
||||
## Session: <brief date or task label>
|
||||
|
||||
<your summary here>
|
||||
EOF
|
||||
```
|
||||
|
||||
**Creating a new file:**
|
||||
|
||||
Name it after the topic, not the date. Good names: `auth.md`, `database-schema.md`, `api-design.md`, `deployment.md`. Bad names: `session-2024-01-15.md`, `notes.md`, `misc.md`.
|
||||
|
||||
```bash
|
||||
cat > .codearch/<topic>.md << 'EOF'
|
||||
# <Topic Title>
|
||||
|
||||
## Session: <brief task label>
|
||||
|
||||
<your summary here>
|
||||
EOF
|
||||
```
|
||||
|
||||
### Step 4: Create the folder if it doesn't exist
|
||||
|
||||
```bash
|
||||
mkdir -p .codearch
|
||||
```
|
||||
|
||||
Do this before writing if the folder is missing.
|
||||
|
||||
---
|
||||
|
||||
## File Naming Rules
|
||||
|
||||
- One file per logical domain (`auth`, `billing`, `database`, `api`, `deployment`, `testing`)
|
||||
- Use kebab-case for multi-word topics: `rate-limiting.md`, `background-jobs.md`
|
||||
- Never use dates as file names
|
||||
- Never use generic names: `notes`, `misc`, `temp`, `context`
|
||||
- If a file grows beyond ~300 lines, split it by subtopic
|
||||
|
||||
---
|
||||
|
||||
## What Makes a Good `.codearch` Entry
|
||||
|
||||
**Include:**
|
||||
|
||||
- Decisions and their rationale
|
||||
- Non-obvious constraints (env vars, external dependencies, config quirks)
|
||||
- What was tried and rejected
|
||||
- Known bugs or tech debt left behind
|
||||
- Key file paths and what they do
|
||||
|
||||
**Exclude:**
|
||||
|
||||
- Code snippets (link to files instead)
|
||||
- Step-by-step logs of what you typed
|
||||
- Anything that's obvious from reading the code
|
||||
|
||||
---
|
||||
|
||||
## Example `.codearch/` Structure
|
||||
|
||||
```
|
||||
.codearch/
|
||||
├── auth.md # JWT strategy, refresh token design, middleware notes
|
||||
├── database-schema.md # Table decisions, migration gotchas, index rationale
|
||||
├── api-design.md # REST conventions, versioning decisions, error formats
|
||||
├── deployment.md # Docker setup, env var requirements, CI notes
|
||||
└── background-jobs.md # Queue design, retry logic, failure handling
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| When | What to do |
|
||||
| ---------------------- | ------------------------------------------------------------ |
|
||||
| Start of task | `ls .codearch/` → grep for keywords → read matches → proceed |
|
||||
| End of task | Write summary → find matching file → append or create |
|
||||
| Folder missing | `mkdir -p .codearch` then proceed |
|
||||
| No relevant file found | Skip loading; create one after the run |
|
||||
| Ambiguous match | Read both files; create a new one if neither fits well |
|
||||
@@ -0,0 +1,182 @@
|
||||
---
|
||||
name: code-context
|
||||
description: Persistent code architecture documentation via a `.codecontext/` folder.
|
||||
user-invokable: false
|
||||
metadata:
|
||||
category: architecture
|
||||
---
|
||||
|
||||
# Code Architecture Documentation Skill
|
||||
|
||||
Use this skill to **read architecture docs before work** and **document architecture after work** using the `.codecontext/` folder.
|
||||
|
||||
`.codecontext/` is a living architecture reference — it helps new developers onboard and coding agents pick up where previous sessions left off. It is **NOT** a session log, changelog, or task diary.
|
||||
|
||||
---
|
||||
|
||||
## What Belongs in `.codecontext/`
|
||||
|
||||
Only document **architecture-level knowledge** that would take significant effort to rediscover by reading code alone.
|
||||
|
||||
### Include
|
||||
|
||||
- **Code architecture** — how modules/components are structured, layered, and why
|
||||
- **Code flow** — request lifecycle, data flow between layers, event/cron pipelines
|
||||
- **Component relationships** — which modules depend on each other, call chains, shared state
|
||||
- **Edge cases and gotchas** — non-obvious behaviors, race conditions, ordering constraints
|
||||
- **Design decisions and rationale** — why a pattern was chosen over alternatives
|
||||
- **Integration points** — how external services, databases, queues connect
|
||||
- **Invariants and constraints** — rules that must hold (e.g., "timestamps are always UTC seconds", "all DB access goes through db singleton")
|
||||
- **Error handling patterns** — how errors propagate, retry logic, fallback behavior
|
||||
- **Key file map** — which files own which responsibilities (only when non-obvious)
|
||||
|
||||
### Exclude
|
||||
|
||||
- Session logs, changelogs, or diary-style entries
|
||||
- What files were changed in a specific task
|
||||
- Raw terminal output or build logs
|
||||
- Code snippets (reference file paths + line ranges instead)
|
||||
- Obvious facts that can be inferred from reading one file
|
||||
- Task status, TODO lists, or progress tracking
|
||||
- Anything already covered in README, AGENTS.md, or inline comments
|
||||
|
||||
---
|
||||
|
||||
## Trigger Conditions
|
||||
|
||||
Run this skill at the **start and end** of any coding task that touches architecture:
|
||||
|
||||
- Feature implementations spanning multiple files/modules
|
||||
- Refactors that change module boundaries or data flow
|
||||
- Bug fixes that reveal non-obvious system behavior
|
||||
- New integrations or service connections
|
||||
- Discovery of undocumented edge cases or invariants
|
||||
|
||||
**Skip** for trivial changes (typo fixes, single-line edits, style-only changes).
|
||||
|
||||
---
|
||||
|
||||
## Phase A — Read Architecture Docs (Before Acting)
|
||||
|
||||
### A1) Discover docs
|
||||
|
||||
```bash
|
||||
ls .codecontext/
|
||||
```
|
||||
|
||||
If `.codecontext/` does not exist, continue the task and create it in Phase B.
|
||||
|
||||
### A2) Find relevant docs
|
||||
|
||||
```bash
|
||||
grep -ril "<domain keyword>" .codecontext/
|
||||
```
|
||||
|
||||
Use keywords from the feature area you are working on (e.g., "alerting", "auth", "monitors", "cron").
|
||||
|
||||
### A3) Read and apply
|
||||
|
||||
Read only relevant files. Extract:
|
||||
|
||||
- Architecture constraints that affect your implementation
|
||||
- Code flow you need to hook into or extend
|
||||
- Edge cases to preserve or handle
|
||||
- Integration points to respect
|
||||
|
||||
If existing docs conflict with current code, trust the code — update docs in Phase B.
|
||||
|
||||
---
|
||||
|
||||
## Phase B — Document Architecture (Before Ending)
|
||||
|
||||
Only write/update docs if the task revealed architecture knowledge worth preserving.
|
||||
|
||||
### B1) Decide what to document
|
||||
|
||||
Ask: *"Would a new developer or future agent need to re-discover this to work in this area?"*
|
||||
|
||||
If yes, document it. If no, skip Phase B entirely.
|
||||
|
||||
### B2) Write architecture documentation
|
||||
|
||||
Structure each doc as a **reference document**, not a session diary.
|
||||
|
||||
Template (use only the sections that apply):
|
||||
|
||||
```markdown
|
||||
# <Domain/Feature Area>
|
||||
|
||||
## Overview
|
||||
Brief description of what this area does and its role in the system.
|
||||
|
||||
## Architecture
|
||||
How the components are structured, key abstractions, layers.
|
||||
|
||||
## Code Flow
|
||||
Step-by-step flow for the primary operations (e.g., "How a monitor check executes").
|
||||
|
||||
## Key Files
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `src/lib/server/...` | Does X |
|
||||
|
||||
## Edge Cases and Gotchas
|
||||
- Non-obvious behavior 1
|
||||
- Constraint that must be preserved
|
||||
|
||||
## Design Decisions
|
||||
- Why X was chosen over Y (if non-obvious)
|
||||
```
|
||||
|
||||
Not all sections are required — include only what is relevant. Keep each doc under **300 lines**.
|
||||
|
||||
### B3) Pick target file
|
||||
|
||||
```bash
|
||||
ls .codecontext/
|
||||
grep -ril "<topic keyword>" .codecontext/
|
||||
```
|
||||
|
||||
| Condition | Action |
|
||||
| ---------------------------------- | ------------------------------------- |
|
||||
| Existing doc covers this domain | Update/rewrite relevant sections |
|
||||
| Different domain | Create new file |
|
||||
| No match | Create new file |
|
||||
|
||||
When updating, **replace outdated sections** rather than appending session entries. The doc should always read as a clean, current architecture reference.
|
||||
|
||||
### B4) Persist
|
||||
|
||||
```bash
|
||||
mkdir -p .codecontext
|
||||
```
|
||||
|
||||
Create or overwrite the file so it reads as a standalone reference:
|
||||
|
||||
```bash
|
||||
cat > .codecontext/<domain>.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Naming Rules
|
||||
|
||||
- Name by domain/feature area: `alerting.md`, `auth.md`, `monitor-execution.md`, `incident-lifecycle.md`
|
||||
- Use kebab-case for multi-word topics
|
||||
- Never use generic names: `notes.md`, `misc.md`, `context.md`, `session-1.md`
|
||||
- One file per bounded domain — split if a file exceeds ~300 lines
|
||||
|
||||
---
|
||||
|
||||
## Fast Checklist
|
||||
|
||||
Before coding:
|
||||
|
||||
- [ ] Checked `.codecontext/` for relevant architecture docs
|
||||
- [ ] Applied constraints and patterns from existing docs
|
||||
|
||||
Before finishing:
|
||||
|
||||
- [ ] Documented any new architecture knowledge discovered
|
||||
- [ ] Updated outdated docs if current code contradicts them
|
||||
- [ ] Doc reads as a clean architecture reference, not a session log
|
||||
@@ -1,9 +0,0 @@
|
||||
# Localization
|
||||
|
||||
## Session: locale-key refresh (2026-02-24)
|
||||
|
||||
Task: Updated 18 locale files under `src/lib/locales` for 8 specific UI keys.
|
||||
Changes: Replaced English placeholders with localized values in `de.json`, `dk.json`, `es.json`, `fa.json`, `fr.json`, `hi.json`, `it.json`, `ja.json`, `ko.json`, `nb-NO.json`, `nl.json`, `pl.json`, `pt-BR.json`, `ru.json`, `sk.json`, `tr.json`, `vi.json`, and `zh-CN.json`.
|
||||
Decisions: Kept formatting and structure intact (2-space JSON indentation, only target keys edited). Adjusted French `Notifications` to `Alertes` to satisfy non-English-equality validation.
|
||||
Validation: Ran a script checking the 8 target keys; final result reports no values equal to the original English keys.
|
||||
Gotcha: Shared terminal got stuck in heredoc/REPL during checks; recovered and reran validation with a robust one-liner.
|
||||
@@ -1,10 +0,0 @@
|
||||
# Versioning
|
||||
|
||||
## Session: startup banner version source
|
||||
|
||||
Task: Clarify runtime version source for startup figlet/banner and align with project architecture.
|
||||
Changes: Reverted `src/lib/server/startup.ts` to use shared `version()` from `src/lib/version.ts` instead of reading `package.json` directly with fs path logic.
|
||||
Decision: Keep `src/lib/version.ts` as the single source for version resolution across server/client contexts. This respects code architecture and avoids duplicate version loading paths.
|
||||
Gotcha: If version appears stale at runtime, ensure the app is rebuilt/restarted after bumping `package.json` so injected/runtime values update.
|
||||
Files touched: `src/lib/server/startup.ts`.
|
||||
Verification: `npm run check` passed with 0 errors (existing unrelated warnings only).
|
||||
@@ -119,4 +119,22 @@ Always use `import type { ... }` when importing types to avoid accidental runtim
|
||||
|
||||
# Other skills
|
||||
|
||||
Read files in .claude/skills for more instructions on specific tasks or file types.
|
||||
Read files in .claude/skills for more instructions on specific tasks or file types.
|
||||
|
||||
## Code Architecture Documentation (MUST)
|
||||
|
||||
For every coding task that touches architecture (multi-file features, refactors, new integrations):
|
||||
|
||||
1. **Before edits**
|
||||
- Read and apply `.claude/skills/code-context/SKILL.md`.
|
||||
- Load relevant architecture docs from `.codecontext/` when present.
|
||||
|
||||
2. **Before finishing the response**
|
||||
- If the task revealed new architecture knowledge (code flow, edge cases, component relationships, design decisions), write/update a `.codecontext/*.md` entry as a clean reference doc.
|
||||
- Skip if the task was trivial (typo fixes, single-line edits).
|
||||
|
||||
3. **Final response contract**
|
||||
- Include a short line: `Context loaded: ...`
|
||||
- Include a short line: `Context updated: ...`
|
||||
|
||||
`.codecontext/` documents **code architecture only** — not session logs, changelogs, or task summaries.
|
||||
@@ -30,8 +30,22 @@ When the user asks to write or edit documentation, follow the skill file:
|
||||
|
||||
This is mandatory for docs-related tasks. Prioritize short, clear, action-oriented docs and avoid bloat.
|
||||
|
||||
## Code architecture skill - Important for all coding tasks
|
||||
## Code architecture docs skill - Important for all tasks
|
||||
|
||||
Always try to use the code architecture skill at the start and end of coding sessions:
|
||||
Always try to use the code-context skill at the start and end of coding sessions:
|
||||
|
||||
- `.claude/skills/code-architecture/SKILL.md`
|
||||
- `.claude/skills/code-context/SKILL.md`
|
||||
|
||||
## Code architecture enforcement (mandatory)
|
||||
|
||||
The code-context skill is not optional. Agents MUST do both:
|
||||
|
||||
1. **Before coding**: load relevant architecture docs from `.codecontext/`.
|
||||
2. **Before final response**: if the task revealed new architecture knowledge (code flow, edge cases, component relationships), update or create a `.codecontext/*.md` entry. Skip if the task was trivial.
|
||||
|
||||
Required output evidence in the final response:
|
||||
|
||||
- `Context loaded:` list of `.codecontext` files read (or `none found`).
|
||||
- `Context updated:` exact `.codecontext` file path written (or `skipped — no architecture changes`).
|
||||
|
||||
The `.codecontext/` folder documents **code architecture only** — not session logs, changelogs, or task summaries.
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
table.string("rrule", 500).notNullable();
|
||||
table.integer("duration_seconds").notNullable();
|
||||
table.string("status", 50).notNullable().defaultTo("ACTIVE");
|
||||
table.string("is_global", 15).notNullable().defaultTo("YES");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
@@ -20,6 +21,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
table.increments("id").primary();
|
||||
table.integer("maintenance_id").unsigned().notNullable();
|
||||
table.string("monitor_tag", 255).notNullable();
|
||||
table.string("monitor_impact").defaultTo("MAINTENANCE").notNullable();
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable("maintenance_monitors"))) return;
|
||||
const hasCol = await knex.schema.hasColumn("maintenance_monitors", "monitor_impact");
|
||||
if (!hasCol) {
|
||||
await knex.schema.alterTable("maintenance_monitors", (table) => {
|
||||
@@ -10,6 +11,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable("maintenance_monitors"))) return;
|
||||
await knex.schema.alterTable("maintenance_monitors", (table) => {
|
||||
table.dropColumn("monitor_impact");
|
||||
});
|
||||
|
||||
@@ -27,12 +27,46 @@ export async function up(knex: Knex): Promise<void> {
|
||||
table.text("meta").nullable();
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// Add indexes, unique constraints, and foreign keys for subscriber_methods (idempotent)
|
||||
try {
|
||||
await knex.schema.alterTable("subscriber_methods", (table) => {
|
||||
table.index(["subscriber_user_id"]);
|
||||
});
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("subscriber_methods", (table) => {
|
||||
table.index(["method_type"]);
|
||||
});
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("subscriber_methods", (table) => {
|
||||
table.index(["status"]);
|
||||
table.unique(["subscriber_user_id", "method_type", "method_value"]);
|
||||
});
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("subscriber_methods", (table) => {
|
||||
table.unique(["subscriber_user_id", "method_type", "method_value"], {
|
||||
indexName: "sub_methods_user_type_value_unique",
|
||||
});
|
||||
});
|
||||
} catch (_e) {
|
||||
/* unique constraint already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("subscriber_methods", (table) => {
|
||||
table.foreign("subscriber_user_id").references("id").inTable("subscriber_users").onDelete("CASCADE");
|
||||
});
|
||||
} catch (_e) {
|
||||
/* foreign key already exists */
|
||||
}
|
||||
|
||||
// 3. Create user_subscriptions_v2 table
|
||||
@@ -45,14 +79,60 @@ export async function up(knex: Knex): Promise<void> {
|
||||
table.string("status", 20).notNullable().defaultTo("ACTIVE");
|
||||
table.timestamp("created_at").defaultTo(knex.fn.now());
|
||||
table.timestamp("updated_at").defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
// Add indexes, unique constraints, and foreign keys for user_subscriptions_v2 (idempotent)
|
||||
try {
|
||||
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
|
||||
table.index(["subscriber_user_id"]);
|
||||
});
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
|
||||
table.index(["subscriber_method_id"]);
|
||||
});
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
|
||||
table.index(["event_type"]);
|
||||
});
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
|
||||
table.index(["status"]);
|
||||
table.unique(["subscriber_user_id", "subscriber_method_id", "event_type"]);
|
||||
});
|
||||
} catch (_e) {
|
||||
/* index already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
|
||||
table.unique(["subscriber_user_id", "subscriber_method_id", "event_type"], {
|
||||
indexName: "sub_v2_user_method_event_unique",
|
||||
});
|
||||
});
|
||||
} catch (_e) {
|
||||
/* unique constraint already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
|
||||
table.foreign("subscriber_user_id").references("id").inTable("subscriber_users").onDelete("CASCADE");
|
||||
});
|
||||
} catch (_e) {
|
||||
/* foreign key already exists */
|
||||
}
|
||||
try {
|
||||
await knex.schema.alterTable("user_subscriptions_v2", (table) => {
|
||||
table.foreign("subscriber_method_id").references("id").inTable("subscriber_methods").onDelete("CASCADE");
|
||||
});
|
||||
} catch (_e) {
|
||||
/* foreign key already exists */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
table.string("is_global", 15).notNullable().defaultTo("YES");
|
||||
});
|
||||
}
|
||||
if (!(await knex.schema.hasColumn("maintenances", "is_global"))) {
|
||||
if ((await knex.schema.hasTable("maintenances")) && !(await knex.schema.hasColumn("maintenances", "is_global"))) {
|
||||
await knex.schema.table("maintenances", (table) => {
|
||||
table.string("is_global", 15).notNullable().defaultTo("YES");
|
||||
});
|
||||
@@ -17,7 +17,9 @@ export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.table("incidents", (table) => {
|
||||
table.dropColumn("is_global");
|
||||
});
|
||||
await knex.schema.table("maintenances", (table) => {
|
||||
table.dropColumn("is_global");
|
||||
});
|
||||
if (await knex.schema.hasTable("maintenances")) {
|
||||
await knex.schema.table("maintenances", (table) => {
|
||||
table.dropColumn("is_global");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@ interface DocsSidebarGroup {
|
||||
|
||||
interface DocsNavTab {
|
||||
name: string;
|
||||
sidebar: DocsSidebarGroup[];
|
||||
url?: string;
|
||||
sidebar?: DocsSidebarGroup[];
|
||||
}
|
||||
|
||||
interface DocsVersion {
|
||||
@@ -164,14 +165,16 @@ async function main(): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const primaryTabSidebar = latestVersion.content.navigation?.tabs?.[0]?.sidebar ?? [];
|
||||
const sidebar = normalizeSidebar(primaryTabSidebar);
|
||||
const tabs = latestVersion.content.navigation?.tabs ?? [];
|
||||
const documents: DocsSearchDocument[] = [];
|
||||
|
||||
// Collect all pages from sidebar
|
||||
// Collect all pages from all tabs' sidebars
|
||||
const allPages: Array<{ page: DocsPageSource; group: string }> = [];
|
||||
for (const sidebarGroup of sidebar) {
|
||||
collectPages(sidebarGroup.pages, sidebarGroup.group, allPages);
|
||||
for (const tab of tabs) {
|
||||
const sidebar = normalizeSidebar(tab.sidebar ?? []);
|
||||
for (const sidebarGroup of sidebar) {
|
||||
collectPages(sidebarGroup.pages, sidebarGroup.group, allPages);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[index-docs] Indexing version ${latestVersion.slug}`);
|
||||
|
||||
@@ -11,7 +11,15 @@
|
||||
|
||||
let { data } = page;
|
||||
const navItems: { name: string; url: string; iconURL: string }[] = data.navItems || [];
|
||||
const { siteName, siteUrl, logo } = data;
|
||||
const { siteName, logo, globalPageVisibilitySettings } = data;
|
||||
|
||||
const brandPath = $derived.by(() => {
|
||||
if (globalPageVisibilitySettings?.forceExclusivity) {
|
||||
const currentPagePath = page.params?.page_path?.trim();
|
||||
return currentPagePath ? `/${currentPagePath}` : "/";
|
||||
}
|
||||
return "/";
|
||||
});
|
||||
|
||||
function trackBrandClick() {
|
||||
trackEvent("nav_brand_clicked", { name: siteName });
|
||||
@@ -29,7 +37,7 @@
|
||||
>
|
||||
<!-- Brand -->
|
||||
<a
|
||||
href={clientResolver(resolve, siteUrl)}
|
||||
href={clientResolver(resolve, brandPath)}
|
||||
class="{navigationMenuTriggerStyle()} hover:border-border border border-transparent bg-transparent text-xs hover:bg-transparent"
|
||||
style="border-radius: var(--radius-3xl)"
|
||||
onclick={trackBrandClick}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from "$app/paths";
|
||||
import { page } from "$app/state";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import * as Popover from "$lib/components/ui/popover/index.js";
|
||||
import { Spinner } from "$lib/components/ui/spinner/index.js";
|
||||
@@ -9,6 +10,7 @@
|
||||
import { t } from "$lib/stores/i18n";
|
||||
import type { NotificationEvent } from "$lib/server/controllers/dashboardController.js";
|
||||
import Calendar from "@lucide/svelte/icons/calendar-1";
|
||||
import { format } from "date-fns";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -22,6 +24,17 @@
|
||||
let notifications = $state<NotificationEvent[]>([]);
|
||||
let loading = $state(false);
|
||||
|
||||
const defaultEventsPath = $derived(`/events/${format(new Date(), "MMMM-yyyy")}`);
|
||||
|
||||
const resolvedEventsPath = $derived.by(() => {
|
||||
const finalEventsPath = eventsPath || defaultEventsPath;
|
||||
if (page.data?.globalPageVisibilitySettings?.forceExclusivity) {
|
||||
const currentPagePath = page.params?.page_path?.trim();
|
||||
return currentPagePath ? `/${currentPagePath}${finalEventsPath}` : finalEventsPath;
|
||||
}
|
||||
return finalEventsPath;
|
||||
});
|
||||
|
||||
async function fetchNotifications() {
|
||||
loading = true;
|
||||
try {
|
||||
@@ -76,7 +89,7 @@
|
||||
>
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h4 class="text-sm font-semibold">{$t("Events")}</h4>
|
||||
<Button variant="outline" href={clientResolver(resolve, eventsPath)} size="icon-sm" class="rounded-btn">
|
||||
<Button variant="outline" href={clientResolver(resolve, resolvedEventsPath)} size="icon-sm" class="rounded-btn">
|
||||
<Calendar class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
import Sun from "@lucide/svelte/icons/sun";
|
||||
import Moon from "@lucide/svelte/icons/moon";
|
||||
import Share from "@lucide/svelte/icons/share-2";
|
||||
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
|
||||
import { format } from "date-fns";
|
||||
import SubscribeMenu from "$lib/components/SubscribeMenu.svelte";
|
||||
import CopyButton from "$lib/components/CopyButton.svelte";
|
||||
@@ -79,7 +78,9 @@
|
||||
</script>
|
||||
|
||||
<div class="theme-plus-bar scrollbar-hidden sticky top-18 z-20 flex w-full items-center gap-2 rounded py-2">
|
||||
<PageSelector />
|
||||
{#if !!!page.data.globalPageVisibilitySettings.forceExclusivity && page.data.globalPageVisibilitySettings.showSwitcher}
|
||||
<PageSelector />
|
||||
{/if}
|
||||
<div class="ml-auto flex shrink-0 items-center gap-2">
|
||||
{#if page.data.isSubsEnabled && page.data.canSendEmail}
|
||||
<ButtonGroup.Root class="hidden shrink-0 sm:flex">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json, error } from "@sveltejs/kit";
|
||||
import type { APIServerRequest } from "$lib/server/types/api-server";
|
||||
import type { IncidentForMonitorListWithComments, MaintenanceEventsMonitorList } from "$lib/server/types/db";
|
||||
import db from "$lib/server/db/db";
|
||||
import { GetAllSiteData } from "$lib/server/controllers/siteDataController";
|
||||
|
||||
interface EventsByMonthRequest {
|
||||
start_ts: number;
|
||||
@@ -15,6 +16,13 @@ export interface EventsByMonthResponse {
|
||||
|
||||
export default async function post(req: APIServerRequest): Promise<Response> {
|
||||
const body = req.body as EventsByMonthRequest;
|
||||
const queryParams = req.query;
|
||||
const rawPagePath = queryParams.get("page_path");
|
||||
let pagePath = rawPagePath?.trim() || null;
|
||||
|
||||
if (pagePath && ["undefined", "null"].includes(pagePath.toLowerCase())) {
|
||||
pagePath = null;
|
||||
}
|
||||
|
||||
if (!body.start_ts || typeof body.start_ts !== "number") {
|
||||
return error(400, { message: "start_ts is required and must be a number" });
|
||||
@@ -24,9 +32,28 @@ export default async function post(req: APIServerRequest): Promise<Response> {
|
||||
return error(400, { message: "end_ts is required and must be a number" });
|
||||
}
|
||||
|
||||
// get site data
|
||||
const siteData = await GetAllSiteData();
|
||||
const { globalPageVisibilitySettings } = siteData;
|
||||
const isExclusivePageEnabled = !!globalPageVisibilitySettings?.forceExclusivity;
|
||||
|
||||
if (isExclusivePageEnabled && !pagePath) {
|
||||
pagePath = "";
|
||||
}
|
||||
|
||||
let pathMonitors: string[] | undefined = undefined;
|
||||
if (pagePath !== null) {
|
||||
const pageData = await db.getPageByPath(pagePath);
|
||||
if (!pageData) {
|
||||
return error(404, { message: "Page not found" });
|
||||
}
|
||||
const monitorsForPage = await db.getPageMonitorsExcludeHidden(pageData.id);
|
||||
pathMonitors = monitorsForPage.map((m) => m.monitor_tag);
|
||||
}
|
||||
|
||||
const [incidents, maintenances] = await Promise.all([
|
||||
db.getIncidentsForEventsByDateRange(body.start_ts, body.end_ts),
|
||||
db.getMaintenanceEventsForEventsByDateRange(body.start_ts, body.end_ts),
|
||||
db.getIncidentsForEventsByDateRange(body.start_ts, body.end_ts, pathMonitors),
|
||||
db.getMaintenanceEventsForEventsByDateRange(body.start_ts, body.end_ts, pathMonitors),
|
||||
]);
|
||||
|
||||
const response: EventsByMonthResponse = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Cookies } from "@sveltejs/kit";
|
||||
import type { UserRecordPublic } from "$lib/server/types/db";
|
||||
import seedSiteData from "$lib/server/db/seedSiteData";
|
||||
import { GetAllSiteData, GetLoggedInSession, GetLocaleFromCookie, GetUsersCount, IsEmailSetup } from "./controller.js";
|
||||
import type { EventDisplaySettings } from "$lib/types/site.js";
|
||||
import type { EventDisplaySettings, GlobalPageVisibilitySettings } from "$lib/types/site.js";
|
||||
|
||||
export interface LayoutServerData {
|
||||
isMobile: boolean;
|
||||
@@ -62,6 +62,7 @@ export interface LayoutServerData {
|
||||
eventDisplaySettings: EventDisplaySettings;
|
||||
socialPreviewImage?: string;
|
||||
customCSS?: string;
|
||||
globalPageVisibilitySettings: GlobalPageVisibilitySettings;
|
||||
}
|
||||
|
||||
export async function GetLayoutServerData(cookies: Cookies, request: Request): Promise<LayoutServerData> {
|
||||
@@ -128,5 +129,6 @@ export async function GetLayoutServerData(cookies: Cookies, request: Request): P
|
||||
eventDisplaySettings: siteData.eventDisplaySettings || seedSiteData.eventDisplaySettings,
|
||||
socialPreviewImage: siteData.socialPreviewImage,
|
||||
customCSS: siteData.customCSS,
|
||||
globalPageVisibilitySettings: siteData.globalPageVisibilitySettings || seedSiteData.globalPageVisibilitySettings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Cookies } from "@sveltejs/kit";
|
||||
import type {
|
||||
DataRetentionPolicy,
|
||||
EventDisplaySettings,
|
||||
GlobalPageVisibilitySettings,
|
||||
SiteAnalyticsItem,
|
||||
SiteAnnouncement,
|
||||
SiteCategory,
|
||||
@@ -56,6 +57,7 @@ export interface SiteDataTransformed {
|
||||
eventDisplaySettings?: EventDisplaySettings;
|
||||
socialPreviewImage?: string;
|
||||
customCSS?: string;
|
||||
globalPageVisibilitySettings?: GlobalPageVisibilitySettings;
|
||||
}
|
||||
|
||||
export function InsertKeyValue(key: string, value: string): Promise<number[]> {
|
||||
|
||||
@@ -261,4 +261,9 @@ export const siteDataKeys: SiteDataKey[] = [
|
||||
isValid: (value) => typeof value === "string",
|
||||
data_type: "string",
|
||||
},
|
||||
{
|
||||
key: "globalPageVisibilitySettings",
|
||||
isValid: IsValidJSONString,
|
||||
data_type: "object",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import db from "$lib/server/db/db";
|
||||
import type { VaultRecord } from "$lib/server/db/repositories/vault";
|
||||
|
||||
const DUMMY_SECRET = "DUMMY_SECRET_KEY_32_BYTES_LONG!";
|
||||
const ALGORITHM = "aes-256-cbc";
|
||||
const IV_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* Get the encryption key from environment variable
|
||||
* Pads or truncates to 32 bytes for AES-256
|
||||
*/
|
||||
function getEncryptionKey(): Buffer {
|
||||
const key = process.env.KENER_SECRET_KEY || DUMMY_SECRET;
|
||||
// AES-256 requires a 32-byte key
|
||||
const keyBuffer = Buffer.alloc(32);
|
||||
Buffer.from(key).copy(keyBuffer);
|
||||
return keyBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a value using AES-256-CBC
|
||||
*/
|
||||
export function encryptValue(plainText: string): string {
|
||||
const key = getEncryptionKey();
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
let encrypted = cipher.update(plainText, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
// Prepend IV to encrypted data (IV:encrypted)
|
||||
return iv.toString("hex") + ":" + encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a value using AES-256-CBC
|
||||
*/
|
||||
export function decryptValue(encryptedText: string): string {
|
||||
const key = getEncryptionKey();
|
||||
const parts = encryptedText.split(":");
|
||||
if (parts.length !== 2) {
|
||||
throw new Error("Invalid encrypted value format");
|
||||
}
|
||||
const iv = Buffer.from(parts[0], "hex");
|
||||
const encrypted = parts[1];
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
let decrypted = decipher.update(encrypted, "hex", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for decrypted vault secret
|
||||
*/
|
||||
export interface VaultSecretDecrypted {
|
||||
id: number;
|
||||
secret_name: string;
|
||||
secret_value: string; // Decrypted value
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all secrets with decrypted values
|
||||
*/
|
||||
export async function GetAllSecrets(): Promise<VaultSecretDecrypted[]> {
|
||||
const secrets = await db.getAllSecrets();
|
||||
return secrets.map((secret) => ({
|
||||
...secret,
|
||||
secret_value: decryptValue(secret.secret_value),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a secret by ID with decrypted value
|
||||
*/
|
||||
export async function GetSecretById(id: number): Promise<VaultSecretDecrypted | undefined> {
|
||||
const secret = await db.getSecretById(id);
|
||||
if (!secret) return undefined;
|
||||
return {
|
||||
...secret,
|
||||
secret_value: decryptValue(secret.secret_value),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a secret by name with decrypted value
|
||||
*/
|
||||
export async function GetSecretByName(secretName: string): Promise<VaultSecretDecrypted | undefined> {
|
||||
const secret = await db.getSecretByName(secretName);
|
||||
if (!secret) return undefined;
|
||||
return {
|
||||
...secret,
|
||||
secret_value: decryptValue(secret.secret_value),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new secret (encrypts the value before storing)
|
||||
*/
|
||||
export async function CreateSecret(
|
||||
secretName: string,
|
||||
secretValue: string,
|
||||
): Promise<{ success: boolean; error?: string; id?: number }> {
|
||||
// Validate input
|
||||
if (!secretName || !secretName.trim()) {
|
||||
return { success: false, error: "Secret name is required" };
|
||||
}
|
||||
if (!secretValue) {
|
||||
return { success: false, error: "Secret value is required" };
|
||||
}
|
||||
|
||||
// Check if name already exists
|
||||
const exists = await db.secretNameExists(secretName.trim());
|
||||
if (exists) {
|
||||
return { success: false, error: `Secret with name "${secretName}" already exists` };
|
||||
}
|
||||
|
||||
// Encrypt and store
|
||||
const encryptedValue = encryptValue(secretValue);
|
||||
const ids = await db.insertSecret({
|
||||
secret_name: secretName.trim(),
|
||||
secret_value: encryptedValue,
|
||||
});
|
||||
|
||||
return { success: true, id: ids[0] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a secret by ID (encrypts the new value before storing)
|
||||
*/
|
||||
export async function UpdateSecret(
|
||||
id: number,
|
||||
data: { secret_name?: string; secret_value?: string },
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Check if secret exists
|
||||
const existing = await db.getSecretById(id);
|
||||
if (!existing) {
|
||||
return { success: false, error: `Secret with ID ${id} not found` };
|
||||
}
|
||||
|
||||
// If updating name, check for duplicates
|
||||
if (data.secret_name && data.secret_name !== existing.secret_name) {
|
||||
const nameExists = await db.secretNameExists(data.secret_name.trim(), id);
|
||||
if (nameExists) {
|
||||
return { success: false, error: `Secret with name "${data.secret_name}" already exists` };
|
||||
}
|
||||
}
|
||||
|
||||
// Build update data
|
||||
const updateData: { secret_name?: string; secret_value?: string } = {};
|
||||
if (data.secret_name !== undefined) {
|
||||
updateData.secret_name = data.secret_name.trim();
|
||||
}
|
||||
if (data.secret_value !== undefined) {
|
||||
updateData.secret_value = encryptValue(data.secret_value);
|
||||
}
|
||||
|
||||
await db.updateSecretById(id, updateData);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a secret by ID
|
||||
*/
|
||||
export async function DeleteSecret(id: number): Promise<{ success: boolean; error?: string }> {
|
||||
const existing = await db.getSecretById(id);
|
||||
if (!existing) {
|
||||
return { success: false, error: `Secret with ID ${id} not found` };
|
||||
}
|
||||
|
||||
await db.deleteSecretById(id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get secrets count
|
||||
*/
|
||||
export async function GetSecretsCount(): Promise<number> {
|
||||
return await db.getSecretsCount();
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import { MaintenancesRepository } from "./repositories/maintenances.js";
|
||||
import { MonitorAlertConfigRepository } from "./repositories/monitorAlertConfig.js";
|
||||
import { SubscriptionSystemRepository } from "./repositories/subscriptionSystem.js";
|
||||
import { EmailTemplateConfigRepository } from "./repositories/emailTemplateConfig.js";
|
||||
import { VaultRepository } from "./repositories/vault.js";
|
||||
|
||||
// Re-export types from base
|
||||
export type { MonitorFilter, TriggerFilter, IncidentFilter, CountResult } from "./repositories/base.js";
|
||||
@@ -44,7 +43,6 @@ class DbImpl {
|
||||
private monitorAlertConfig!: MonitorAlertConfigRepository;
|
||||
private subscriptionSystem!: SubscriptionSystemRepository;
|
||||
private emailTemplateConfig!: EmailTemplateConfigRepository;
|
||||
private vault!: VaultRepository;
|
||||
|
||||
// Method bindings - declared with definite assignment assertion
|
||||
// ============ Monitoring Data ============
|
||||
@@ -347,18 +345,6 @@ class DbImpl {
|
||||
deleteEmailTemplate!: EmailTemplateConfigRepository["deleteEmailTemplate"];
|
||||
upsertEmailTemplate!: EmailTemplateConfigRepository["upsertEmailTemplate"];
|
||||
|
||||
// ============ Vault ============
|
||||
getAllSecrets!: VaultRepository["getAllSecrets"];
|
||||
getSecretById!: VaultRepository["getSecretById"];
|
||||
getSecretByName!: VaultRepository["getSecretByName"];
|
||||
insertSecret!: VaultRepository["insertSecret"];
|
||||
updateSecretById!: VaultRepository["updateSecretById"];
|
||||
updateSecretByName!: VaultRepository["updateSecretByName"];
|
||||
deleteSecretById!: VaultRepository["deleteSecretById"];
|
||||
deleteSecretByName!: VaultRepository["deleteSecretByName"];
|
||||
secretNameExists!: VaultRepository["secretNameExists"];
|
||||
getSecretsCount!: VaultRepository["getSecretsCount"];
|
||||
|
||||
constructor(opts: KnexType.Config) {
|
||||
this.knex = Knex(opts);
|
||||
|
||||
@@ -375,7 +361,6 @@ class DbImpl {
|
||||
this.monitorAlertConfig = new MonitorAlertConfigRepository(this.knex);
|
||||
this.subscriptionSystem = new SubscriptionSystemRepository(this.knex);
|
||||
this.emailTemplateConfig = new EmailTemplateConfigRepository(this.knex);
|
||||
this.vault = new VaultRepository(this.knex);
|
||||
|
||||
// Bind methods after repositories are initialized
|
||||
this.bindMonitoringMethods();
|
||||
@@ -390,7 +375,6 @@ class DbImpl {
|
||||
this.bindMonitorAlertConfigMethods();
|
||||
this.bindSubscriptionSystemMethods();
|
||||
this.bindEmailTemplateConfigMethods();
|
||||
this.bindVaultMethods();
|
||||
|
||||
this.init();
|
||||
}
|
||||
@@ -794,19 +778,6 @@ class DbImpl {
|
||||
this.upsertEmailTemplate = this.emailTemplateConfig.upsertEmailTemplate.bind(this.emailTemplateConfig);
|
||||
}
|
||||
|
||||
private bindVaultMethods(): void {
|
||||
this.getAllSecrets = this.vault.getAllSecrets.bind(this.vault);
|
||||
this.getSecretById = this.vault.getSecretById.bind(this.vault);
|
||||
this.getSecretByName = this.vault.getSecretByName.bind(this.vault);
|
||||
this.insertSecret = this.vault.insertSecret.bind(this.vault);
|
||||
this.updateSecretById = this.vault.updateSecretById.bind(this.vault);
|
||||
this.updateSecretByName = this.vault.updateSecretByName.bind(this.vault);
|
||||
this.deleteSecretById = this.vault.deleteSecretById.bind(this.vault);
|
||||
this.deleteSecretByName = this.vault.deleteSecretByName.bind(this.vault);
|
||||
this.secretNameExists = this.vault.secretNameExists.bind(this.vault);
|
||||
this.getSecretsCount = this.vault.getSecretsCount.bind(this.vault);
|
||||
}
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async close(): Promise<void> {
|
||||
|
||||
@@ -826,8 +826,9 @@ export class IncidentsRepository extends BaseRepository {
|
||||
async getIncidentsForEventsByDateRange(
|
||||
startTs: number,
|
||||
endTs: number,
|
||||
monitorTags?: string[],
|
||||
): Promise<IncidentForMonitorListWithComments[]> {
|
||||
const rows = await this.knex("incidents")
|
||||
const query = this.knex("incidents")
|
||||
.select(
|
||||
"incidents.id",
|
||||
"incidents.title",
|
||||
@@ -848,8 +849,15 @@ export class IncidentsRepository extends BaseRepository {
|
||||
.where("incidents.incident_type", GC.INCIDENT)
|
||||
.andWhere("incidents.status", "OPEN")
|
||||
.andWhere("incidents.start_date_time", ">=", startTs)
|
||||
.andWhere("incidents.start_date_time", "<=", endTs)
|
||||
.orderBy("incidents.start_date_time", "desc");
|
||||
.andWhere("incidents.start_date_time", "<=", endTs);
|
||||
|
||||
if (monitorTags) {
|
||||
query.andWhere(function () {
|
||||
this.whereIn("incident_monitors.monitor_tag", monitorTags).orWhere("incidents.is_global", "YES");
|
||||
});
|
||||
}
|
||||
|
||||
const rows = await query.orderBy("incidents.start_date_time", "desc");
|
||||
|
||||
const incidents = this.groupIncidentsByIdForMonitorListFilterHidden(rows);
|
||||
|
||||
|
||||
@@ -631,8 +631,9 @@ export class MaintenancesRepository extends BaseRepository {
|
||||
async getMaintenanceEventsForEventsByDateRange(
|
||||
startTs: number,
|
||||
endTs: number,
|
||||
monitorTags?: string[],
|
||||
): Promise<MaintenanceEventsMonitorList[]> {
|
||||
const rows = await this.knex("maintenances_events")
|
||||
const query = this.knex("maintenances_events")
|
||||
.select(
|
||||
"maintenances_events.id",
|
||||
"maintenances.title",
|
||||
@@ -652,8 +653,15 @@ export class MaintenancesRepository extends BaseRepository {
|
||||
.leftJoin("maintenance_monitors", "maintenances_events.maintenance_id", "maintenance_monitors.maintenance_id")
|
||||
.leftJoin("monitors", "maintenance_monitors.monitor_tag", "monitors.tag")
|
||||
.andWhere("maintenances_events.start_date_time", ">=", startTs)
|
||||
.andWhere("maintenances_events.start_date_time", "<=", endTs)
|
||||
.orderBy("maintenances_events.start_date_time", "desc");
|
||||
.andWhere("maintenances_events.start_date_time", "<=", endTs);
|
||||
|
||||
if (monitorTags) {
|
||||
query.andWhere(function () {
|
||||
this.whereIn("maintenance_monitors.monitor_tag", monitorTags).orWhere("maintenances.is_global", "YES");
|
||||
});
|
||||
}
|
||||
|
||||
const rows = await query.orderBy("maintenances_events.start_date_time", "desc");
|
||||
|
||||
return this.groupMaintenancesByIdForMonitorList(rows);
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { BaseRepository } from "./base.js";
|
||||
|
||||
export interface VaultRecord {
|
||||
id: number;
|
||||
secret_name: string;
|
||||
secret_value: string; // Encrypted value in DB
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface VaultInsert {
|
||||
secret_name: string;
|
||||
secret_value: string; // Should be encrypted before insert
|
||||
}
|
||||
|
||||
export interface VaultUpdate {
|
||||
secret_name?: string;
|
||||
secret_value?: string; // Should be encrypted before update
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository for vault (secrets) operations
|
||||
* Note: Encryption/decryption should be handled at the controller level
|
||||
*/
|
||||
export class VaultRepository extends BaseRepository {
|
||||
/**
|
||||
* Get all vault secrets
|
||||
*/
|
||||
async getAllSecrets(): Promise<VaultRecord[]> {
|
||||
return await this.knex("vault").orderBy("id", "asc");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a secret by ID
|
||||
*/
|
||||
async getSecretById(id: number): Promise<VaultRecord | undefined> {
|
||||
return await this.knex("vault").where({ id }).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a secret by name
|
||||
*/
|
||||
async getSecretByName(secretName: string): Promise<VaultRecord | undefined> {
|
||||
return await this.knex("vault").where({ secret_name: secretName }).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new secret
|
||||
*/
|
||||
async insertSecret(data: VaultInsert): Promise<number[]> {
|
||||
return await this.knex("vault").insert({
|
||||
secret_name: data.secret_name,
|
||||
secret_value: data.secret_value,
|
||||
created_at: this.knex.fn.now(),
|
||||
updated_at: this.knex.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a secret by ID
|
||||
*/
|
||||
async updateSecretById(id: number, data: VaultUpdate): Promise<number> {
|
||||
const updateData: Record<string, unknown> = {
|
||||
updated_at: this.knex.fn.now(),
|
||||
};
|
||||
|
||||
if (data.secret_name !== undefined) {
|
||||
updateData.secret_name = data.secret_name;
|
||||
}
|
||||
if (data.secret_value !== undefined) {
|
||||
updateData.secret_value = data.secret_value;
|
||||
}
|
||||
|
||||
return await this.knex("vault").where({ id }).update(updateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a secret by name
|
||||
*/
|
||||
async updateSecretByName(secretName: string, data: VaultUpdate): Promise<number> {
|
||||
const updateData: Record<string, unknown> = {
|
||||
updated_at: this.knex.fn.now(),
|
||||
};
|
||||
|
||||
if (data.secret_name !== undefined) {
|
||||
updateData.secret_name = data.secret_name;
|
||||
}
|
||||
if (data.secret_value !== undefined) {
|
||||
updateData.secret_value = data.secret_value;
|
||||
}
|
||||
|
||||
return await this.knex("vault").where({ secret_name: secretName }).update(updateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a secret by ID
|
||||
*/
|
||||
async deleteSecretById(id: number): Promise<number> {
|
||||
return await this.knex("vault").where({ id }).del();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a secret by name
|
||||
*/
|
||||
async deleteSecretByName(secretName: string): Promise<number> {
|
||||
return await this.knex("vault").where({ secret_name: secretName }).del();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a secret name already exists
|
||||
*/
|
||||
async secretNameExists(secretName: string, excludeId?: number): Promise<boolean> {
|
||||
let query = this.knex("vault").where({ secret_name: secretName });
|
||||
if (excludeId !== undefined) {
|
||||
query = query.andWhereNot({ id: excludeId });
|
||||
}
|
||||
const result = await query.first();
|
||||
return !!result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get secrets count
|
||||
*/
|
||||
async getSecretsCount(): Promise<number> {
|
||||
const result = await this.knex("vault").count("id as count").first();
|
||||
return Number(result?.count || 0);
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,10 @@ const seedSiteData = {
|
||||
upcoming: { show: true, maxCount: 5, daysInFuture: 7 },
|
||||
},
|
||||
},
|
||||
globalPageVisibilitySettings: {
|
||||
showSwitcher: true,
|
||||
forceExclusivity: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default seedSiteData;
|
||||
|
||||
@@ -125,3 +125,8 @@ export interface EventDisplaySettings {
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface GlobalPageVisibilitySettings {
|
||||
showSwitcher: boolean;
|
||||
forceExclusivity: boolean;
|
||||
}
|
||||
|
||||
@@ -252,6 +252,14 @@
|
||||
{
|
||||
"title": "Base Path Deployment",
|
||||
"content": "v4/guides/base-path"
|
||||
},
|
||||
{
|
||||
"title": "Custom Fonts",
|
||||
"content": "v4/guides/custom-fonts"
|
||||
},
|
||||
{
|
||||
"title": "Custom JS & CSS",
|
||||
"content": "v4/guides/custom-js-css-guide"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ This is an anonymous JS function, it should return a **Promise**, that resolves
|
||||
The following example shows how to use the eval function to evaluate the response. The function checks if the status code is 2XX then the status is UP, if the status code is 5XX then the status is DOWN. If the response contains the word `Unknown Error` then the status is DOWN. If the response time is greater than 2000 then the status is DEGRADED.
|
||||
|
||||
```javascript
|
||||
;(async function (statusCode, responseTime, responseRaw, modules) {
|
||||
async function (statusCode, responseTime, responseRaw, modules) {
|
||||
let status = "DOWN"
|
||||
|
||||
//if the status code is 2XX then the status is UP
|
||||
@@ -94,7 +94,7 @@ The following example shows how to use the eval function to evaluate the respons
|
||||
status: status,
|
||||
latency: responseTime
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Example 2 {#example-2}
|
||||
@@ -102,7 +102,7 @@ The following example shows how to use the eval function to evaluate the respons
|
||||
This next example shows how to call another API withing eval. It is scrapping the second last script tag from the response and checking if the heading is "No recent issues" then the status is UP else it is DOWN.
|
||||
|
||||
```js
|
||||
;(async function (statusCode, responseTime, responseRaw, modules) {
|
||||
async function (statusCode, responseTime, responseRaw, modules) {
|
||||
let htmlString = responseRaw
|
||||
const scriptTags = htmlString.match(/<script[^>]*src="([^"]+)"[^>]*>/g)
|
||||
if (scriptTags && scriptTags.length >= 2) {
|
||||
@@ -126,7 +126,7 @@ This next example shows how to call another API withing eval. It is scrapping th
|
||||
status: "DOWN",
|
||||
latency: responseTime
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Example 3 {#example-3}
|
||||
@@ -134,7 +134,7 @@ This next example shows how to call another API withing eval. It is scrapping th
|
||||
The next example shows how to use cheerio to parse bitbucket status page and check if all the components are operational. If all the components are operational then the status is UP else it is DOWN.
|
||||
|
||||
```js
|
||||
;(async function (statusCode, responseTime, responseDataBase64, modules) {
|
||||
async function (statusCode, responseTime, responseDataBase64, modules) {
|
||||
let html = atob(responseDataBase64)
|
||||
const $ = modules.cheerio.load(html)
|
||||
const components = $(".components-section .components-container .component-container")
|
||||
@@ -150,7 +150,7 @@ The next example shows how to use cheerio to parse bitbucket status page and che
|
||||
status: status ? "UP" : "DOWN",
|
||||
latency: responseTime
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Examples {#examples}
|
||||
|
||||
@@ -50,12 +50,12 @@ This is an anonymous JS function, it should return a **Promise**, that resolves
|
||||
> `{status: "DEGRADED", latency: 200}`.
|
||||
|
||||
```javascript
|
||||
;(async function (responseTime, responseRaw) {
|
||||
async function (responseTime, responseRaw) {
|
||||
return {
|
||||
status: "UP",
|
||||
latency: responseTime
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- `responseTime` **REQUIRED** is a number. It is the latency in milliseconds
|
||||
|
||||
@@ -30,7 +30,7 @@ This is an anonymous JS function, it should return a **Promise**, that resolves
|
||||
> `{status:"DEGRADED", latency: 200}`.
|
||||
|
||||
```javascript
|
||||
;(async function (arrayOfPings) {
|
||||
async function (arrayOfPings) {
|
||||
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc + ping.latency
|
||||
}, 0)
|
||||
@@ -43,7 +43,7 @@ This is an anonymous JS function, it should return a **Promise**, that resolves
|
||||
status: alive ? "UP" : "DOWN",
|
||||
latency: latencyTotal / arrayOfPings.length
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- `arrayOfPings` **REQUIRED** is an array of Ping Response Objects as shown below.
|
||||
@@ -103,7 +103,7 @@ Each object in the array represents the ping response of a host.
|
||||
The following example shows how to use the eval function to evaluate the response. The function checks if the combined latency is more 10ms then returns `DEGRADED`.
|
||||
|
||||
```javascript
|
||||
;(async function (arrayOfPings) {
|
||||
async function (arrayOfPings) {
|
||||
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc + ping.latency
|
||||
}, 0)
|
||||
@@ -125,5 +125,5 @@ The following example shows how to use the eval function to evaluate the respons
|
||||
status: areAllOpen ? "UP" : "DOWN",
|
||||
latency: avgLatency
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
@@ -30,7 +30,7 @@ This is an anonymous JS function, it should return a **Promise**, that resolves
|
||||
> `{status:"DEGRADED", latency: 200}`.
|
||||
|
||||
```javascript
|
||||
;(async function (arrayOfPings) {
|
||||
async function (arrayOfPings) {
|
||||
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc + ping.latency
|
||||
}, 0)
|
||||
@@ -47,7 +47,7 @@ This is an anonymous JS function, it should return a **Promise**, that resolves
|
||||
status: alive ? "UP" : "DOWN",
|
||||
latency: latencyTotal / arrayOfPings.length
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- `arrayOfPings` **REQUIRED** is an array of TCP Response Objects as shown below.
|
||||
@@ -98,7 +98,7 @@ Each object in the array represents the tcp response of a host.
|
||||
The following example shows how to use the eval function to evaluate the response. The function checks if the combined latency is more 10ms then returns `DEGRADED`.
|
||||
|
||||
```javascript
|
||||
;(async function (arrayOfPings) {
|
||||
async function (arrayOfPings) {
|
||||
let latencyTotal = arrayOfPings.reduce((acc, ping) => {
|
||||
return acc + ping.latency
|
||||
}, 0)
|
||||
@@ -124,5 +124,5 @@ The following example shows how to use the eval function to evaluate the respons
|
||||
status: areAllOpen ? "UP" : "DOWN",
|
||||
latency: avgLatency
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
@@ -119,17 +119,63 @@ Uptime behavior is more configurable in v4, including formula-level flexibility
|
||||
|
||||
v4 includes broader frontend/runtime improvements, including more client-driven data interactions for monitor and operational views, with improved responsiveness and control.
|
||||
|
||||
## Known migration issues (v3 → v4) {#known-migration-issues}
|
||||
|
||||
Community-reported issues when upgrading from v3 to v4. Review these before migrating.
|
||||
|
||||
### Uploaded files and custom fonts return 404 {#uploads-return-404}
|
||||
|
||||
v3 served static files (images, fonts) from the `/uploads/` directory. v4 stores images in the database and serves them from `/assets/images/[id]`.
|
||||
|
||||
After upgrading, any URL referencing `/uploads/...` will return 404. This affects:
|
||||
|
||||
- Custom font files (`.woff2`, `.woff`, `.ttf`) referenced in Custom CSS
|
||||
- Any image previously uploaded via the v3 dashboard
|
||||
|
||||
**Fix:** Re-upload site images (logo, favicon, etc.) through the v4 dashboard (**Manage → Site**). For custom fonts, host them externally (CDN or self-hosted URL) and update your Custom CSS `@font-face` `src` URLs accordingly.
|
||||
|
||||
### Subscriptions and subscribers are not migrated {#subscriptions-not-migrated}
|
||||
|
||||
The subscription system was fully redesigned in v4 with new tables (`subscriber_users`, `subscriber_methods`, `user_subscriptions_v2`). Data from v3 tables (`subscribers`, `subscriptions`, `subscription_triggers`) is **not** automatically migrated.
|
||||
|
||||
**Fix:** After upgrading, re-configure subscription settings in the v4 dashboard. Existing subscribers will need to re-subscribe.
|
||||
|
||||
### Monitor timeout type error {#monitor-timeout-type-error}
|
||||
|
||||
Monitors migrated from v3 may store the `timeout` value as a string (e.g. `"30000"`) instead of a number. This causes a runtime error:
|
||||
|
||||
```
|
||||
"msecs" argument must be of type number. Received type string ('30000')
|
||||
```
|
||||
|
||||
**Fix:** Open each affected monitor in the v4 dashboard, verify the timeout value, and save. Re-saving converts the value to the correct numeric type.
|
||||
|
||||
### Monitors not visible on status pages {#monitors-not-visible}
|
||||
|
||||
v4 introduced a [Pages](/docs/v4/pages) model where monitors must be explicitly assigned to a page before they appear on any status page. After migration, existing monitors will not be visible until assigned.
|
||||
|
||||
**Fix:** Go to **Manage → Pages**, create or edit a page, and assign your monitors to it.
|
||||
|
||||
### Site images (logo, favicon) need re-upload {#site-images-reupload}
|
||||
|
||||
Site branding images (logo, favicon) uploaded in v3 are stored as file paths pointing to `/uploads/`. v4 expects these as database-stored images.
|
||||
|
||||
**Fix:** Go to **Manage → Site** and re-upload your logo and favicon.
|
||||
|
||||
## Upgrade checklist from v3 {#upgrade-checklist-from-v3}
|
||||
|
||||
Before promoting v4 to production:
|
||||
|
||||
1. Configure Redis (`REDIS_URL`).
|
||||
2. Update all heartbeat callers to `/ext/heartbeat/{tag}:{secret}`.
|
||||
3. Revalidate subscription data/flows against the new v4 system.
|
||||
4. Audit integrations/scripts for removed v3 API routes.
|
||||
5. Regenerate or re-import API clients from `static/api-references/v4.json`.
|
||||
6. Validate custom domain + `ORIGIN` and auth form behavior in production.
|
||||
7. If you used v3 categories, migrate display organization to v4 pages and verify monitor order per page.
|
||||
3. Re-upload site images (logo, favicon) via **Manage → Site** — v3 `/uploads/` paths no longer work.
|
||||
4. Assign monitors to pages via **Manage → Pages** — monitors are not visible until assigned.
|
||||
5. Open and re-save any monitors that used a custom timeout to fix string-to-number type issues.
|
||||
6. Re-configure subscriptions — v3 subscriber data is not migrated to the new v4 system.
|
||||
7. If you used custom fonts via `/uploads/`, host them externally and update Custom CSS `@font-face` URLs.
|
||||
8. Audit integrations/scripts for removed v3 API routes.
|
||||
9. Regenerate or re-import API clients from `static/api-references/v4.json`.
|
||||
10. Validate custom domain + `ORIGIN` and auth form behavior in production.
|
||||
|
||||
## See also {#see-also}
|
||||
|
||||
|
||||
@@ -13,10 +13,6 @@ Most of the default settings are designed to work out of the box, but you can cu
|
||||
|
||||
- **Site Name**: The name of your status page. This will be displayed in the header and title of the page.
|
||||
- **Site URL**: The URL of your status page. This is used for sharing and linking to your status page.
|
||||
- **Home Path**: The path to the home page of your status page. This is used for routing and navigation within your status page. By default, it is set to `/`, but you can change it to something else if you want.
|
||||
|
||||
> [!NOTE]
|
||||
> If you are hosting site on a subpath, make sure to set the Home Path to the correct value. For example, if your site is hosted at `https://example.com/status`, then you should set the Home Path to `/status`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Setting up the Site URL correctly is very important for Kener to function properly.
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Welcome to Kener - the modern, open-source status page system
|
||||
Kener is an open-source status page system designed to help you monitor and communicate the status of your services effectively.
|
||||
|
||||
> [!NOTE]
|
||||
> This documentation is for kener version 4x. There are few changes from version 3.x. Please refer to the [migration guide](/docs/migration/v3-to-v4) if you are upgrading from version 3.x. If you are looking for version 3.x documentation, you can find it [here](/docs/v3/home).
|
||||
> This documentation is for kener version 4x. There are few changes from version 3.x. Please refer to the [changelogs](/docs/v4/changelogs/v4.0.0) if you are upgrading from version 3.x. If you are looking for version 3.x documentation, you can find it [here](/docs/v3/home).
|
||||
|
||||
Kener is a simple status monitoring app that helps you keep your users informed about the status of your services. It is built with Node.js and Svelte, and uses Redis for caching.
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: How to Add Custom Fonts
|
||||
description: Use self-hosted or external font files with Kener via Custom CSS
|
||||
---
|
||||
|
||||
Kener supports two ways to use custom fonts on your status page:
|
||||
|
||||
1. **Font CSS URL** — point to a hosted stylesheet (Google Fonts, Bunny Fonts, etc.)
|
||||
2. **Custom CSS** — define `@font-face` rules for self-hosted font files
|
||||
|
||||
## Use a hosted font {#use-a-hosted-font}
|
||||
|
||||
If your font is available from a CDN (Google Fonts, Bunny Fonts, etc.):
|
||||
|
||||
1. Go to **Manage → Customizations**.
|
||||
2. In the **Font** card, set **Font CSS URL** to the stylesheet URL — e.g. `https://fonts.bunny.net/css?family=lato:400,700&display=swap`.
|
||||
3. Set **Font Family Name** to the font name — e.g. `Lato`.
|
||||
4. Click **Save Font**.
|
||||
|
||||
## Use a self-hosted font {#use-a-self-hosted-font}
|
||||
|
||||
If you want to use your own font files (`.woff2`, `.woff`, `.ttf`), host them on a CDN or static file server and reference them in Custom CSS.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Kener v4 does not serve files from a local `/uploads/` directory. Font files must be hosted at a publicly accessible URL.
|
||||
|
||||
1. Upload your font files to a CDN or static host and note the URLs.
|
||||
2. Go to **Manage → Customizations**.
|
||||
3. Clear the **Font CSS URL** and **Font Family Name** fields in the **Font** card and save.
|
||||
4. In the **Custom CSS** card, add your `@font-face` rules:
|
||||
|
||||
```css
|
||||
@font-face {
|
||||
font-family: "CustomFont";
|
||||
src:
|
||||
url("https://cdn.example.com/fonts/CustomFont.woff2") format("woff2"),
|
||||
url("https://cdn.example.com/fonts/CustomFont.woff") format("woff"),
|
||||
url("https://cdn.example.com/fonts/CustomFont.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
* {
|
||||
font-family: "CustomFont", sans-serif;
|
||||
}
|
||||
```
|
||||
|
||||
5. Click **Save Custom CSS**.
|
||||
|
||||
## Verify {#verify}
|
||||
|
||||
Open your public status page and confirm the font renders correctly. Use browser DevTools → **Computed** tab on any text element to confirm the applied `font-family`.
|
||||
|
||||
## Migrating from v3 {#migrating-from-v3}
|
||||
|
||||
In v3, font files could be placed in the `/uploads/` directory and referenced as `/uploads/CustomFont.woff2`. This path no longer works in v4.
|
||||
|
||||
**Fix:** Move your font files to an external host and update the `@font-face` `src` URLs accordingly.
|
||||
|
||||
## See also {#see-also}
|
||||
|
||||
- [Site Customizations](/docs/v4/setup/customizations)
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
title: Custom JS and CSS Guide
|
||||
description: Add custom JavaScript and CSS to your Kener instance with static files or inline CSS
|
||||
---
|
||||
|
||||
Use this guide to customize Kener with your own JavaScript and CSS.
|
||||
|
||||
## Add custom JavaScript {#add-custom-javascript}
|
||||
|
||||
1. Add your JS file to `static/` (for example `static/custom.js`).
|
||||
2. Open `src/app.html`.
|
||||
3. Add your script tag before `</body>`:
|
||||
|
||||
```html
|
||||
<script src="/custom.js"></script>
|
||||
```
|
||||
|
||||
## Add custom CSS {#add-custom-css}
|
||||
|
||||
You can add CSS using either a file or inline CSS.
|
||||
|
||||
### Option 1: CSS file {#option-1-css-file}
|
||||
|
||||
1. Add your CSS file to `static/` (for example `static/custom.css`).
|
||||
2. Open `src/app.html`.
|
||||
3. Add this in `<head>`:
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="/custom.css" />
|
||||
```
|
||||
|
||||
If you use a subpath deployment, include the base path in the asset URL.
|
||||
|
||||
Example with `/status` base path:
|
||||
|
||||
```html
|
||||
<script src="/status/custom.js"></script>
|
||||
<link rel="stylesheet" href="/status/custom.css" />
|
||||
```
|
||||
|
||||
For subpath setup details, see [Base Path Deployment](/docs/v4/guides/base-path).
|
||||
|
||||
### Option 2: Inline CSS in dashboard {#option-2-inline-css}
|
||||
|
||||
1. Go to **Manage → Customizations → Custom CSS**.
|
||||
2. Add raw CSS.
|
||||
3. Click **Save Custom CSS**.
|
||||
|
||||
```css
|
||||
.my-class {
|
||||
color: red;
|
||||
}
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
> Do not include `<style>` tags in the Custom CSS editor.
|
||||
|
||||
## Card border radius example {#card-border-radius-example}
|
||||
|
||||
Use this CSS to make all card components square (border radius `0`) without Tailwind utility classes:
|
||||
|
||||
```css
|
||||
.rounded-3xl,
|
||||
.rounded-xl,
|
||||
.rounded-2xl,
|
||||
.rounded-md,
|
||||
.rounded-btn,
|
||||
.rounded-full {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
```
|
||||
|
||||
## Verify changes {#verify-changes}
|
||||
|
||||
1. Hard-refresh your status page.
|
||||
2. Open browser DevTools and confirm custom JS/CSS files load without 404 errors.
|
||||
3. Confirm your visual changes are applied.
|
||||
@@ -23,7 +23,7 @@ Use **Manage → Customizations** to control visual behavior of the public statu
|
||||
| Font | `font` (`cssSrc`, `family`) | Loads font stylesheet and applies global `--font-family` |
|
||||
| Theme defaults | `theme`, `themeToggle` | Sets initial mode and controls whether users can toggle theme |
|
||||
| Announcement | `announcement` | Renders site banner when title + message are present |
|
||||
| Custom CSS | `customCSS` | Saved in site data, not yet injected into public layout |
|
||||
| Custom CSS | `customCSS` | Injected as `<style>` block in public layout `<head>` |
|
||||
|
||||
## Footer HTML {#footer-html}
|
||||
|
||||
@@ -46,11 +46,13 @@ Color settings feed CSS variables for status/accent colors in the public layout.
|
||||
- `themeToggle` controls whether users can switch theme in UI
|
||||
- announcement banner appears when both `title` and `message` are set
|
||||
|
||||
## Custom CSS status {#custom-css-status}
|
||||
## Custom CSS {#custom-css-status}
|
||||
|
||||
`customCSS` is already persisted through site data APIs, but public layout injection is not enabled yet.
|
||||
`customCSS` is injected as a `<style>` block in the public layout `<head>`. Do not include `<style>` tags — only raw CSS.
|
||||
|
||||
Planned approach: append a `<style>` block in public layout head using saved `customCSS` (after validation/sanitization policy is finalized).
|
||||
Use Custom CSS to override styles, add `@font-face` rules for self-hosted fonts, or customize the status page appearance beyond the built-in settings.
|
||||
|
||||
See [Custom Fonts guide](/docs/v4/guides/custom-fonts) for font-specific examples.
|
||||
|
||||
## Verify changes {#verify-changes}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Site Configuration
|
||||
description: Configure core site settings and understand where they affect runtime behavior
|
||||
---
|
||||
|
||||
Use **Manage → Site Configurations** to control identity, navigation, monitor sharing controls, retention, and event visibility.
|
||||
Use **Manage → Site Configurations** to control identity, navigation, monitor sharing controls, page visibility behavior, retention, and event visibility.
|
||||
|
||||
## Quick setup {#quick-setup}
|
||||
|
||||
@@ -11,18 +11,19 @@ Use **Manage → Site Configurations** to control identity, navigation, monitor
|
||||
2. Save **Site Information** (`siteName`, `siteURL`, logo, favicon).
|
||||
3. Configure **Navigation Menu**.
|
||||
4. Set **Monitor Sub Menu Options**.
|
||||
5. Configure **Data Retention Policy**.
|
||||
6. Configure **Event Display Settings**.
|
||||
5. Configure **Global Page Visibility Settings**.
|
||||
6. Configure **Data Retention Policy**.
|
||||
7. Configure **Event Display Settings**.
|
||||
|
||||
## Runtime impact map {#runtime-impact-map}
|
||||
|
||||
| Setting area | Stored key | Runtime impact |
|
||||
| ---------------------------- | ------------------------------------ | ------------------------------------------------------------------------ |
|
||||
| Site name / URL / logo / nav | `siteName`, `siteURL`, `logo`, `nav` | Rendered in top navbar branding and nav links |
|
||||
| Favicon | `favicon` | Used in `<head>` as page icon |
|
||||
| Monitor sub menu options | `subMenuOptions` | Gates monitor share actions (badges/embed) on public monitor pages |
|
||||
| Data retention policy | `dataRetentionPolicy` | Controls daily cleanup of old `monitoring_data` |
|
||||
| Event display settings | `eventDisplaySettings` | Filters incidents/maintenances returned for dashboard/home notifications |
|
||||
| Setting area | Stored key | Runtime impact |
|
||||
| ---------------------------- | ------------------------------------ | ------------------------------------------------------------------- |
|
||||
| Site name / URL / logo / nav | `siteName`, `siteURL`, `logo`, `nav` | Rendered in top navbar branding and nav links |
|
||||
| Favicon | `favicon` | Used in `<head>` as page icon |
|
||||
| Monitor sub menu options | `subMenuOptions` | Gates monitor share actions (badges/embed) on public monitor pages |
|
||||
| Global page visibility | `globalPageVisibilitySettings` | Controls page switcher visibility and page-scoped navigation/events |
|
||||
| Data retention policy | `dataRetentionPolicy` | Controls daily cleanup of old `monitoring_data` |
|
||||
|
||||
## Monitor sub menu options {#monitor-sub-menu-options}
|
||||
|
||||
@@ -36,6 +37,28 @@ These site-level flags control share actions globally:
|
||||
|
||||
See [Sharing Monitors](/docs/v4/sharing).
|
||||
|
||||
## Global page visibility settings {#global-page-visibility-settings}
|
||||
|
||||
`globalPageVisibilitySettings` has two flags:
|
||||
|
||||
- `showSwitcher: boolean`
|
||||
- Controls whether the page switcher dropdown is visible in the top controls.
|
||||
- `true`: users can switch pages from the selector.
|
||||
- `false`: page selector is hidden.
|
||||
|
||||
- `forceExclusivity: boolean`
|
||||
- Enables page-scoped behavior for navigation/events.
|
||||
- In the dashboard UI, enabling this automatically sets `showSwitcher` to `true` and locks it as read-only.
|
||||
- Runtime effects:
|
||||
- navbar brand/logo click resolves to the current page path (`/{page_path}`) instead of global root,
|
||||
- notifications calendar link resolves to page-scoped events (`/{page_path}/events/{MMMM-yyyy}`),
|
||||
- events-by-month API is filtered to monitors assigned to that page (global incidents/maintenances still appear).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `forceExclusivity` takes precedence over `showSwitcher` behavior for page-scoped navigation/event flows.
|
||||
|
||||
See [Pages](/docs/v4/pages).
|
||||
|
||||
## Data retention policy {#data-retention-policy}
|
||||
|
||||
`dataRetentionPolicy` drives the daily cleanup scheduler:
|
||||
@@ -61,5 +84,9 @@ This affects:
|
||||
|
||||
- Update site name/logo/nav and refresh home page.
|
||||
- Toggle monitor share options and verify Badge/Embed actions on a monitor page.
|
||||
- Toggle `showSwitcher` and verify the page selector appears/disappears.
|
||||
- Enable `forceExclusivity` and verify:
|
||||
- brand link stays within current page path,
|
||||
- notifications calendar opens page-scoped events for the current month.
|
||||
- Change event display settings and verify incident/maintenance visibility.
|
||||
- Set retention policy and confirm scheduler logs in server output.
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
const SPEC_PATH = join(process.cwd(), "static", "api-references", "v4.json");
|
||||
const DEV_PATH = join(process.cwd(), "static", "api-references", "v4.json");
|
||||
const PROD_PATH = join(process.cwd(), "build", "client", "api-references", "v4.json");
|
||||
const SPEC_PATH = existsSync(PROD_PATH) ? PROD_PATH : DEV_PATH;
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { PageServerLoad } from "./$types";
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { parse, isValid, getYear, startOfMonth, endOfMonth, addMonths, getUnixTime } from "date-fns";
|
||||
import { GetPageByPathWithMonitors } from "$lib/server/controllers/controller.js";
|
||||
|
||||
const MIN_YEAR = 2023;
|
||||
|
||||
export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
const parentData = await parent();
|
||||
|
||||
// Parse month parameter (format: MMMM-YYYY e.g. "January-2026")
|
||||
const monthParam = `${params.MMMM}-${params.YYYY}`;
|
||||
|
||||
let parsedDate: Date;
|
||||
try {
|
||||
parsedDate = parse(monthParam, "MMMM-yyyy", new Date());
|
||||
if (!isValid(parsedDate)) {
|
||||
throw error(404, "Invalid date format");
|
||||
}
|
||||
} catch (e) {
|
||||
throw error(404, "Invalid date format");
|
||||
}
|
||||
|
||||
const year = getYear(parsedDate);
|
||||
const currentDate = new Date();
|
||||
const maxDate = addMonths(currentDate, 12);
|
||||
const maxYear = getYear(maxDate);
|
||||
|
||||
if (year < MIN_YEAR || year > maxYear) {
|
||||
throw error(404, "Date out of allowed range");
|
||||
}
|
||||
|
||||
if (year === maxYear && parsedDate > maxDate) {
|
||||
throw error(404, "Date out of allowed range");
|
||||
}
|
||||
|
||||
// Calculate month timestamps
|
||||
const monthStart = startOfMonth(parsedDate);
|
||||
const monthEnd = endOfMonth(parsedDate);
|
||||
const monthStartTs = getUnixTime(monthStart);
|
||||
const monthEndTs = getUnixTime(monthEnd) + 86399; // End of the last day
|
||||
|
||||
return {
|
||||
...{ monthParam, monthStartTs, monthEndTs },
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,292 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { Spinner } from "$lib/components/ui/spinner/index.js";
|
||||
import * as Card from "$lib/components/ui/card/index.js";
|
||||
import ThemePlus from "$lib/components/ThemePlus.svelte";
|
||||
import IncidentItem from "$lib/components/IncidentItem.svelte";
|
||||
import MaintenanceItem from "$lib/components/MaintenanceItem.svelte";
|
||||
import ArrowLeft from "@lucide/svelte/icons/arrow-left";
|
||||
import ArrowRight from "@lucide/svelte/icons/arrow-right";
|
||||
import Check from "@lucide/svelte/icons/check";
|
||||
import ICONS from "$lib/icons";
|
||||
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 { format, parse, addMonths, subMonths, getUnixTime, startOfDay, formatDistanceStrict } from "date-fns";
|
||||
import { page } from "$app/state";
|
||||
import type { IncidentForMonitorListWithComments, MaintenanceEventsMonitorList } from "$lib/server/types/db";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const MIN_YEAR = 2023;
|
||||
|
||||
// State
|
||||
let loading = $state(true);
|
||||
let incidents = $state<IncidentForMonitorListWithComments[]>([]);
|
||||
let maintenances = $state<MaintenanceEventsMonitorList[]>([]);
|
||||
|
||||
// Parse the current month from params (derived from reactive data)
|
||||
const parsedDate = $derived(parse(data.monthParam, "MMMM-yyyy", new Date()));
|
||||
const currentMonth = $derived(format(parsedDate, "MMMM yyyy"));
|
||||
|
||||
// Navigation (derived from reactive parsedDate)
|
||||
const prevMonth = $derived(subMonths(parsedDate, 1));
|
||||
const nextMonth = $derived(addMonths(parsedDate, 1));
|
||||
const prevMonthPath = $derived(format(prevMonth, "MMMM-yyyy"));
|
||||
const nextMonthPath = $derived(format(nextMonth, "MMMM-yyyy"));
|
||||
|
||||
// Determine if navigation buttons should show
|
||||
const currentDate = new Date();
|
||||
const maxDate = addMonths(currentDate, 12);
|
||||
const minDate = new Date(MIN_YEAR, 0, 1);
|
||||
const showPrevButton = $derived(prevMonth >= minDate);
|
||||
const showNextButton = $derived(nextMonth <= maxDate);
|
||||
|
||||
// Counts
|
||||
let numberOfIncidents = $derived(incidents.length);
|
||||
let numberOfMaintenances = $derived(maintenances.length);
|
||||
|
||||
// Unified event type for display
|
||||
interface DisplayEvent {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
start_date_time: number;
|
||||
end_date_time: number | null;
|
||||
type: "incident" | "maintenance";
|
||||
incident?: IncidentForMonitorListWithComments;
|
||||
maintenance?: MaintenanceEventsMonitorList;
|
||||
monitors?: Array<{
|
||||
monitor_tag: string;
|
||||
monitor_impact: string;
|
||||
monitor_name: string;
|
||||
monitor_image: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Group events by day
|
||||
interface DayEvents {
|
||||
date: number; // Unix timestamp of start of day
|
||||
events: DisplayEvent[];
|
||||
}
|
||||
|
||||
let eventsByDay = $derived.by(() => {
|
||||
const allEvents: DisplayEvent[] = [];
|
||||
|
||||
// Add incidents
|
||||
for (const incident of incidents) {
|
||||
allEvents.push({
|
||||
id: incident.id,
|
||||
title: incident.title,
|
||||
start_date_time: incident.start_date_time,
|
||||
end_date_time: incident.end_date_time,
|
||||
type: "incident",
|
||||
incident,
|
||||
monitors: incident.monitors
|
||||
});
|
||||
}
|
||||
|
||||
// Add maintenances
|
||||
for (const maintenance of maintenances) {
|
||||
allEvents.push({
|
||||
id: maintenance.id,
|
||||
title: maintenance.title,
|
||||
description: maintenance.description,
|
||||
start_date_time: maintenance.start_date_time,
|
||||
end_date_time: maintenance.end_date_time,
|
||||
type: "maintenance",
|
||||
maintenance,
|
||||
monitors: maintenance.monitors
|
||||
});
|
||||
}
|
||||
|
||||
// Group by day
|
||||
const dayMap = new Map<number, DisplayEvent[]>();
|
||||
for (const event of allEvents) {
|
||||
const dayStart = getUnixTime(startOfDay(new Date(event.start_date_time * 1000)));
|
||||
if (!dayMap.has(dayStart)) {
|
||||
dayMap.set(dayStart, []);
|
||||
}
|
||||
dayMap.get(dayStart)!.push(event);
|
||||
}
|
||||
|
||||
// Sort days descending (newest first)
|
||||
const sortedDays = Array.from(dayMap.keys()).sort((a, b) => b - a);
|
||||
|
||||
const result: DayEvents[] = sortedDays.map((dayTs) => ({
|
||||
date: dayTs,
|
||||
events: dayMap.get(dayTs)!.sort((a, b) => b.start_date_time - a.start_date_time)
|
||||
}));
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
async function fetchEvents() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
clientResolver(resolve, "/dashboard-apis/events-by-month") + `?page_path=${page.params.page_path}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
start_ts: data.monthStartTs,
|
||||
end_ts: data.monthEndTs
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
incidents = result.incidents || [];
|
||||
maintenances = result.maintenances || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch events:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchEvents();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{currentMonth} - Maintenances & Incidents - {data.siteName}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Header with back button -->
|
||||
|
||||
<ThemePlus />
|
||||
|
||||
<div class="flex flex-col gap-4 sm:flex-row">
|
||||
<div class="flex flex-row justify-start gap-y-3 rounded-3xl border p-4">
|
||||
<div class="flex flex-1 flex-row items-center justify-center gap-4">
|
||||
<div class="flex w-full flex-row items-center justify-between gap-4">
|
||||
<Button
|
||||
rel="external"
|
||||
variant="outline"
|
||||
class="size-8 rounded-full p-0 shadow-none"
|
||||
href={clientResolver(resolve, `/events/${prevMonthPath}`)}
|
||||
>
|
||||
<ICONS.CHEVRON_LEFT class="size-5" />
|
||||
</Button>
|
||||
<p class="text-2xl">{$formatDate(parsedDate, "MMMM yyyy")}</p>
|
||||
<Button
|
||||
rel="external"
|
||||
href={clientResolver(resolve, `/events/${nextMonthPath}`)}
|
||||
variant="outline"
|
||||
class="size-8 rounded-full p-0 shadow-none"
|
||||
>
|
||||
<ICONS.CHEVRON_RIGHT class="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col justify-around gap-y-4 rounded-3xl border p-4">
|
||||
<div class="flex flex-wrap gap-x-3">
|
||||
<!-- Incidents in this page -->
|
||||
<div class="flex flex-1 flex-row items-center gap-2">
|
||||
{#if numberOfIncidents === 0}
|
||||
<Check class="text-up" />
|
||||
{:else}
|
||||
<p class="text-3xl">
|
||||
{numberOfIncidents}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-sm leading-4 font-medium">
|
||||
{@html $t("Total Incidents")}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Maintenances in this page -->
|
||||
<div class="flex flex-1 flex-row items-center gap-2">
|
||||
{#if numberOfMaintenances === 0}
|
||||
<Check class="text-up" />
|
||||
{:else}
|
||||
<p class="text-3xl">
|
||||
{numberOfMaintenances}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-sm leading-4 font-medium">{@html $t("Total Maintenances")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<Spinner class="size-8" />
|
||||
</div>
|
||||
{:else if eventsByDay.length === 0}
|
||||
<!-- No Events -->
|
||||
<Card.Root class="rounded-3xl border bg-transparent shadow-none">
|
||||
<Card.Content class=" py-12 text-center">
|
||||
<div class="mx-auto mb-4 text-4xl">🎉</div>
|
||||
<h2 class="text-xl font-semibold">
|
||||
{$t("No Events in %currentMonth", { currentMonth })}
|
||||
</h2>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
{$t("There are no incidents or maintenances scheduled for this month.")}
|
||||
</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
<!-- Events grouped by day -->
|
||||
{#each eventsByDay as day}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="bg-secondary mt-4 w-fit rounded-3xl border px-4 py-2 text-xs font-medium">
|
||||
{$formatDate(day.date, "EEEE, MMMM do")}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each day.events as event}
|
||||
<div class="flex flex-col gap-2 rounded-3xl border p-4">
|
||||
{#if event.type === "incident" && event.incident}
|
||||
<IncidentItem incident={event.incident} />
|
||||
{:else if event.maintenance}
|
||||
<!-- Maintenance -->
|
||||
<MaintenanceItem maintenance={event.maintenance} />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Navigation buttons -->
|
||||
<div class="flex justify-between">
|
||||
{#if showPrevButton}
|
||||
<Button
|
||||
variant="outline"
|
||||
rel="external"
|
||||
class="rounded-full shadow-none"
|
||||
href={clientResolver(resolve, `/events/${prevMonthPath}`)}
|
||||
>
|
||||
<ArrowLeft class="mr-2 h-4 w-4" />
|
||||
{$formatDate(prevMonth, "MMMM yyyy")}
|
||||
</Button>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
{#if showNextButton}
|
||||
<Button
|
||||
variant="outline"
|
||||
rel="external"
|
||||
class="rounded-full shadow-none"
|
||||
href={clientResolver(resolve, `/events/${nextMonthPath}`)}
|
||||
>
|
||||
{$formatDate(nextMonth, "MMMM yyyy")}
|
||||
<ArrowRight class="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,16 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||
import { Spinner } from "$lib/components/ui/spinner/index.js";
|
||||
import * as Card from "$lib/components/ui/card/index.js";
|
||||
import * as Item from "$lib/components/ui/item/index.js";
|
||||
import ThemePlus from "$lib/components/ThemePlus.svelte";
|
||||
import IncidentItem from "$lib/components/IncidentItem.svelte";
|
||||
import MaintenanceItem from "$lib/components/MaintenanceItem.svelte";
|
||||
import ArrowLeft from "@lucide/svelte/icons/arrow-left";
|
||||
import ArrowRight from "@lucide/svelte/icons/arrow-right";
|
||||
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
|
||||
import Check from "@lucide/svelte/icons/check";
|
||||
import ICONS from "$lib/icons";
|
||||
import { t } from "$lib/stores/i18n";
|
||||
@@ -18,7 +15,7 @@
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver 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";
|
||||
|
||||
let { data } = $props();
|
||||
@@ -30,22 +27,22 @@
|
||||
let incidents = $state<IncidentForMonitorListWithComments[]>([]);
|
||||
let maintenances = $state<MaintenanceEventsMonitorList[]>([]);
|
||||
|
||||
// Parse the current month from params
|
||||
const parsedDate = parse(data.monthParam, "MMMM-yyyy", new Date());
|
||||
const currentMonth = format(parsedDate, "MMMM yyyy");
|
||||
// Parse the current month from params (derived from reactive data)
|
||||
const parsedDate = $derived(parse(data.monthParam, "MMMM-yyyy", new Date()));
|
||||
const currentMonth = $derived(format(parsedDate, "MMMM yyyy"));
|
||||
|
||||
// Navigation
|
||||
const prevMonth = subMonths(parsedDate, 1);
|
||||
const nextMonth = addMonths(parsedDate, 1);
|
||||
const prevMonthPath = format(prevMonth, "MMMM-yyyy");
|
||||
const nextMonthPath = format(nextMonth, "MMMM-yyyy");
|
||||
// Navigation (derived from reactive parsedDate)
|
||||
const prevMonth = $derived(subMonths(parsedDate, 1));
|
||||
const nextMonth = $derived(addMonths(parsedDate, 1));
|
||||
const prevMonthPath = $derived(format(prevMonth, "MMMM-yyyy"));
|
||||
const nextMonthPath = $derived(format(nextMonth, "MMMM-yyyy"));
|
||||
|
||||
// Determine if navigation buttons should show
|
||||
const currentDate = new Date();
|
||||
const maxDate = addMonths(currentDate, 12);
|
||||
const minDate = new Date(MIN_YEAR, 0, 1);
|
||||
const showPrevButton = prevMonth >= minDate;
|
||||
const showNextButton = nextMonth <= maxDate;
|
||||
const showPrevButton = $derived(prevMonth >= minDate);
|
||||
const showNextButton = $derived(nextMonth <= maxDate);
|
||||
|
||||
// Counts
|
||||
let numberOfIncidents = $derived(incidents.length);
|
||||
@@ -129,14 +126,17 @@
|
||||
async function fetchEvents() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch(clientResolver(resolve, "/dashboard-apis/events-by-month"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
start_ts: data.monthStartTs,
|
||||
end_ts: data.monthEndTs
|
||||
})
|
||||
});
|
||||
const response = await fetch(
|
||||
clientResolver(resolve, "/dashboard-apis/events-by-month") + `?page_path=${page.params.page_path}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
start_ts: data.monthStartTs,
|
||||
end_ts: data.monthEndTs
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
|
||||
@@ -104,13 +104,6 @@ import {
|
||||
AdminDeleteSubscriber,
|
||||
AdminAddSubscriber,
|
||||
} from "$lib/server/controllers/userSubscriptionsController.js";
|
||||
import {
|
||||
GetAllSecrets,
|
||||
GetSecretById,
|
||||
CreateSecret,
|
||||
UpdateSecret,
|
||||
DeleteSecret,
|
||||
} from "$lib/server/controllers/vaultController.js";
|
||||
import {
|
||||
GetAllGeneralEmailTemplates,
|
||||
GetGeneralEmailTemplateById,
|
||||
@@ -635,48 +628,6 @@ export async function POST({ request, cookies }) {
|
||||
if (!resp.success) {
|
||||
throw new Error(resp.error);
|
||||
}
|
||||
}
|
||||
// ============ Vault ============
|
||||
else if (action == "getVaultSecrets") {
|
||||
AdminCan(userDB.role);
|
||||
resp = await GetAllSecrets();
|
||||
} else if (action == "getVaultSecret") {
|
||||
AdminCan(userDB.role);
|
||||
const { id } = data;
|
||||
if (!id) {
|
||||
throw new Error("Secret ID is required");
|
||||
}
|
||||
resp = await GetSecretById(id);
|
||||
if (!resp) {
|
||||
throw new Error("Secret not found");
|
||||
}
|
||||
} else if (action == "createVaultSecret") {
|
||||
AdminCan(userDB.role);
|
||||
const { secret_name, secret_value } = data;
|
||||
resp = await CreateSecret(secret_name, secret_value);
|
||||
if (!resp.success) {
|
||||
throw new Error(resp.error);
|
||||
}
|
||||
} else if (action == "updateVaultSecret") {
|
||||
AdminCan(userDB.role);
|
||||
const { id, secret_name, secret_value } = data;
|
||||
if (!id) {
|
||||
throw new Error("Secret ID is required");
|
||||
}
|
||||
resp = await UpdateSecret(id, { secret_name, secret_value });
|
||||
if (!resp.success) {
|
||||
throw new Error(resp.error);
|
||||
}
|
||||
} else if (action == "deleteVaultSecret") {
|
||||
AdminCan(userDB.role);
|
||||
const { id } = data;
|
||||
if (!id) {
|
||||
throw new Error("Secret ID is required");
|
||||
}
|
||||
resp = await DeleteSecret(id);
|
||||
if (!resp.success) {
|
||||
throw new Error(resp.error);
|
||||
}
|
||||
} else if (action == "getSubscriptionsConfig") {
|
||||
AdminCan(userDB.role);
|
||||
let subscriptionsSettings = await GetSiteDataByKey("subscriptionsSettings");
|
||||
|
||||
@@ -620,7 +620,7 @@
|
||||
<p class="text-muted-foreground mt-4 text-sm">
|
||||
Want to upload and use custom fonts? Read more about it in the
|
||||
<a
|
||||
href="https//kener.ing/docs/v4/guides/custom-fonts"
|
||||
href={clientResolver(resolve, "/docs/v4/guides/custom-fonts")}
|
||||
target="_blank"
|
||||
class="text-foreground underline underline-offset-4"
|
||||
>
|
||||
@@ -785,7 +785,7 @@
|
||||
Add custom CSS to further customize the appearance of your status page. Do not include <style> tags.
|
||||
Learn more in the
|
||||
<a
|
||||
href="https//kener.ing/docs/v4/guides/custom-js-css-guide"
|
||||
href={clientResolver(resolve, "/docs/v4/guides/custom-js-css-guide")}
|
||||
target="_blank"
|
||||
class="text-foreground underline underline-offset-4"
|
||||
>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { toast } from "svelte-sonner";
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import type { DataRetentionPolicy, EventDisplaySettings } from "$lib/types/site.js";
|
||||
import type { DataRetentionPolicy, EventDisplaySettings, GlobalPageVisibilitySettings } from "$lib/types/site.js";
|
||||
|
||||
interface NavItem {
|
||||
name: string;
|
||||
@@ -33,6 +33,7 @@
|
||||
let savingSocialPreviewImage = $state(false);
|
||||
let savingNav = $state(false);
|
||||
let savingSubMenuOptions = $state(false);
|
||||
let savingGlobalPageVisibilitySettings = $state(false);
|
||||
let savingDataRetentionPolicy = $state(false);
|
||||
let savingEventDisplaySettings = $state(false);
|
||||
let uploadingLogo = $state(false);
|
||||
@@ -58,7 +59,6 @@
|
||||
interface SiteDataForm {
|
||||
siteName: string;
|
||||
siteURL: string;
|
||||
home: string;
|
||||
logo: string;
|
||||
favicon: string;
|
||||
socialPreviewImage: string | null;
|
||||
@@ -68,7 +68,6 @@
|
||||
let siteData = $state<SiteDataForm>({
|
||||
siteName: "",
|
||||
siteURL: "",
|
||||
home: "/",
|
||||
logo: "",
|
||||
favicon: "",
|
||||
socialPreviewImage: null
|
||||
@@ -83,6 +82,15 @@
|
||||
showShareEmbedMonitor: true
|
||||
});
|
||||
|
||||
const defaultGlobalPageVisibilitySettings: GlobalPageVisibilitySettings = {
|
||||
showSwitcher: true,
|
||||
forceExclusivity: false
|
||||
};
|
||||
|
||||
let globalPageVisibilitySettings = $state<GlobalPageVisibilitySettings>(
|
||||
structuredClone(defaultGlobalPageVisibilitySettings)
|
||||
);
|
||||
|
||||
let dataRetentionPolicy = $state<DataRetentionPolicy>({
|
||||
enabled: true,
|
||||
retentionDays: 90
|
||||
@@ -91,6 +99,14 @@
|
||||
let eventDisplaySettings = $state<EventDisplaySettings>(structuredClone(defaultEventDisplaySettings));
|
||||
let currentOrigin = $state("");
|
||||
|
||||
function onForceExclusivityChange(checked: boolean | "indeterminate") {
|
||||
const enabled = checked === true;
|
||||
globalPageVisibilitySettings.forceExclusivity = enabled;
|
||||
if (enabled) {
|
||||
globalPageVisibilitySettings.showSwitcher = true;
|
||||
}
|
||||
}
|
||||
|
||||
function parseOriginOnlyURL(value: string): URL | null {
|
||||
try {
|
||||
const trimmedValue = value.trim();
|
||||
@@ -116,10 +132,7 @@
|
||||
|
||||
// Validation
|
||||
const isValidSiteInfo = $derived(
|
||||
siteData.siteName.trim().length > 0 &&
|
||||
siteData.siteURL.trim().length > 0 &&
|
||||
isOriginOnlySiteURL &&
|
||||
siteData.home.trim().length > 0
|
||||
siteData.siteName.trim().length > 0 && siteData.siteURL.trim().length > 0 && isOriginOnlySiteURL
|
||||
);
|
||||
|
||||
async function fetchSiteData() {
|
||||
@@ -135,7 +148,6 @@
|
||||
siteData = {
|
||||
siteName: data.siteName || "",
|
||||
siteURL: data.siteURL || "",
|
||||
home: data.home || "/",
|
||||
logo: data.logo || "",
|
||||
favicon: data.favicon || "",
|
||||
socialPreviewImage: data.socialPreviewImage || null
|
||||
@@ -153,6 +165,27 @@
|
||||
showShareEmbedMonitor: data.subMenuOptions.showShareEmbedMonitor ?? true
|
||||
};
|
||||
}
|
||||
|
||||
if (data.globalPageVisibilitySettings) {
|
||||
try {
|
||||
const parsed =
|
||||
typeof data.globalPageVisibilitySettings === "string"
|
||||
? JSON.parse(data.globalPageVisibilitySettings)
|
||||
: data.globalPageVisibilitySettings;
|
||||
|
||||
globalPageVisibilitySettings = {
|
||||
...structuredClone(defaultGlobalPageVisibilitySettings),
|
||||
...parsed,
|
||||
showSwitcher: Boolean(parsed?.showSwitcher ?? true),
|
||||
forceExclusivity: Boolean(parsed?.forceExclusivity ?? false)
|
||||
};
|
||||
} catch {
|
||||
globalPageVisibilitySettings = structuredClone(defaultGlobalPageVisibilitySettings);
|
||||
}
|
||||
} else {
|
||||
globalPageVisibilitySettings = structuredClone(defaultGlobalPageVisibilitySettings);
|
||||
}
|
||||
|
||||
dataRetentionPolicy = {
|
||||
enabled: data.dataRetentionPolicy?.enabled ?? true,
|
||||
retentionDays: data.dataRetentionPolicy?.retentionDays ?? 90
|
||||
@@ -191,8 +224,7 @@
|
||||
action: "storeSiteData",
|
||||
data: {
|
||||
siteName: siteData.siteName,
|
||||
siteURL: siteData.siteURL,
|
||||
home: siteData.home
|
||||
siteURL: siteData.siteURL
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -338,6 +370,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGlobalPageVisibilitySettings() {
|
||||
savingGlobalPageVisibilitySettings = true;
|
||||
try {
|
||||
const payload: GlobalPageVisibilitySettings = {
|
||||
showSwitcher: globalPageVisibilitySettings.forceExclusivity ? true : globalPageVisibilitySettings.showSwitcher,
|
||||
forceExclusivity: globalPageVisibilitySettings.forceExclusivity
|
||||
};
|
||||
|
||||
const response = await fetch(clientResolver(resolve, "/manage/api"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "storeSiteData",
|
||||
data: { globalPageVisibilitySettings: JSON.stringify(payload) }
|
||||
})
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
globalPageVisibilitySettings = payload;
|
||||
toast.success("Global page visibility settings saved successfully");
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error("Failed to save global page visibility settings");
|
||||
} finally {
|
||||
savingGlobalPageVisibilitySettings = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDataRetentionPolicy() {
|
||||
savingDataRetentionPolicy = true;
|
||||
try {
|
||||
@@ -593,15 +655,6 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Home Path -->
|
||||
<div class="space-y-2">
|
||||
<Label for="home">Home Path *</Label>
|
||||
<Input id="home" type="text" bind:value={siteData.home} placeholder="/" />
|
||||
<p class="text-muted-foreground text-xs">
|
||||
The path users are redirected to when clicking the logo (e.g., "/" or "/status")
|
||||
</p>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-end">
|
||||
<Button onclick={saveSiteInfo} disabled={savingSiteInfo || !isValidSiteInfo} class="cursor-pointer">
|
||||
@@ -934,6 +987,55 @@
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Global Page Visibility Settings Card -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Global Page Visibility Settings</Card.Title>
|
||||
<Card.Description>
|
||||
Configure page switcher visibility and global exclusivity behavior for page-linked content.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<Label>Show page switcher</Label>
|
||||
<p class="text-muted-foreground text-xs">This will hide the pages dropdown from the menu.</p>
|
||||
</div>
|
||||
<Switch
|
||||
bind:checked={globalPageVisibilitySettings.showSwitcher}
|
||||
disabled={globalPageVisibilitySettings.forceExclusivity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<Label>Force exclusivity</Label>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
This sets <code>showSwitcher</code> to true and makes it read-only. It also enables brand icon link
|
||||
overwrite and calendar event updates for affected monitors. Global events (incidents and maintenances with
|
||||
<code>is_global=YES</code>) are still shown.
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={globalPageVisibilitySettings.forceExclusivity} onCheckedChange={onForceExclusivityChange} />
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex justify-end">
|
||||
<Button
|
||||
onclick={saveGlobalPageVisibilitySettings}
|
||||
disabled={savingGlobalPageVisibilitySettings}
|
||||
class="cursor-pointer"
|
||||
>
|
||||
{#if savingGlobalPageVisibilitySettings}
|
||||
<Loader class="h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
{:else}
|
||||
<SaveIcon class="h-4 w-4" />
|
||||
Save
|
||||
{/if}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Data Retention Policy Card -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
|
||||
Reference in New Issue
Block a user